From 5e4b79961e7d764ed65d648e95abc2eff0295721 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 09:32:58 +0700 Subject: [PATCH 001/113] fix(drive-abci): verify vote extensions against the withdrawals of their own round (#5010) Co-authored-by: Claude Opus 5.5 --- .../rs-drive-abci/src/abci/app/consensus.rs | 8 + packages/rs-drive-abci/src/abci/app/full.rs | 8 + packages/rs-drive-abci/src/abci/app/mod.rs | 5 + .../src/abci/handler/finalize_block.rs | 46 ++ .../src/abci/handler/process_proposal.rs | 53 +++ .../src/abci/handler/verify_vote_extension.rs | 413 ++++++++++-------- .../src/platform_types/withdrawal/mod.rs | 2 + .../unsigned_withdrawal_txs_by_round.rs | 158 +++++++ .../rs-drive-abci/src/test/helpers/mod.rs | 3 + .../src/test/helpers/withdrawals.rs | 54 +++ .../tests/strategy_tests/test_cases/mod.rs | 1 + .../test_cases/vote_extension_round_tests.rs | 306 +++++++++++++ 12 files changed, 867 insertions(+), 190 deletions(-) create mode 100644 packages/rs-drive-abci/src/platform_types/withdrawal/unsigned_withdrawal_txs_by_round.rs create mode 100644 packages/rs-drive-abci/src/test/helpers/withdrawals.rs create mode 100644 packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs diff --git a/packages/rs-drive-abci/src/abci/app/consensus.rs b/packages/rs-drive-abci/src/abci/app/consensus.rs index 43b6d518db8..ff2d3792619 100644 --- a/packages/rs-drive-abci/src/abci/app/consensus.rs +++ b/packages/rs-drive-abci/src/abci/app/consensus.rs @@ -5,6 +5,7 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::block_execution_context::BlockExecutionContext; use crate::platform_types::platform::Platform; +use crate::platform_types::withdrawal::unsigned_withdrawal_txs_by_round::UnsignedWithdrawalTxsByRound; use crate::rpc::core::CoreRPCLike; use dpp::version::PlatformVersion; use drive::grovedb::Transaction; @@ -23,6 +24,8 @@ pub struct ConsensusAbciApplication<'a, C> { transaction: RwLock>>, /// The current block execution context block_execution_context: RwLock>, + /// The unsigned withdrawal transactions of every proposal accepted at the current height + unsigned_withdrawal_txs_by_round: RwLock, } impl<'a, C> ConsensusAbciApplication<'a, C> { @@ -32,6 +35,7 @@ impl<'a, C> ConsensusAbciApplication<'a, C> { platform, transaction: Default::default(), block_execution_context: Default::default(), + unsigned_withdrawal_txs_by_round: Default::default(), } } } @@ -46,6 +50,10 @@ impl BlockExecutionApplication for ConsensusAbciApplication<'_, C> { fn block_execution_context(&self) -> &RwLock> { &self.block_execution_context } + + fn unsigned_withdrawal_txs_by_round(&self) -> &RwLock { + &self.unsigned_withdrawal_txs_by_round + } } impl<'a, C> TransactionalApplication<'a> for ConsensusAbciApplication<'a, C> { diff --git a/packages/rs-drive-abci/src/abci/app/full.rs b/packages/rs-drive-abci/src/abci/app/full.rs index bd290b87156..d43ff625d60 100644 --- a/packages/rs-drive-abci/src/abci/app/full.rs +++ b/packages/rs-drive-abci/src/abci/app/full.rs @@ -5,6 +5,7 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::block_execution_context::BlockExecutionContext; use crate::platform_types::platform::Platform; +use crate::platform_types::withdrawal::unsigned_withdrawal_txs_by_round::UnsignedWithdrawalTxsByRound; use crate::rpc::core::CoreRPCLike; use dpp::version::PlatformVersion; use drive::grovedb::Transaction; @@ -23,6 +24,8 @@ pub struct FullAbciApplication<'a, C> { pub transaction: RwLock>>, /// The current block execution context pub block_execution_context: RwLock>, + /// The unsigned withdrawal transactions of every proposal accepted at the current height + pub unsigned_withdrawal_txs_by_round: RwLock, } impl<'a, C> FullAbciApplication<'a, C> { @@ -32,6 +35,7 @@ impl<'a, C> FullAbciApplication<'a, C> { platform, transaction: Default::default(), block_execution_context: Default::default(), + unsigned_withdrawal_txs_by_round: Default::default(), } } } @@ -46,6 +50,10 @@ impl BlockExecutionApplication for FullAbciApplication<'_, C> { fn block_execution_context(&self) -> &RwLock> { &self.block_execution_context } + + fn unsigned_withdrawal_txs_by_round(&self) -> &RwLock { + &self.unsigned_withdrawal_txs_by_round + } } impl<'a, C> TransactionalApplication<'a> for FullAbciApplication<'a, C> { diff --git a/packages/rs-drive-abci/src/abci/app/mod.rs b/packages/rs-drive-abci/src/abci/app/mod.rs index 27d7ef0794e..2b85d893deb 100644 --- a/packages/rs-drive-abci/src/abci/app/mod.rs +++ b/packages/rs-drive-abci/src/abci/app/mod.rs @@ -10,6 +10,7 @@ pub mod execution_result; mod full; use crate::execution::types::block_execution_context::BlockExecutionContext; +use crate::platform_types::withdrawal::unsigned_withdrawal_txs_by_round::UnsignedWithdrawalTxsByRound; use crate::rpc::core::DefaultCoreRPC; #[cfg(test)] pub(crate) use check_tx::error_into_status; @@ -40,4 +41,8 @@ pub trait TransactionalApplication<'a> { pub trait BlockExecutionApplication { /// Returns the current block execution context fn block_execution_context(&self) -> &RwLock>; + + /// Returns the unsigned withdrawal transactions of every proposal accepted at the current + /// height, by round, which vote extensions are verified against + fn unsigned_withdrawal_txs_by_round(&self) -> &RwLock; } diff --git a/packages/rs-drive-abci/src/abci/handler/finalize_block.rs b/packages/rs-drive-abci/src/abci/handler/finalize_block.rs index 80af22a7434..efd0289aaab 100644 --- a/packages/rs-drive-abci/src/abci/handler/finalize_block.rs +++ b/packages/rs-drive-abci/src/abci/handler/finalize_block.rs @@ -40,6 +40,12 @@ where "block execution context must be set in block begin handler for finalize block", )))?; + // The height is decided, so no more of its votes will be verified + app.unsigned_withdrawal_txs_by_round() + .write() + .unwrap() + .clear(); + let platform_version = block_execution_context .block_platform_state() .current_platform_version()?; @@ -242,6 +248,7 @@ mod tests { use crate::platform_types::platform::Platform; use crate::platform_types::platform_state::PlatformState; use crate::platform_types::withdrawal::unsigned_withdrawal_txs::v0::UnsignedWithdrawalTxs; + use crate::platform_types::withdrawal::unsigned_withdrawal_txs_by_round::UnsignedWithdrawalTxsByRound; use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; use dpp::block::block_info::BlockInfo; @@ -265,6 +272,7 @@ mod tests { commit_error: RwLock>, transaction: RwLock>>, block_execution_context: RwLock>, + unsigned_withdrawal_txs_by_round: RwLock, } impl PlatformApplication for FailingCommitApplication<'_> { @@ -277,6 +285,10 @@ mod tests { fn block_execution_context(&self) -> &RwLock> { &self.block_execution_context } + + fn unsigned_withdrawal_txs_by_round(&self) -> &RwLock { + &self.unsigned_withdrawal_txs_by_round + } } impl<'a> TransactionalApplication<'a> for FailingCommitApplication<'a> { @@ -421,6 +433,7 @@ mod tests { commit_error: RwLock::new(Some(commit_error)), transaction: Default::default(), block_execution_context: Default::default(), + unsigned_withdrawal_txs_by_round: Default::default(), }; app.start_transaction(); @@ -538,6 +551,39 @@ mod tests { ); } + /// The withdrawals kept to verify vote extensions of the height's rounds are forgotten once + /// the height is finalized, so the next height starts with none. + #[test] + fn should_forget_the_withdrawals_of_every_round_when_the_height_is_finalized() { + let mut config = PlatformConfig::default_testnet(); + config.testing_configs.block_commit_signature_verification = false; + let platform: TempPlatform = TestPlatformBuilder::new() + .with_config(config) + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let app = FullAbciApplication::new(&platform.platform); + let other_round_block_hash = [3u8; 32]; + + { + let mut by_round = app.unsigned_withdrawal_txs_by_round.write().unwrap(); + by_round.insert(1, 0, BLOCK_HASH, UnsignedWithdrawalTxs::default()); + by_round.insert( + 1, + 1, + other_round_block_hash, + UnsignedWithdrawalTxs::default(), + ); + } + + finalize_real_block(&platform, &app, 1, 1_700_000_000_000, None) + .expect("the block must finalize"); + + let by_round = app.unsigned_withdrawal_txs_by_round.read().unwrap(); + assert!(by_round.get(1, 0, &BLOCK_HASH).is_none()); + assert!(by_round.get(1, 1, &other_round_block_hash).is_none()); + } + /// Records the events emitted while `capture` runs, to assert a failure is observable. #[derive(Clone, Default)] struct CapturedLogs(Arc>>); diff --git a/packages/rs-drive-abci/src/abci/handler/process_proposal.rs b/packages/rs-drive-abci/src/abci/handler/process_proposal.rs index f1df6687163..51865faebad 100644 --- a/packages/rs-drive-abci/src/abci/handler/process_proposal.rs +++ b/packages/rs-drive-abci/src/abci/handler/process_proposal.rs @@ -1,5 +1,6 @@ use crate::abci::app::{BlockExecutionApplication, PlatformApplication, TransactionalApplication}; use crate::abci::AbciError; +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::engine::consensus_params_update::consensus_params_update; use crate::execution::types::block_execution_context::v0::{ @@ -92,6 +93,58 @@ pub fn process_proposal<'a, A, C>( app: &A, request: proto::RequestProcessProposal, ) -> Result +where + A: PlatformApplication + TransactionalApplication<'a> + BlockExecutionApplication, + C: CoreRPCLike, +{ + let response = execute_proposal(app, request)?; + + if response.status == proto::response_process_proposal::ProposalStatus::Accept as i32 { + keep_withdrawals_of_accepted_proposal(app)?; + } + + Ok(response) +} + +/// Keeps the unsigned withdrawal transactions of the proposal just accepted, which validators +/// precommitting it sign in their vote extensions. A later round replaces the block execution +/// context, and votes of this round can still arrive after that. +fn keep_withdrawals_of_accepted_proposal(app: &A) -> Result<(), Error> +where + A: BlockExecutionApplication, +{ + let block_execution_context_guard = app.block_execution_context().read().unwrap(); + let block_execution_context = + block_execution_context_guard + .as_ref() + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "an accepted proposal must leave a block execution context", + )))?; + + let block_state_info = block_execution_context.block_state_info(); + let block_hash = block_state_info.block_hash().ok_or(Error::Execution( + ExecutionError::CorruptedCodeExecution("an accepted proposal must have a block hash"), + ))?; + + app.unsigned_withdrawal_txs_by_round() + .write() + .unwrap() + .insert( + block_state_info.height(), + block_state_info.round(), + block_hash, + block_execution_context + .unsigned_withdrawal_transactions() + .clone(), + ); + + Ok(()) +} + +fn execute_proposal<'a, A, C>( + app: &A, + request: proto::RequestProcessProposal, +) -> Result where A: PlatformApplication + TransactionalApplication<'a> + BlockExecutionApplication, C: CoreRPCLike, diff --git a/packages/rs-drive-abci/src/abci/handler/verify_vote_extension.rs b/packages/rs-drive-abci/src/abci/handler/verify_vote_extension.rs index f6d51579b65..e9ebea4ec31 100644 --- a/packages/rs-drive-abci/src/abci/handler/verify_vote_extension.rs +++ b/packages/rs-drive-abci/src/abci/handler/verify_vote_extension.rs @@ -1,13 +1,15 @@ use crate::abci::app::{BlockExecutionApplication, PlatformApplication}; use crate::error::Error; -use crate::execution::types::block_execution_context::v0::BlockExecutionContextV0Getters; -use crate::execution::types::block_state_info::v0::BlockStateInfoV0Getters; use crate::rpc::core::CoreRPCLike; use tenderdash_abci::proto::abci as proto; use tenderdash_abci::proto::abci::response_verify_vote_extension::VerifyStatus; use tenderdash_abci::proto::abci::ExtendVoteExtension; -/// Todo: Verify votes extension not really needed because extend votes is deterministic +/// Verifies that another validator's precommit asks for signatures on exactly the withdrawal +/// transactions this node built for the same block. +/// +/// Tenderdash asks about every non-nil precommit of another validator at the height it is +/// deciding, whatever the round, and drops the vote when it is rejected. pub fn verify_vote_extension( app: &A, request: proto::RequestVerifyVoteExtension, @@ -18,8 +20,8 @@ where { let _timer = crate::metrics::abci_request_duration("verify_vote_extension"); - // Verify that this is a votes extension for our current executed block and our proposer let proto::RequestVerifyVoteExtension { + hash, height, round, vote_extensions, @@ -29,45 +31,36 @@ where let height: u64 = height as u64; let round: u32 = round as u32; - // Make sure we are in a block execution phase - let block_execution_context_ref = app.block_execution_context().read().unwrap(); - let Some(block_execution_context) = block_execution_context_ref.as_ref() else { - tracing::warn!( - "votes extensions for height: {}, round: {} are rejected because we are not in a block execution phase", - height, - round, - ); - - return Ok(proto::ResponseVerifyVoteExtension { - status: VerifyStatus::Reject.into(), - }); - }; - - // Make sure votes extension is for our currently executing block - - let block_state_info = block_execution_context.block_state_info(); - - // We might get votes extension to verify for previous (in case if other node is behind) - // or future round (in case if the current node is behind), so we make sure that only height - // is matching. It's fine because withdrawal transactions to sign are the same for any round - // of the same height - if block_state_info.height() != height { - tracing::warn!( - "votes extensions for height: {}, round: {} are rejected because we are at height: {}", + // Each round of a height has its own proposal, and its withdrawal transactions carry that + // proposal's chain-locked core height as their request height. A later round whose proposer + // saw a newer chain lock asks validators to sign different transactions, so a vote is + // compared with what we built for the block it is for, never with another round's. + let withdrawals_by_round = app.unsigned_withdrawal_txs_by_round().read().unwrap(); + + let Some(expected_withdrawals) = withdrawals_by_round.get(height, round, &hash) else { + // We have not accepted the block this vote is for: its proposal has not reached us yet, + // or it belongs to another height. We reject it, because nothing else we could check + // tells an honest vote from one a relaying peer altered. The block signature does not + // cover vote extensions, and ours carry a sign request id that binds them to neither + // height nor round, so any peer can drop some or all of a precommit's extensions, or + // swap in the same validator's extensions from another round, and the vote still + // verifies. Counting such votes could let extensions other than the block's reach the + // recovery threshold, and the commit they form would then fail in `finalize_block`. + // + // A dropped vote is not lost for good: a peer that learns we lack it can send it again + // once we have accepted the block, and a node that falls behind catches up through the + // commit. + tracing::debug!( + block_hash = hex::encode(&hash), + "votes extensions for height: {}, round: {} are rejected because we have not accepted a proposal for that block", height, round, - block_state_info.height(), ); return Ok(proto::ResponseVerifyVoteExtension { status: VerifyStatus::Reject.into(), }); - } - - // Verify that a validator is requesting a signatures - // for a correct set of withdrawal transactions - - let expected_withdrawals = block_execution_context.unsigned_withdrawal_transactions(); + }; if expected_withdrawals != vote_extensions.as_slice() { let expected_extensions: Vec = expected_withdrawals.into(); @@ -75,6 +68,7 @@ where tracing::error!( received_extensions = ?vote_extensions, ?expected_extensions, + block_hash = hex::encode(&hash), "votes extensions for height: {}, round: {} mismatch", height, round ); @@ -99,203 +93,242 @@ where mod tests { use super::*; use crate::abci::app::FullAbciApplication; - use crate::execution::types::block_execution_context::v0::BlockExecutionContextV0; - use crate::execution::types::block_execution_context::BlockExecutionContext; - use crate::execution::types::block_state_info::v0::BlockStateInfoV0; - use crate::execution::types::block_state_info::BlockStateInfo; - use crate::platform_types::epoch_info::v0::EpochInfoV0; - use crate::platform_types::epoch_info::EpochInfo; - use crate::platform_types::platform_state::PlatformState; use crate::platform_types::withdrawal::unsigned_withdrawal_txs::v0::UnsignedWithdrawalTxs; use crate::rpc::core::MockCoreRPCLike; - use crate::test::helpers::setup::TestPlatformBuilder; - use dpp::version::PlatformVersion; - use std::collections::BTreeMap; + use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; + use crate::test::helpers::withdrawals::unsigned_withdrawal_transactions; - fn make_test_block_execution_context( - height: u64, - round: u32, - block_hash: Option<[u8; 32]>, - platform: &crate::platform_types::platform::Platform, - ) -> BlockExecutionContext { - let platform_version = PlatformVersion::latest(); - BlockExecutionContext::V0(BlockExecutionContextV0 { - block_state_info: BlockStateInfo::V0(BlockStateInfoV0 { - height, - round, - block_time_ms: 1_000_000, - previous_block_time_ms: None, - proposer_pro_tx_hash: [0u8; 32], - core_chain_locked_height: 1, - block_hash, - app_hash: None, - }), - epoch_info: EpochInfo::V0(EpochInfoV0 { - current_epoch_index: 0, - previous_epoch_index: None, - is_epoch_change: false, - }), - unsigned_withdrawal_transactions: UnsignedWithdrawalTxs::default(), - block_address_balance_changes: BTreeMap::new(), - block_platform_state: PlatformState::default_with_protocol_versions( - platform_version.protocol_version, - platform_version.protocol_version, - &platform.config, - ) - .expect("should create default platform state"), - proposer_results: None, - }) - } + const HEIGHT: u64 = 10; + const ROUND_0_BLOCK: [u8; 32] = [0xA0; 32]; + const ROUND_1_BLOCK: [u8; 32] = [0xA1; 32]; + const ROUND_0_CORE_HEIGHT: u32 = 1000; + const ROUND_1_CORE_HEIGHT: u32 = 1001; - #[test] - fn verify_vote_extension_rejects_when_no_block_execution_context() { - let platform = TestPlatformBuilder::new() + fn platform() -> TempPlatform { + TestPlatformBuilder::new() .with_latest_protocol_version() - .build_with_mock_rpc(); + .build_with_mock_rpc() + } - let app = FullAbciApplication::::new(&platform.platform); + fn extensions(transactions: &UnsignedWithdrawalTxs) -> Vec { + transactions.into() + } + + fn round_0_extensions() -> Vec { + extensions(&unsigned_withdrawal_transactions(ROUND_0_CORE_HEIGHT)) + } - // No block execution context is set + fn round_1_extensions() -> Vec { + extensions(&unsigned_withdrawal_transactions(ROUND_1_CORE_HEIGHT)) + } + + fn verify( + app: &FullAbciApplication, + height: u64, + round: u32, + block_hash: [u8; 32], + vote_extensions: Vec, + ) -> i32 { let request = proto::RequestVerifyVoteExtension { - hash: vec![0u8; 32], + hash: block_hash.to_vec(), validator_pro_tx_hash: vec![0u8; 32], - height: 10, - round: 0, - vote_extensions: vec![], + height: height as i64, + round: round as i32, + vote_extensions, }; - let response = verify_vote_extension::<_, MockCoreRPCLike>(&app, request) - .expect("should return Ok with Reject status"); - - assert_eq!(response.status, VerifyStatus::Reject as i32); + verify_vote_extension::<_, MockCoreRPCLike>(app, request) + .expect("verification answers with a status") + .status } - #[test] - fn verify_vote_extension_rejects_when_height_mismatch() { - let platform = TestPlatformBuilder::new() - .with_latest_protocol_version() - .build_with_mock_rpc(); - - let app = FullAbciApplication::::new(&platform.platform); + /// Round 0 accepted at `HEIGHT`, at `ROUND_0_CORE_HEIGHT` + fn accept_round_0(app: &FullAbciApplication) { + app.unsigned_withdrawal_txs_by_round + .write() + .unwrap() + .insert( + HEIGHT, + 0, + ROUND_0_BLOCK, + unsigned_withdrawal_transactions(ROUND_0_CORE_HEIGHT), + ); + } - // Set block execution context at height 10 - let context = - make_test_block_execution_context(10, 0, Some([0xAA; 32]), &platform.platform); - app.block_execution_context + /// Round 1 accepted at `HEIGHT`, at the newer `ROUND_1_CORE_HEIGHT` + fn accept_round_1(app: &FullAbciApplication) { + app.unsigned_withdrawal_txs_by_round .write() .unwrap() - .replace(context); + .insert( + HEIGHT, + 1, + ROUND_1_BLOCK, + unsigned_withdrawal_transactions(ROUND_1_CORE_HEIGHT), + ); + } - // Request verification for a different height (20) - let request = proto::RequestVerifyVoteExtension { - hash: vec![0u8; 32], - validator_pro_tx_hash: vec![0u8; 32], - height: 20, - round: 0, - vote_extensions: vec![], - }; + /// Round 1 was proposed at a newer chain-locked core height than round 0, and this node + /// processed it last. A round 0 precommit carries round 0's withdrawal transactions and is + /// valid; it used to be compared with round 1's and rejected. + #[test] + fn should_accept_a_vote_for_an_earlier_round_at_an_older_core_height() { + let platform = platform(); + let app = FullAbciApplication::::new(&platform.platform); + accept_round_0(&app); + accept_round_1(&app); - let response = verify_vote_extension::<_, MockCoreRPCLike>(&app, request) - .expect("should return Ok with Reject status"); + assert_ne!( + round_0_extensions(), + round_1_extensions(), + "test premise: the request height makes the two rounds' extensions differ" + ); - assert_eq!(response.status, VerifyStatus::Reject as i32); + assert_eq!( + verify(&app, HEIGHT, 0, ROUND_0_BLOCK, round_0_extensions()), + VerifyStatus::Accept as i32 + ); + assert_eq!( + verify(&app, HEIGHT, 1, ROUND_1_BLOCK, round_1_extensions()), + VerifyStatus::Accept as i32 + ); } #[test] - fn verify_vote_extension_accepts_matching_empty_withdrawals() { - let platform = TestPlatformBuilder::new() - .with_latest_protocol_version() - .build_with_mock_rpc(); - + fn should_reject_a_vote_whose_withdrawals_differ_from_its_round() { + let platform = platform(); let app = FullAbciApplication::::new(&platform.platform); + accept_round_0(&app); + accept_round_1(&app); - // Set block execution context at height 10, with empty withdrawal transactions - let context = - make_test_block_execution_context(10, 0, Some([0xAA; 32]), &platform.platform); - app.block_execution_context - .write() - .unwrap() - .replace(context); + // Round 1's withdrawal transactions in a round 0 vote + assert_eq!( + verify(&app, HEIGHT, 0, ROUND_0_BLOCK, round_1_extensions()), + VerifyStatus::Reject as i32 + ); - // Request with matching empty vote extensions - let request = proto::RequestVerifyVoteExtension { - hash: vec![0u8; 32], - validator_pro_tx_hash: vec![0u8; 32], - height: 10, - round: 0, - vote_extensions: vec![], - }; + // Bytes nobody built + assert_eq!( + verify( + &app, + HEIGHT, + 0, + ROUND_0_BLOCK, + vec![ExtendVoteExtension { + r#type: 0, + extension: vec![1, 2, 3], + sign_request_id: None, + }], + ), + VerifyStatus::Reject as i32 + ); - let response = verify_vote_extension::<_, MockCoreRPCLike>(&app, request) - .expect("should return Ok with Accept status"); + // Extensions stripped by a relaying peer, entirely or in part + assert_eq!( + verify(&app, HEIGHT, 0, ROUND_0_BLOCK, vec![]), + VerifyStatus::Reject as i32 + ); + assert_eq!( + verify( + &app, + HEIGHT, + 0, + ROUND_0_BLOCK, + round_0_extensions().into_iter().take(1).collect(), + ), + VerifyStatus::Reject as i32 + ); - assert_eq!(response.status, VerifyStatus::Accept as i32); + // The right extensions in another order + assert_eq!( + verify( + &app, + HEIGHT, + 0, + ROUND_0_BLOCK, + round_0_extensions().into_iter().rev().collect(), + ), + VerifyStatus::Reject as i32 + ); } #[test] - fn verify_vote_extension_rejects_mismatched_withdrawals() { - let platform = TestPlatformBuilder::new() - .with_latest_protocol_version() - .build_with_mock_rpc(); - + fn should_accept_matching_empty_withdrawals() { + let platform = platform(); let app = FullAbciApplication::::new(&platform.platform); - - // Set block execution context at height 10 with empty withdrawal transactions - let context = - make_test_block_execution_context(10, 0, Some([0xAA; 32]), &platform.platform); - app.block_execution_context + app.unsigned_withdrawal_txs_by_round .write() .unwrap() - .replace(context); + .insert(HEIGHT, 0, ROUND_0_BLOCK, UnsignedWithdrawalTxs::default()); - // Request with non-empty vote extensions (mismatch) - let request = proto::RequestVerifyVoteExtension { - hash: vec![0u8; 32], - validator_pro_tx_hash: vec![0u8; 32], - height: 10, - round: 0, - vote_extensions: vec![proto::ExtendVoteExtension { - r#type: 0, - extension: vec![1, 2, 3], - sign_request_id: None, - }], - }; + assert_eq!( + verify(&app, HEIGHT, 0, ROUND_0_BLOCK, vec![]), + VerifyStatus::Accept as i32 + ); + assert_eq!( + verify(&app, HEIGHT, 0, ROUND_0_BLOCK, round_0_extensions()), + VerifyStatus::Reject as i32 + ); + } - let response = verify_vote_extension::<_, MockCoreRPCLike>(&app, request) - .expect("should return Ok with Reject status"); + /// Only round 0 is accepted. Nothing tells an honest round 1 vote from one whose + /// extensions a relaying peer stripped, or replaced with the same validator's round 0 + /// extensions, whose signatures are bound to neither height nor round. The last case + /// matches the only proposal this node processed, and used to be accepted. + #[test] + fn should_reject_a_vote_for_a_round_this_node_has_not_accepted() { + let platform = platform(); + let app = FullAbciApplication::::new(&platform.platform); + accept_round_0(&app); - assert_eq!(response.status, VerifyStatus::Reject as i32); + for vote_extensions in [round_1_extensions(), vec![], round_0_extensions()] { + assert_eq!( + verify(&app, HEIGHT, 1, ROUND_1_BLOCK, vote_extensions), + VerifyStatus::Reject as i32 + ); + } } + /// The same holds for a block other than the one this node accepted in that round. #[test] - fn verify_vote_extension_accepts_different_round_same_height() { - let platform = TestPlatformBuilder::new() - .with_latest_protocol_version() - .build_with_mock_rpc(); - + fn should_reject_a_vote_for_a_block_this_node_has_not_accepted() { + let platform = platform(); let app = FullAbciApplication::::new(&platform.platform); + accept_round_0(&app); - // Set block execution context at height 10, round 1 - let context = - make_test_block_execution_context(10, 1, Some([0xAA; 32]), &platform.platform); - app.block_execution_context - .write() - .unwrap() - .replace(context); + assert_eq!( + verify(&app, HEIGHT, 0, ROUND_1_BLOCK, round_0_extensions()), + VerifyStatus::Reject as i32 + ); + } - // Request for same height but different round (round 5) - // This should still be accepted since only height needs to match - let request = proto::RequestVerifyVoteExtension { - hash: vec![0u8; 32], - validator_pro_tx_hash: vec![0u8; 32], - height: 10, - round: 5, - vote_extensions: vec![], - }; + /// Before this node accepts any proposal of the height, for example while the first + /// proposal is still on its way or right after a restart. + #[test] + fn should_reject_a_vote_before_any_proposal_of_the_height_is_accepted() { + let platform = platform(); + let app = FullAbciApplication::::new(&platform.platform); + + for vote_extensions in [round_0_extensions(), vec![]] { + assert_eq!( + verify(&app, HEIGHT, 0, ROUND_0_BLOCK, vote_extensions), + VerifyStatus::Reject as i32 + ); + } + } - let response = verify_vote_extension::<_, MockCoreRPCLike>(&app, request) - .expect("should return Ok with Accept status"); + /// Withdrawals kept for one height say nothing about another. + #[test] + fn should_reject_a_vote_for_another_height() { + let platform = platform(); + let app = FullAbciApplication::::new(&platform.platform); + accept_round_0(&app); - assert_eq!(response.status, VerifyStatus::Accept as i32); + for height in [HEIGHT - 1, HEIGHT + 1] { + assert_eq!( + verify(&app, height, 0, ROUND_0_BLOCK, round_0_extensions()), + VerifyStatus::Reject as i32 + ); + } } } diff --git a/packages/rs-drive-abci/src/platform_types/withdrawal/mod.rs b/packages/rs-drive-abci/src/platform_types/withdrawal/mod.rs index ec9a669431d..65b57b7a97f 100644 --- a/packages/rs-drive-abci/src/platform_types/withdrawal/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/withdrawal/mod.rs @@ -1,2 +1,4 @@ /// Collection of unsigned withdrawal transactions pub mod unsigned_withdrawal_txs; +/// Unsigned withdrawal transactions of every proposal accepted at the current height, by round +pub mod unsigned_withdrawal_txs_by_round; diff --git a/packages/rs-drive-abci/src/platform_types/withdrawal/unsigned_withdrawal_txs_by_round.rs b/packages/rs-drive-abci/src/platform_types/withdrawal/unsigned_withdrawal_txs_by_round.rs new file mode 100644 index 00000000000..fcd373973b1 --- /dev/null +++ b/packages/rs-drive-abci/src/platform_types/withdrawal/unsigned_withdrawal_txs_by_round.rs @@ -0,0 +1,158 @@ +//! The unsigned withdrawal transactions of every proposal accepted at the current height + +use crate::platform_types::withdrawal::unsigned_withdrawal_txs::v0::UnsignedWithdrawalTxs; +use std::collections::BTreeMap; + +/// The unsigned withdrawal transactions of one accepted proposal +#[derive(Debug, Clone)] +struct AcceptedProposalWithdrawals { + block_hash: [u8; 32], + transactions: UnsignedWithdrawalTxs, +} + +/// The unsigned withdrawal transactions of every proposal this node accepted at the current +/// height, by round. +/// +/// A withdrawal transaction carries the chain-locked core height of the proposal that built it +/// as its request height, so two rounds of one height whose proposers saw different chain locks +/// ask validators to sign different transactions. A vote extension is verified against the +/// proposal of its own round, which the block execution context alone cannot give: it only +/// holds the last proposal processed. +/// +/// This is node memory, not consensus state. +#[derive(Debug, Default, Clone)] +pub struct UnsignedWithdrawalTxsByRound { + height: u64, + rounds: BTreeMap, +} + +impl UnsignedWithdrawalTxsByRound { + /// Keeps the withdrawal transactions of the block `block_hash`, accepted at `height` and + /// `round`, in place of any block kept for that round. Blocks of another height are + /// forgotten. + pub fn insert( + &mut self, + height: u64, + round: u32, + block_hash: [u8; 32], + transactions: UnsignedWithdrawalTxs, + ) { + if self.height != height { + self.rounds.clear(); + self.height = height; + } + + self.rounds.insert( + round, + AcceptedProposalWithdrawals { + block_hash, + transactions, + }, + ); + } + + /// The withdrawal transactions of the block `block_hash` at `height` and `round`, or `None` + /// when this node has not accepted that block. + pub fn get( + &self, + height: u64, + round: u32, + block_hash: &[u8], + ) -> Option<&UnsignedWithdrawalTxs> { + if self.height != height { + return None; + } + + self.rounds + .get(&round) + .filter(|proposal| proposal.block_hash.as_slice() == block_hash) + .map(|proposal| &proposal.transactions) + } + + /// Forgets every block, once their height is finalized + pub fn clear(&mut self) { + self.rounds.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test::helpers::withdrawals::unsigned_withdrawal_transactions; + use tenderdash_abci::proto::abci::ExtendVoteExtension; + + const ROUND_0_BLOCK: [u8; 32] = [0xA0; 32]; + const ROUND_1_BLOCK: [u8; 32] = [0xA1; 32]; + + fn extensions(transactions: &UnsignedWithdrawalTxs) -> Vec { + transactions.into() + } + + #[test] + fn should_keep_the_withdrawals_of_each_round_apart() { + let round_0 = unsigned_withdrawal_transactions(1000); + let round_1 = unsigned_withdrawal_transactions(1001); + + let mut by_round = UnsignedWithdrawalTxsByRound::default(); + by_round.insert(10, 0, ROUND_0_BLOCK, round_0.clone()); + by_round.insert(10, 1, ROUND_1_BLOCK, round_1.clone()); + + let kept_round_0 = by_round + .get(10, 0, &ROUND_0_BLOCK) + .expect("round 0 is kept after round 1"); + let kept_round_1 = by_round + .get(10, 1, &ROUND_1_BLOCK) + .expect("round 1 is kept"); + + assert_eq!(extensions(kept_round_0), extensions(&round_0)); + assert_eq!(extensions(kept_round_1), extensions(&round_1)); + assert_ne!( + extensions(kept_round_0), + extensions(kept_round_1), + "test premise: the request height makes the two rounds' transactions differ" + ); + } + + #[test] + fn should_not_answer_for_a_block_or_round_it_did_not_accept() { + let mut by_round = UnsignedWithdrawalTxsByRound::default(); + by_round.insert(10, 0, ROUND_0_BLOCK, unsigned_withdrawal_transactions(1000)); + + assert!(by_round.get(10, 0, &ROUND_1_BLOCK).is_none()); + assert!(by_round.get(10, 1, &ROUND_0_BLOCK).is_none()); + assert!(by_round.get(11, 0, &ROUND_0_BLOCK).is_none()); + } + + #[test] + fn should_replace_the_block_kept_for_a_round() { + let mut by_round = UnsignedWithdrawalTxsByRound::default(); + by_round.insert(10, 0, ROUND_0_BLOCK, unsigned_withdrawal_transactions(1000)); + by_round.insert(10, 0, ROUND_1_BLOCK, unsigned_withdrawal_transactions(1001)); + + assert!(by_round.get(10, 0, &ROUND_0_BLOCK).is_none()); + assert!(by_round.get(10, 0, &ROUND_1_BLOCK).is_some()); + } + + #[test] + fn should_start_a_new_height_empty() { + let mut by_round = UnsignedWithdrawalTxsByRound::default(); + by_round.insert(10, 0, ROUND_0_BLOCK, unsigned_withdrawal_transactions(1000)); + by_round.insert(11, 1, ROUND_1_BLOCK, unsigned_withdrawal_transactions(1001)); + + assert!(by_round.get(10, 0, &ROUND_0_BLOCK).is_none()); + assert!(by_round.get(11, 0, &ROUND_0_BLOCK).is_none()); + assert!(by_round.get(11, 1, &ROUND_1_BLOCK).is_some()); + } + + #[test] + fn should_forget_every_block_when_cleared() { + let mut by_round = UnsignedWithdrawalTxsByRound::default(); + by_round.insert(10, 0, ROUND_0_BLOCK, unsigned_withdrawal_transactions(1000)); + by_round.insert(10, 1, ROUND_1_BLOCK, unsigned_withdrawal_transactions(1001)); + + by_round.clear(); + + assert!(by_round.get(10, 0, &ROUND_0_BLOCK).is_none()); + assert!(by_round.get(10, 1, &ROUND_1_BLOCK).is_none()); + } +} diff --git a/packages/rs-drive-abci/src/test/helpers/mod.rs b/packages/rs-drive-abci/src/test/helpers/mod.rs index 5c710ca8b4c..7b7e45e3ade 100644 --- a/packages/rs-drive-abci/src/test/helpers/mod.rs +++ b/packages/rs-drive-abci/src/test/helpers/mod.rs @@ -8,6 +8,9 @@ pub mod fee_pools; pub mod setup; #[cfg(test)] pub mod state_mutation_guard; +/// Withdrawal fixtures +#[cfg(test)] +pub mod withdrawals; // TODO: Move tests to appropriate place #[cfg(test)] diff --git a/packages/rs-drive-abci/src/test/helpers/withdrawals.rs b/packages/rs-drive-abci/src/test/helpers/withdrawals.rs new file mode 100644 index 00000000000..63872c0cd1d --- /dev/null +++ b/packages/rs-drive-abci/src/test/helpers/withdrawals.rs @@ -0,0 +1,54 @@ +use crate::platform_types::withdrawal::unsigned_withdrawal_txs::v0::UnsignedWithdrawalTxs; +use dpp::dashcore::blockdata::transaction::special_transaction::asset_unlock::request_info::AssetUnlockRequestInfo; +use dpp::dashcore::consensus::Encodable; +use dpp::dashcore::hashes::Hash; +use dpp::dashcore::transaction::special_transaction::asset_unlock::qualified_asset_unlock::build_asset_unlock_tx; +use dpp::dashcore::transaction::special_transaction::asset_unlock::unqualified_asset_unlock::{ + AssetUnlockBasePayload, AssetUnlockBaseTransactionInfo, +}; +use dpp::dashcore::{QuorumHash, ScriptBuf, TxOut}; + +/// Two unsigned withdrawal transactions, with indices 0 and 1, built the way a proposal at +/// chain-locked core height `request_height` builds them. +pub fn unsigned_withdrawal_transactions(request_height: u32) -> UnsignedWithdrawalTxs { + let transactions = (0..2) + .map(|index| { + let untied_transaction = AssetUnlockBaseTransactionInfo { + version: 1, + lock_time: 0, + output: vec![TxOut { + value: 100_000, + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + base_payload: AssetUnlockBasePayload { + version: 1, + index, + fee: 2_000, + }, + }; + + let mut untied_transaction_bytes = vec![]; + untied_transaction + .consensus_encode(&mut untied_transaction_bytes) + .expect("expected to encode an untied withdrawal transaction"); + + let request_info = AssetUnlockRequestInfo { + request_height, + quorum_hash: QuorumHash::from_byte_array([7u8; 32]), + }; + + let mut unsigned_transaction_bytes = vec![]; + request_info + .consensus_append_to_base_encode( + untied_transaction_bytes, + &mut unsigned_transaction_bytes, + ) + .expect("expected to append the request info"); + + build_asset_unlock_tx(&unsigned_transaction_bytes) + .expect("expected to build an unsigned withdrawal transaction") + }) + .collect(); + + UnsignedWithdrawalTxs::from_vec(transactions) +} diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs index 1e963cb1cbd..4444a8c1e65 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs @@ -15,5 +15,6 @@ mod token_tests; mod top_up_tests; mod update_identities_tests; mod upgrade_fork_tests; +mod vote_extension_round_tests; mod voting_tests; mod withdrawal_tests; diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs new file mode 100644 index 00000000000..d3e7ff67ab5 --- /dev/null +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs @@ -0,0 +1,306 @@ +//! Vote extensions of different rounds of one height. +//! +//! Each round of a height has its own proposal, and the unsigned withdrawal transactions a +//! proposal asks validators to sign carry its chain-locked core height as their request height. +//! When a new chain lock arrives between two rounds, the later proposal asks for signatures on +//! different transactions. Precommits of the earlier round are still valid and can still arrive +//! after this node processed the later proposal, so each vote must be verified against the +//! proposal of its own round. A vote for a block this node has not accepted cannot be checked, +//! since a relaying peer can strip its extensions or swap in another round's, and is rejected. +#[cfg(test)] +mod tests { + use crate::execution::run_chain_for_strategy; + use crate::strategy::{ChainExecutionOutcome, NetworkStrategy}; + use dpp::block::block_info::BlockInfo; + use dpp::block::extended_block_info::v0::ExtendedBlockInfoV0Setters; + use dpp::dashcore::consensus::Encodable; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::transaction::special_transaction::asset_unlock::unqualified_asset_unlock::{ + AssetUnlockBasePayload, AssetUnlockBaseTransactionInfo, + }; + use dpp::dashcore::{ScriptBuf, TxOut}; + use drive_abci::config::{ExecutionConfig, PlatformConfig, PlatformTestConfig}; + use drive_abci::platform_types::platform_state::PlatformStateV0Methods; + use drive_abci::test::helpers::setup::TestPlatformBuilder; + use platform_version::version::PlatformVersion; + use std::sync::Arc; + use strategy_tests::{IdentityInsertInfo, StartAddresses, StartIdentities, Strategy}; + use tenderdash_abci::proto::abci::response_process_proposal::ProposalStatus; + use tenderdash_abci::proto::abci::response_verify_vote_extension::VerifyStatus; + use tenderdash_abci::proto::abci::{ + ExtendVoteExtension, RequestExtendVote, RequestProcessProposal, RequestVerifyVoteExtension, + }; + use tenderdash_abci::proto::google::protobuf::Timestamp; + use tenderdash_abci::proto::version::Consensus; + use tenderdash_abci::Application; + + const BLOCK_SPACING_MS: u64 = 3000; + const ROUND_0_BLOCK: [u8; 32] = [0xA0; 32]; + const ROUND_1_BLOCK: [u8; 32] = [0xA1; 32]; + + fn strategy() -> NetworkStrategy { + NetworkStrategy { + strategy: Strategy { + start_contracts: vec![], + operations: vec![], + start_identities: StartIdentities::default(), + start_addresses: StartAddresses::default(), + identity_inserts: IdentityInsertInfo::default(), + identity_contract_nonce_gaps: None, + signer: None, + }, + total_hpmns: 50, + extra_normal_mns: 0, + validator_quorum_count: 10, + chain_lock_quorum_count: 10, + upgrading_info: None, + proposer_strategy: Default::default(), + rotate_quorums: false, + failure_testing: None, + query_testing: None, + verify_state_transition_results: false, + ..Default::default() + } + } + + fn config() -> PlatformConfig { + PlatformConfig { + execution: ExecutionConfig { + verify_sum_trees: true, + ..Default::default() + }, + block_spacing_ms: BLOCK_SPACING_MS, + testing_configs: PlatformTestConfig::default_minimal_verifications(), + ..Default::default() + } + } + + /// Queues one untied withdrawal transaction, as a committed block that pooled it would + /// have left it, so that the next height dequeues it and asks validators to sign it. + fn queue_withdrawal_transaction(outcome: &ChainExecutionOutcome) { + let platform = outcome.abci_app.platform; + let platform_version = PlatformVersion::latest(); + + let untied_transaction = AssetUnlockBaseTransactionInfo { + version: 1, + lock_time: 0, + output: vec![TxOut { + value: 100_000, + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + base_payload: AssetUnlockBasePayload { + version: 1, + index: 0, + fee: 2_000, + }, + }; + let mut untied_transaction_bytes = vec![]; + untied_transaction + .consensus_encode(&mut untied_transaction_bytes) + .expect("expected to encode an untied withdrawal transaction"); + + let transaction = platform.drive.grove.start_transaction(); + let mut drive_operations = vec![]; + platform + .drive + .add_enqueue_untied_withdrawal_transaction_operations( + vec![(0, untied_transaction_bytes)], + 102_000_000, + &mut drive_operations, + platform_version, + ) + .expect("expected to enqueue a withdrawal transaction"); + platform + .drive + .apply_drive_operations( + drive_operations, + true, + &BlockInfo::default(), + Some(&transaction), + platform_version, + None, + ) + .expect("expected to apply the enqueue operations"); + platform + .drive + .commit_transaction(transaction, &platform_version.drive) + .expect("expected to commit the queued withdrawal transaction"); + + // The committed app hash moved with the queue, as it would have in a real block. + let app_hash = platform + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("expected the committed root hash"); + let mut platform_state = platform.state.load().as_ref().clone(); + platform_state + .last_committed_block_info_mut() + .as_mut() + .expect("a block was committed") + .set_app_hash(app_hash); + platform.state.store(Arc::new(platform_state)); + } + + /// The proposal of `round` for the next height, at chain-locked `core_chain_locked_height` + fn proposal( + outcome: &ChainExecutionOutcome, + round: u32, + core_chain_locked_height: u32, + hash: [u8; 32], + ) -> RequestProcessProposal { + let platform_state = outcome.abci_app.platform.state.load(); + let height = platform_state.last_committed_block_height() + 1; + let time_ms = outcome.end_time_ms + BLOCK_SPACING_MS + round as u64 * 1000; + let proposer = outcome.proposers[round as usize].pro_tx_hash(); + + RequestProcessProposal { + txs: vec![], + proposed_last_commit: None, + misbehavior: vec![], + hash: hash.to_vec(), + height: height as i64, + time: Some(Timestamp { + seconds: (time_ms / 1000) as i64, + nanos: ((time_ms % 1000) * 1_000_000) as i32, + }), + next_validators_hash: [0u8; 32].to_vec(), + round: round as i32, + core_chain_locked_height, + core_chain_lock_update: None, + proposer_pro_tx_hash: proposer.to_byte_array().to_vec(), + proposed_app_version: PlatformVersion::latest().protocol_version as u64, + version: Some(Consensus { + block: 0, + app: PlatformVersion::latest().protocol_version as u64, + }), + quorum_hash: outcome + .current_quorum() + .quorum_hash + .to_byte_array() + .to_vec(), + } + } + + /// The vote extensions this node signs when it precommits `proposal`, which it processed last + fn extend_vote( + outcome: &ChainExecutionOutcome, + proposal: &RequestProcessProposal, + ) -> Vec { + outcome + .abci_app + .extend_vote(RequestExtendVote { + hash: proposal.hash.clone(), + height: proposal.height, + round: proposal.round, + }) + .expect("expected to extend the vote") + .vote_extensions + } + + /// Another validator's precommit for `proposal`, carrying `vote_extensions` + fn verify( + outcome: &ChainExecutionOutcome, + proposal: &RequestProcessProposal, + vote_extensions: Vec, + ) -> i32 { + outcome + .abci_app + .verify_vote_extension(RequestVerifyVoteExtension { + hash: proposal.hash.clone(), + validator_pro_tx_hash: outcome.proposers[5].pro_tx_hash().to_byte_array().to_vec(), + height: proposal.height, + round: proposal.round, + vote_extensions, + }) + .expect("expected to verify the vote extensions") + .status + } + + /// Round 0 is processed at the last chain-locked core height, then round 1 at a newer one. + /// A round 0 precommit is still accepted afterwards, which it was not when every vote was + /// compared with the last proposal processed. A vote whose withdrawals differ from its own + /// round's is rejected, and so is a vote for a round not processed yet. + #[tokio::test] + async fn should_verify_each_rounds_vote_extensions_against_its_own_withdrawals() { + let config = config(); + let mut platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .build_with_mock_rpc(); + + let outcome = run_chain_for_strategy( + &mut platform, + 2, + strategy(), + config, + 15, + &mut None, + &mut None, + ) + .await; + + queue_withdrawal_transaction(&outcome); + + let round_0_core_height = outcome + .abci_app + .platform + .state + .load() + .last_committed_core_height(); + let round_0 = proposal(&outcome, 0, round_0_core_height, ROUND_0_BLOCK); + let round_1 = proposal(&outcome, 1, round_0_core_height + 1, ROUND_1_BLOCK); + + let response = outcome + .abci_app + .process_proposal(round_0.clone()) + .expect("expected to process the round 0 proposal"); + assert_eq!(response.status, ProposalStatus::Accept as i32); + let round_0_extensions = extend_vote(&outcome, &round_0); + + // Before round 1 is processed, a round 1 precommit carrying the same validator's round 0 + // extensions verifies in Tenderdash, as their signatures are bound to neither height nor + // round, and matches the only proposal this node processed. It must still be rejected. + assert_eq!( + verify(&outcome, &round_1, round_0_extensions.clone()), + VerifyStatus::Reject as i32, + "a vote for a round this node has not accepted must be rejected" + ); + assert_eq!( + verify(&outcome, &round_0, vec![]), + VerifyStatus::Reject as i32, + "a round 0 precommit whose extensions were stripped must be rejected" + ); + + let response = outcome + .abci_app + .process_proposal(round_1.clone()) + .expect("expected to process the round 1 proposal"); + assert_eq!(response.status, ProposalStatus::Accept as i32); + let round_1_extensions = extend_vote(&outcome, &round_1); + + assert_eq!( + round_0_extensions.len(), + 1, + "test premise: the queued withdrawal transaction is signed at this height" + ); + assert_ne!( + round_0_extensions, round_1_extensions, + "test premise: the newer core height changes the transaction validators sign" + ); + + assert_eq!( + verify(&outcome, &round_0, round_0_extensions), + VerifyStatus::Accept as i32, + "a round 0 precommit must be accepted after round 1 was processed" + ); + assert_eq!( + verify(&outcome, &round_1, round_1_extensions.clone()), + VerifyStatus::Accept as i32, + ); + assert_eq!( + verify(&outcome, &round_0, round_1_extensions), + VerifyStatus::Reject as i32, + "a round 0 precommit carrying round 1's withdrawals must be rejected" + ); + } +} From 39e785057053b6154b33abe2fb600603b7a12046 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 10:54:38 +0700 Subject: [PATCH 002/113] feat(platform)!: documents with a time to live, deleted by the platform (PV14) (#5007) Co-authored-by: Claude Opus 5.5 --- book/src/SUMMARY.md | 1 + book/src/data-model/contract-moderation.md | 2 +- book/src/data-model/document-ttl.md | 219 ++++ book/src/drive/time-range-ttl.md | 3 + book/src/error-handling/error-codes.md | 2 +- .../document/v3/document-meta.json | 6 + .../document_type/accessors/mod.rs | 48 + .../document_type/accessors/v2/mod.rs | 14 + .../v1/mod.rs | 4 +- .../try_from_schema/common/mod.rs | 129 ++- .../try_from_schema/v3/documents_ttl_tests.rs | 343 +++++++ .../class_methods/try_from_schema/v3/mod.rs | 13 +- .../methods/validate_update/v1/mod.rs | 116 +++ .../src/data_contract/document_type/mod.rs | 9 + .../property/list_element_reference.rs | 5 +- .../document_type/v2/accessors.rs | 10 + .../src/data_contract/document_type/v2/mod.rs | 9 + packages/rs-dpp/src/errors/consensus/codes.rs | 1 + .../state/document/document_expired_error.rs | 96 ++ .../errors/consensus/state/document/mod.rs | 1 + .../src/errors/consensus/state/state_error.rs | 20 +- packages/rs-drive-abci/src/config.rs | 4 +- .../engine/run_block_proposal/v0/mod.rs | 10 + .../block_end/expire_documents/mod.rs | 48 + .../block_end/expire_documents/v0/mod.rs | 40 + .../platform_events/block_end/mod.rs | 3 + .../v0/mod.rs | 61 ++ .../validation/state_transition/common/mod.rs | 2 + .../common/validate_document_not_expired.rs | 99 ++ .../document_reference_validation/v0/mod.rs | 7 +- .../batch/advanced_structure/v1/mod.rs | 25 + .../batch/tests/document/document_ttl.rs | 733 ++++++++++++++ .../batch/tests/document/mod.rs | 1 + .../batch/tests/token/burn/mod.rs | 5 +- .../batch/tests/token/direct_selling/mod.rs | 6 +- .../contract_user_moderation/state/v0/mod.rs | 19 + .../contract_user_moderation/tests.rs | 89 ++ .../v0/mod.rs | 8 +- .../data_contract_create/mod.rs | 35 + .../state_transitions/identity_create/mod.rs | 48 +- .../state_transitions/identity_top_up/mod.rs | 16 +- .../src/platform_types/platform/mod.rs | 6 +- .../test_cases/identity_and_document_tests.rs | 20 +- ...t-deletable-doc-registration-expiring.json | 45 + ...t-permanent-doc-registration-expiring.json | 45 + packages/rs-drive/grovedb-structure.json | 83 +- packages/rs-drive/src/config.rs | 20 + .../moderation/document_removal_tests.rs | 2 +- .../v0/mod.rs | 88 +- .../add_document_expiration_operations/mod.rs | 71 ++ .../v0/mod.rs | 104 ++ .../mod.rs | 53 + .../v0/mod.rs | 86 ++ .../document/expiration/expiration_tests.rs | 949 ++++++++++++++++++ .../expiration/fetch_expired_documents/mod.rs | 69 ++ .../fetch_expired_documents/v0/mod.rs | 94 ++ .../insert_documents_expirations_tree/mod.rs | 42 + .../v0/mod.rs | 26 + .../src/drive/document/expiration/mod.rs | 114 +++ .../src/drive/document/expiration/paths.rs | 41 + .../src/drive/document/expiration/pricing.rs | 269 +++++ .../mod.rs | 70 ++ .../v0/mod.rs | 92 ++ .../remove_expired_documents/mod.rs | 68 ++ .../remove_expired_documents/v0/mod.rs | 213 ++++ .../v1/mod.rs | 202 +++- packages/rs-drive/src/drive/document/mod.rs | 4 + .../rs-drive/src/drive/document/structure.rs | 4 +- .../v1/mod.rs | 52 +- .../src/drive/initialization/v4/mod.rs | 6 + .../rs-drive/src/drive/system/structure.rs | 49 +- packages/rs-drive/src/fees/op.rs | 205 +++- .../contract_user_moderation_transition.rs | 2 +- packages/rs-drive/src/structure/tests.rs | 88 +- .../src/util/batch/drive_op_batch/document.rs | 19 +- .../v0/mod.rs | 10 +- .../v0/mod.rs | 8 +- .../pending_grove_operations/mod.rs | 2 +- .../pending_grove_operations/tests.rs | 19 +- .../document_and_contract_info.rs | 26 + .../util/object_size_info/document_info.rs | 25 + .../v0/mod.rs | 54 +- .../v1/mod.rs | 50 +- .../drive_abci_method_versions/mod.rs | 3 + .../drive_abci_method_versions/v1.rs | 1 + .../drive_abci_method_versions/v10.rs | 1 + .../drive_abci_method_versions/v2.rs | 1 + .../drive_abci_method_versions/v3.rs | 1 + .../drive_abci_method_versions/v4.rs | 1 + .../drive_abci_method_versions/v5.rs | 1 + .../drive_abci_method_versions/v6.rs | 1 + .../drive_abci_method_versions/v7.rs | 1 + .../drive_abci_method_versions/v8.rs | 1 + .../drive_abci_method_versions/v9.rs | 1 + .../drive_document_method_versions/mod.rs | 16 + .../drive_document_method_versions/v1.rs | 15 +- .../drive_document_method_versions/v2.rs | 15 +- .../drive_document_method_versions/v3.rs | 15 +- .../drive_document_method_versions/v4.rs | 15 +- .../src/version/fee/document_ttl/mod.rs | 79 ++ .../src/version/fee/document_ttl/v1.rs | 49 + .../src/version/fee/mod.rs | 9 + .../rs-platform-version/src/version/fee/v1.rs | 3 + .../rs-platform-version/src/version/fee/v2.rs | 3 + .../rs-platform-version/src/version/fee/v3.rs | 5 + .../src/version/mocks/v2_test.rs | 4 + .../src/version/mocks/v3_test.rs | 1 + .../src/version/system_limits/mod.rs | 36 + .../src/version/system_limits/v1.rs | 4 + .../src/version/system_limits/v2.rs | 4 + .../src/version/system_limits/v3.rs | 4 + .../src/version/system_limits/v4.rs | 11 + .../rs-platform-version/src/version/v14.rs | 22 + .../src/errors/consensus/consensus_error.rs | 4 + 114 files changed, 5925 insertions(+), 207 deletions(-) create mode 100644 book/src/data-model/document-ttl.md create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/documents_ttl_tests.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/document/document_expired_error.rs create mode 100644 packages/rs-drive-abci/src/execution/platform_events/block_end/expire_documents/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/platform_events/block_end/expire_documents/v0/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_document_not_expired.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/document_ttl.rs create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-deletable-doc-registration-expiring.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-permanent-doc-registration-expiring.json create mode 100644 packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/mod.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/v0/mod.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/mod.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/v0/mod.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/expiration_tests.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/fetch_expired_documents/mod.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/fetch_expired_documents/v0/mod.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/insert_documents_expirations_tree/mod.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/insert_documents_expirations_tree/v0/mod.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/mod.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/paths.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/pricing.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/mod.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/v0/mod.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/remove_expired_documents/mod.rs create mode 100644 packages/rs-drive/src/drive/document/expiration/remove_expired_documents/v0/mod.rs create mode 100644 packages/rs-platform-version/src/version/fee/document_ttl/mod.rs create mode 100644 packages/rs-platform-version/src/version/fee/document_ttl/v1.rs diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index c41f5122fa5..5dc2b090f4e 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -57,6 +57,7 @@ - [Contract Groups](data-model/contract-groups.md) - [Contract Moderation](data-model/contract-moderation.md) - [Documents](data-model/documents.md) +- [Document Time To Live](data-model/document-ttl.md) - [Contested Documents](data-model/contested-documents.md) - [Identities](data-model/identities.md) - [Key Budgets and Expiry](data-model/key-limits.md) diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 9298c7ac091..87e77389fa2 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -144,7 +144,7 @@ ContractUserModerationAction::DeleteDocument { It names no identity (`identity_id()` is `None`): whose document it is is only known once the document is read. The transform checks, in order and each refusal paid: the document type exists (10406), it carries the keyword (`DocumentTypeNotDeletableByModeratorsError`, 41115), the signer is the owner or a moderator (41101), the document exists (`DocumentNotFoundError`), its owner is neither the contract owner nor a moderator (41102, the rule that protects them from a ban protects what they wrote), and block time is within the type's window after the document's last modification (`$updatedAt`, else `$createdAt`), when the type sets one (41116). The document is read the way a document's own deletion reads it, billed the same. The action carries the contract, the document's owner and the block time, so Drive reads nothing again. Nothing the document type prices is charged: neither its deletion token cost nor its `actionFees` deletion fee, both of which are what a document's own owner pays for deleting it. -Drive then runs `DocumentOperationType::DeleteDocumentByModerator`, the ordinary deletion (so every index and aggregate of the type stays right) without its `canBeDeleted` guard, which is the owner's rule and not the moderators', and writes a **removal record**: +Drive then runs `DocumentOperationType::ForceDeleteDocument`, the ordinary deletion (so every index and aggregate of the type stays right) without its `canBeDeleted` guard, which is the owner's rule and not the moderators', and writes a **removal record**: ```rust pub struct ContractDocumentRemoval { diff --git a/book/src/data-model/document-ttl.md b/book/src/data-model/document-ttl.md new file mode 100644 index 00000000000..41f139f7b3a --- /dev/null +++ b/book/src/data-model/document-ttl.md @@ -0,0 +1,219 @@ +# Document Time To Live + +A document type may declare a **time to live**. Every document of such a type is deleted by +the platform once its time to live has passed, whoever owns it and whatever the type says +about who else may delete it. Its owner pays, when the document is written, for the time +the document will occupy the state rather than for perpetual storage, and prepays its +deletion. Available from protocol version 14. + +```json +"note": { + "type": "object", + "ttl": 1209600, + "properties": { "text": { "type": "string", "maxLength": 280, "position": 0 } }, + "required": ["$createdAt", "text"], + "additionalProperties": false +} +``` + +Documents of `note` above are deleted two weeks (1,209,600 seconds) after their creation. + +This is a different feature from the `ttl` key of a `timeRange` index (see +[Time-Range Index TTL](../drive/time-range-ttl.md)), which expires index entries and leaves +the document in place. + +## Semantics + +- A document expires at its `$createdAt` plus `ttl` seconds. `$createdAt` is set from block + time when the document is created, so nothing the writer sends moves the expiry, and every + document of a type created in one block expires at the same time. +- A replace, a transfer, a purchase or a price update never moves the expiry: `$createdAt` + never changes. A buyer of an expiring document buys what is left of its life. +- After each block's state transitions the platform deletes the expired documents, oldest + first, at most `max_document_expirations_per_block` per block (128 at protocol version + 14), and no more than `max_document_expiration_weight_per_block` (1,024) in weight, where + a document weighs 1 plus the index levels of its type. The rest wait for the next block. A state transition in the block a document expires + in still sees it; a document can therefore outlive its expiry by the part of a block + before the cleanup, and by more when a backlog builds. +- The expiry belongs to the document: it is its own `$createdAt` plus the type's `ttl`, both + fixed, and the platform reads it from there. The expirations tree is only the index the + cleanup finds documents through. +- From its expiry on, a document can no longer be replaced, transferred, bought or repriced, + and a moderator can no longer restore it: each is refused, paid, with + `DocumentExpiredError` (40140), whether or not the cleanup has reached the document. Its + owner may still delete it where `canBeDeleted` allows, which only removes it sooner. Until the cleanup deletes it, it + can still be queried and referenced, and it keeps its values in the type's unique indexes: + a create of the same unique value is refused as a duplicate until the cleanup has run. The + cleanup deletes a fixed number per block, so a sustained flood of creations with a short + time to live builds a backlog it drains at that rate, and that lag grows with it. +- The document may be deleted earlier as usual: by its owner when `canBeDeleted` allows it, + by the contract's moderators when `canBeDeletedByModerators` does. `canBeDeleted: false` + only stops the owner; the platform still deletes the document when it expires. +- The deletion is an ordinary deletion: the document and every index entry go, count and sum + trees are decremented, and nothing is left behind. + +## Where a time to live is refused + +The keyword is refused, on every parse of the contract (registration, update and a stored +contract read back), when: + +| The document type | Why | +|---|---| +| does not list `$createdAt` in `required` | The expiry is computed from it. | +| sets `documentsKeepHistory: true` | Drive refuses to delete a document whose type keeps history. | +| sets `indexOnly: true` | There is no stored row to delete by id. | +| has a contested index | A contested document waits in its vote poll until the poll awards it, keeping the `$createdAt` of its create, so it could expire before it is stored. | +| declares `ttl: 0` | A time to live lasts at least a second. | + +When a contract is registered or updated, a `ttl` below `min_document_ttl_seconds` (one hour at +protocol version 14) or above `max_document_ttl_seconds` (one year) is refused too. The floor +keeps a document in state well past the moment its writer fetches the proof of its create, +which proves the document present; a document the cleanup had already deleted would fail that +proof although the create succeeded. A contract update may not add, remove or change the `ttl` +of an existing document type: every stored document carries the expiry it was written and paid +with. A document type added by an update declares it freely. + +The same holds for any write close to a document's expiry: a replace, transfer, purchase or price +update accepted in the last block before the expiry proves the document present, and a proof +fetched after the next block's cleanup finds it gone. + +References treat a type with a `ttl` as deletable, like one with `canBeDeleted` or +`canBeDeletedByModerators`. A `permanentDocument` reference, a lookup one included, and a list +element reference may not target it; a `deletableDocument` reference may, a lookup one included. +The check is `DocumentTypeV2Getters::documents_can_disappear`. + +Everything else composes: mutable types, `transferable`, `tradeMode`, +`canBeDeletedByModerators` (a moderator's restore puts the document back with its original +`$createdAt`, and is refused once that document has expired), +`creationRestrictionMode`, countable, summable and ranked indexes, `timeRange` indexes with +or without their own `ttl`, `refersTo` declared on the type, action fees and token costs. + +## Storage + +Nothing a document of such a type writes carries storage flags: its primary item, its index +entries and the index trees it creates are flagless, and its deletion refunds nothing. +Drive drops the writer's flags when the document is inserted or changed +(`DocumentAndContractInfo::without_storage_flags_if_expiring`), before any element is sized, +and the re-tagging described below strips any left. + +Each document also gets an entry in the **documents expirations tree** under `Misc`: + +```text +Misc (104) / E / / -> contract id (32 bytes) ++ document type name +``` + +The entry is written with the document and removed with it, whoever deletes it (the hook is +in `force_delete_document_for_contract_operations`, which the owner's, the moderators' and the +cleanup's deletions share). The last entry of an expiry time takes the tree of that time with +it, so every tree of an expiry time holds at least one entry, and a document deleted early +leaves nothing the cleanup would have to read. The expirations tree itself is created with the +initial state structure of protocol version 14 and on the first block of protocol version 14, +through one helper (`Drive::insert_documents_expirations_tree`). + +## Fees + +The fee schedule's `document_ttl` group (`FeeDocumentTtlVersion`, `FEE_VERSION3`) prices a +document of such a type: + +- **Bytes.** Every byte the document writes, its expirations tree entry included, costs the + price of the lifetime it has left. Up to seven days a tier applies; past that a price per + `pricing_period_seconds` spanned, rounded up. The period is part of the schedule (788,400 + seconds, mainnet's epoch length), not the node's epoch length, so a network configured with + short epochs, like testnet's hour, prices a lifetime as mainnet does. + + | Lifetime | Credits per byte (protocol version 14) | + |---|---| + | up to 1 hour | 1 | + | up to 1 day | 4 | + | up to 2 days | 8 | + | up to 4 days | 15 | + | up to 7 days | 26 | + | longer | 34 per 9.125 days spanned | + + The values are the first year's share of the perpetual storage price (27,000 credits per + byte, 5% of it paid out in the first year) pro rata, rounded up. A one-year `ttl` pays + 40 × 34 = 1,360 credits per byte, about what a permanent document deleted after a year + keeps paying net of its refund. +- **Route.** A document of a type whose `ttl` is shorter than `processing_route_below_epochs` + epochs (two) of the network pays that amount into the current epoch's processing fees; one + of a longer `ttl` into the storage fee distribution pool, which spreads it over future + epochs like any storage fee. Here the epoch is the network's: the node's + `epoch_time_length_s`, handed to Drive through `DriveConfig`. The route follows the declared + `ttl`, not the lifetime left, so every write of a document takes the same one. +- **Deletion.** Creating the document prepays, as processing, what its deletion will cost: + `cleanup_base_processing_cost` (1,200,000), plus `cleanup_processing_cost_per_index_level` + (400,000) per index level of the type, where an index counts its properties, times the + overlapping windows of a `timeRange` index, plus `cleanup_processing_cost_per_document_byte` + (420, what removing and reading a byte costs) per byte of the stored document. The cleanup + itself bills nobody. +- **Changes.** A replace, transfer, purchase or price update prices the bytes it adds by the + lifetime left at that block, and prepays the deletion of the document bytes it adds at + `cleanup_processing_cost_per_document_byte`. A deletion by the owner or a moderator pays its + own processing like any deletion; the prepaid deletion is the platform's, and is not + refunded. + +The price never decreases with the lifetime and the route depends on the `ttl` alone, so an +estimate made at an earlier block time (check_tx) stays an upper bound of the execution. A dry +run estimates a change as an insert of the changed document +(`estimate_document_change_as_insert_operations_v1`): without the entry and the deletion the +creation prepaid, which a change never pays, and with the deletion of all the document's +bytes, at least what the change adds. In Drive the document's grove operations are +re-tagged `EphemeralGroveOperation(_, EphemeralPricing::DocumentTtl { .. })` and applied as +their own GroveDB batch, so their added bytes can be priced apart from the rest of the +transition (see `apply_batch_low_level_drive_operations`); operations already tagged for a +`timeRange` index's `ttl` keep that rule. + +## Expired documents + +`validate_document_not_expired` (drive-abci, `state_transition/common`) is the one rule: a +document of a type with a `ttl` has expired when block time is at or past its `$createdAt` +plus the `ttl` (Drive's `document_expires_at`, the time its entry is keyed by), the same +boundary the cleanup deletes at. The batch's advanced structure validation (v1, protocol +version 14 only), which check_tx runs too, calls it for every document action through +`validate_document_action_not_expired`, an exhaustive match that refuses, paid, a replace, +transfer, purchase or price update of an expired document. The moderator restore calls it too, judging the +`$createdAt` of the document the removal record's hash pins. A document deletion by its owner +does not. + +## Cleanup + +`Platform::expire_documents` runs after the block's state transitions, right after the address +balance cleanup in `run_block_proposal`, and calls `Drive::remove_expired_documents` with +`max_document_expirations_per_block` and `max_document_expiration_weight_per_block`: + +1. `fetch_expired_documents` reads, in one query, the entries of the expiry times at or before + the block time, oldest first, at most the limit of them. Every tree of an expiry time holds + an entry, so the query visits at most the limit of trees. +2. Each expired document is read and checked against state (its contract, document type, + `ttl` and stored document, and that the document expires when its entry says), then + deleted from what was read: the deletion an owner runs, without the `canBeDeleted` guard + (`delete_read_document_for_contract_operations_v0`). Its entry, and the tree of its expiry + time when it was the last, go with it. +3. An entry without a document to delete is logged and only removed. None is expected, and + failing the block over one would halt the chain. +4. The cleanup stops before a removal that would pass the weight budget (an entry's removal + weighs 1), except the block's first, so the backlog always drains. + +Every removal goes into one GroveDB batch, applied without a fee, each built against the +removals queued before it, so every emptiness check sees them. + +## Versioning + +Everything rides protocol version 14, unreleased when this landed: the keyword joined document +meta-schema v3 and the generation 3 parser, the limits joined `SYSTEM_LIMITS_V4`, the fee group +joined `FEE_VERSION3`, the update rule joined `validate_update` v1, and `expire_documents` is +`Some(0)` in `DRIVE_ABCI_METHOD_VERSIONS_V10` only. The shipped generations edited in place are +inert before 14: + +- the deletion hook in `delete_document_for_contract_operations` v0, the reference checks + (`documents_can_disappear`) and the restore check of `contract_user_moderation` state v0: + `documents_ttl_seconds` is only ever `Some` on a document type parsed from the keyword, + which no earlier protocol version reads. The deletion's post-read part moved into + `delete_read_document_for_contract_operations_v0` with its operations unchanged; +- the `expire_documents` call in `run_block_proposal` v0: the method is `None` before 14; +- the tree's creation in `transition_to_version_14` (`perform_events_on_first_block_of_protocol_change` + v0), which only an upgrade to 14 runs; +- one batch per pricing rule in `apply_batch_low_level_drive_operations` v0 and the + `DocumentTtl` arm of `consume_to_fees_v0`: nothing is tagged ephemeral before 14; +- the pattern-only edits in `batch_insert_empty_tree_if_not_exists` v0 and + `convert_drive_operations_to_grove_operations` v0, whose output is unchanged. diff --git a/book/src/drive/time-range-ttl.md b/book/src/drive/time-range-ttl.md index 0a5d46ffb6e..0108fc40347 100644 --- a/book/src/drive/time-range-ttl.md +++ b/book/src/drive/time-range-ttl.md @@ -7,6 +7,9 @@ shares. The storage primitive underneath is grovedb's flat-subtree drop landed in grovedb PR #849); see [the storage section](#grovedb-dependency-flat-subtree-drop). +A document type can also expire whole documents with its own `ttl` keyword; that is a +different mechanism, described in [Document Time To Live](../data-model/document-ttl.md). + ## Motivation A `timeRange` index stores every document once per containing window, and diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index 83b58e34750..7e76f7cd311 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -107,7 +107,7 @@ The fee category currently has a single code. The 30000 range is reserved for fu | Range | Category | Examples | |-------|----------|----------| | 40000-40009 | Data Contract | `DataContractAlreadyPresentError` (40000), `DataContractIsReadonlyError` (40001), `DataContractNotFoundError` (40008) | -| 40100-40139 | Documents | `DocumentAlreadyPresentError` (40100), `DocumentNotFoundError` (40101), `DuplicateUniqueIndexError` (40105), `DocumentActionFeeAgreementNotSetError` (40132), `DocumentActionFeeAgreementMismatchError` (40133), `DocumentActionFeeMultiplierNotToleratedError` (40134), `DocumentActionFeeModeratorsShareMismatchError` (40139) | +| 40100-40140 | Documents | `DocumentAlreadyPresentError` (40100), `DocumentNotFoundError` (40101), `DuplicateUniqueIndexError` (40105), `DocumentActionFeeAgreementNotSetError` (40132), `DocumentActionFeeAgreementMismatchError` (40133), `DocumentActionFeeMultiplierNotToleratedError` (40134), `DocumentActionFeeModeratorsShareMismatchError` (40139), `DocumentExpiredError` (40140) | | 40200-40217 | Identity | `IdentityAlreadyExistsError` (40200), `InvalidIdentityRevisionError` (40203), `IdentityInsufficientBalanceError` (40210) | | 40300-40307 | Voting | `MasternodeNotFoundError` (40300), `MasternodeVoteAlreadyPresentError` (40304), `VoteChoiceNotAllowedForVotePollError` (40307) | | 40400-40401 | Prefunded Balances | `PrefundedSpecializedBalanceInsufficientError` (40400) | diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 8884e090a6e..31807057c01 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1775,6 +1775,12 @@ "maximum": 4294967295, "description": "For how many seconds after a document's last modification the contract's moderators may still delete it. The last modification is the document's `$updatedAt`, or its `$createdAt` on a type that carries no `$updatedAt`. Once block time is later than that plus this many seconds the document is settled: no moderator can delete it any more, the contract owner included. A replace or a price update moves `$updatedAt` and opens the window again; a transfer or a purchase does not. Absent means no limit. Requires `canBeDeletedByModerators: true` and `$updatedAt` in `required`; a type with `documentsMutable: false`, whose documents never change after their creation, may list `$createdAt` instead. Fixed when the document type is created. Says nothing about what a document's own owner may do. Available from protocol version 14." }, + "ttl": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295, + "description": "Time to live, in seconds: the platform deletes each document of this type once its `$createdAt` plus this many seconds has passed, whoever owns it and whatever `canBeDeleted` says, at most a protocol-versioned number of documents per block (128 at protocol version 14) after the block's state transitions. Its documents are stored without storage flags and refund nothing when deleted: instead of the perpetual storage price, each byte they write pays a price for the time they will live (five tiers up to seven days, then per 9.125 days spanned), paid into the processing fee pool when the `ttl` is shorter than two epochs and into the storage fee pool otherwise, and a document prepays its deletion as processing when it is created. From its expiry on, a document can no longer be replaced, transferred, bought, repriced or restored by a moderator; its owner may still delete it where `canBeDeleted` allows. Requires `$createdAt` in `required`; refused together with `documentsKeepHistory`, `indexOnly` and a contested index. A `permanentDocument` reference, a lookup one included, and a list element reference may not target the type; a `deletableDocument` reference, a lookup one included, may. At least and at most protocol-versioned bounds (3600, one hour, and 31536000, one year, at protocol version 14). Fixed when the document type is created: an update may not add, remove or change it. Available from protocol version 14." + }, "indexOnly": { "type": "boolean", "description": "When true, documents of this type are never written to primary storage: the index entries are the rows, each terminating in an Item keyed by the index's `terminal` property instead of a Reference keyed by the document id. Only what is in the indexes exists and is recoverable. Requires: every property required and appearing in at least one index (except a `skipIfAbsent` index's optional first property), $ownerId in at least one index (as a property or terminal), documentsMutable: false, no transfers/trading/history/transient properties, and no doctype-level aggregate keywords (use the index-level count flags). Available from protocol version 14." diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs index d3cad4980f0..13996994e86 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs @@ -1019,6 +1019,22 @@ impl DocumentTypeV2Getters for DocumentType { } } + fn documents_ttl_seconds(&self) -> Option { + match self { + DocumentType::V0(_) => None, + DocumentType::V1(_) => None, + DocumentType::V2(v2) => v2.documents_ttl_seconds(), + } + } + + fn documents_can_disappear(&self) -> bool { + match self { + DocumentType::V0(v0) => v0.documents_can_be_deleted(), + DocumentType::V1(v1) => v1.documents_can_be_deleted(), + DocumentType::V2(v2) => v2.documents_can_disappear(), + } + } + fn distinct_from_fields(&self) -> &[String] { match self { DocumentType::V0(_) => &[], @@ -1176,6 +1192,22 @@ impl DocumentTypeV2Getters for DocumentTypeRef<'_> { } } + fn documents_ttl_seconds(&self) -> Option { + match self { + DocumentTypeRef::V0(_) => None, + DocumentTypeRef::V1(_) => None, + DocumentTypeRef::V2(v2) => v2.documents_ttl_seconds(), + } + } + + fn documents_can_disappear(&self) -> bool { + match self { + DocumentTypeRef::V0(v0) => v0.documents_can_be_deleted(), + DocumentTypeRef::V1(v1) => v1.documents_can_be_deleted(), + DocumentTypeRef::V2(v2) => v2.documents_can_disappear(), + } + } + fn distinct_from_fields(&self) -> &[String] { match self { DocumentTypeRef::V0(_) => &[], @@ -1299,6 +1331,22 @@ impl DocumentTypeV2Getters for DocumentTypeMutRef<'_> { } } + fn documents_ttl_seconds(&self) -> Option { + match self { + DocumentTypeMutRef::V0(_) => None, + DocumentTypeMutRef::V1(_) => None, + DocumentTypeMutRef::V2(v2) => v2.documents_ttl_seconds(), + } + } + + fn documents_can_disappear(&self) -> bool { + match self { + DocumentTypeMutRef::V0(v0) => v0.documents_can_be_deleted(), + DocumentTypeMutRef::V1(v1) => v1.documents_can_be_deleted(), + DocumentTypeMutRef::V2(v2) => v2.documents_can_disappear(), + } + } + fn distinct_from_fields(&self) -> &[String] { match self { DocumentTypeMutRef::V0(_) => &[], diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs index 426ffaedca5..388863619d3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs @@ -53,6 +53,20 @@ pub trait DocumentTypeV2Getters { /// document type that predates the keyword answers. fn documents_can_be_deleted_by_moderators_for(&self) -> Option; + /// How many seconds after its creation (`$createdAt`) the platform deletes each + /// document of the type (the `ttl` keyword, protocol version 14). `None` means the + /// documents live until someone deletes them, and is what every document type that + /// predates the keyword answers. + fn documents_ttl_seconds(&self) -> Option; + + /// Whether a document of the type can stop existing once written: its owner may delete + /// it (`canBeDeleted`), the contract's moderators may (`canBeDeletedByModerators`), or + /// the platform deletes it when its `ttl` passes. A `permanentDocument` reference and a + /// list element reference may only target a type for which this is false, and a + /// `deletableDocument` reference only one for which it is true; a lookup follows the + /// kind it declares. + fn documents_can_disappear(&self) -> bool; + /// The top-level properties frozen at document creation on a mutable /// document type (the `immutable` keyword, protocol version 14). A /// replace that changes, adds or removes any of them is rejected with diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs index d207a3b1d1b..35970b2fca0 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs @@ -207,8 +207,8 @@ impl DocumentType { // exists from the same protocol version 14 as every other lookup, so // this stays inert before it let referenced = referenced_document_type.as_ref(); - let deletable = referenced.documents_can_be_deleted() - || referenced.documents_can_be_deleted_by_moderators(); + // Deletable by anyone: owner, moderators, or the platform (`ttl`). + let deletable = referenced.documents_can_disappear(); if permanent == deletable { continue; } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 9b4be2ec681..9266e6938f8 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -27,11 +27,10 @@ use crate::data_contract::document_type::index_level::IndexLevel; use crate::data_contract::document_type::property::DocumentProperty; use crate::data_contract::document_type::property::DocumentPropertyType; use crate::data_contract::document_type::property_names::{ - CAN_BE_DELETED, CAN_BE_DELETED_BY_MODERATORS, CAN_BE_DELETED_BY_MODERATORS_FOR, - CREATION_RESTRICTION_MODE, DOCUMENTS_AVERAGEABLE, DOCUMENTS_COUNTABLE, DOCUMENTS_KEEP_HISTORY, - DOCUMENTS_MUTABLE, DOCUMENTS_SUMMABLE, INDEX_ONLY, KEEPS_PRICING_HISTORY, - KEEPS_PURCHASE_HISTORY, KEEPS_TRANSFER_HISTORY, RANGE_AVERAGEABLE, RANGE_COUNTABLE, - RANGE_SUMMABLE, TRADE_MODE, TRANSFERABLE, + CAN_BE_DELETED, CAN_BE_DELETED_BY_MODERATORS, CREATION_RESTRICTION_MODE, DOCUMENTS_AVERAGEABLE, + DOCUMENTS_COUNTABLE, DOCUMENTS_KEEP_HISTORY, DOCUMENTS_MUTABLE, DOCUMENTS_SUMMABLE, INDEX_ONLY, + KEEPS_PRICING_HISTORY, KEEPS_PURCHASE_HISTORY, KEEPS_TRANSFER_HISTORY, RANGE_AVERAGEABLE, + RANGE_COUNTABLE, RANGE_SUMMABLE, TRADE_MODE, TRANSFERABLE, }; use crate::data_contract::document_type::restricted_creation::CreationRestrictionMode; use crate::data_contract::document_type::token_costs::v0::TokenCostsV0; @@ -2119,12 +2118,13 @@ pub(super) fn apply_can_be_deleted_by_moderators( Ok(()) } -/// Reads the doctype-level `canBeDeletedByModeratorsFor` keyword, a number of -/// seconds, before the core parse consumes `schema`. Its shape is enforced here -/// and not left to the meta-schema: a stored contract is read without one, and -/// no doctype-level keyword of this generation is read more leniently there. -pub(super) fn parse_can_be_deleted_by_moderators_for_keyword( +/// Reads a doctype-level keyword holding a number of seconds (`canBeDeletedByModeratorsFor`, +/// `ttl`) before the core parse consumes `schema`. Its shape is enforced here and not left +/// to the meta-schema: a stored contract is read without one, and no doctype-level keyword +/// of this generation is read more leniently there. +pub(super) fn parse_seconds_keyword( schema: &Value, + keyword: &str, ) -> Result, ProtocolError> { // A schema that is not an object carries no keyword. Like every other // doctype-level keyword read before the core parser, this one must not be @@ -2135,7 +2135,7 @@ pub(super) fn parse_can_be_deleted_by_moderators_for_keyword( return Ok(None); }; - Value::inner_optional_integer_value::(schema_map, CAN_BE_DELETED_BY_MODERATORS_FOR) + Value::inner_optional_integer_value::(schema_map, keyword) .map_err(consensus_or_protocol_value_error) } @@ -2209,6 +2209,113 @@ pub(super) fn apply_can_be_deleted_by_moderators_for( Ok(()) } +/// Applies the `ttl` keyword and checks what it requires. +/// +/// The platform deletes every document of the type once `$createdAt` plus `ttl` +/// seconds has passed, finding it through the expirations tree entry written when the +/// document was created, so: +/// - the type must require `$createdAt`: a document's expiry is computed from it when it +/// is written, replaced and deleted, and it is set from block time, so nothing the +/// writer sends moves it; +/// - the type must not keep history: the storage layer refuses to delete a document whose +/// type keeps history; +/// - the type must not be indexOnly: such a document has no stored row to delete by id; +/// - the type must not have a contested index: a contested document waits in its vote +/// poll, outside the documents tree, until the poll awards it, keeping its `$createdAt` +/// from the create, so it could expire before it exists; +/// - the time to live is at least a second, and under full validation (a contract being +/// registered or updated) at least `min_document_ttl_seconds` and at most +/// `max_document_ttl_seconds`. The floor keeps a document in state well past the moment +/// its writer fetches the proof of its create, which proves it present. +/// +/// What may point at the type follows from `documents_can_disappear`: a `permanentDocument` +/// or list element reference may not target it; a `deletableDocument` reference may, and so +/// may a lookup, which names the kind of document it resolves to (`deletableDocument`). +/// +/// The rules other than the bounds hold for every contract that could be stored (the keyword +/// arrives with protocol version 14), so they are not skipped when a stored contract is +/// read back. Runs after `apply_index_only`, whose flag it reads. +pub(super) fn apply_documents_ttl( + document_type: &mut DocumentTypeV2, + ttl_seconds: Option, + name: &str, + full_validation: bool, + platform_version: &PlatformVersion, +) -> Result<(), ProtocolError> { + let Some(seconds) = ttl_seconds else { + return Ok(()); + }; + let structure_error = |message: String| { + consensus_or_protocol_data_contract_error(DataContractError::InvalidContractStructure( + message, + )) + }; + + if seconds == 0 { + return Err(structure_error(format!( + "document type \"{}\" sets `ttl: 0`: a time to live lasts at least one second \ + (leave `ttl` out for documents that live until someone deletes them)", + name, + ))); + } + if full_validation { + if let Some(min_seconds) = platform_version.system_limits.min_document_ttl_seconds { + if seconds < min_seconds { + return Err(structure_error(format!( + "document type \"{}\" sets `ttl: {}`, below the shortest time to live a \ + document type may declare, {} seconds", + name, seconds, min_seconds, + ))); + } + } + if let Some(max_seconds) = platform_version.system_limits.max_document_ttl_seconds { + if seconds > max_seconds { + return Err(structure_error(format!( + "document type \"{}\" sets `ttl: {}`, above the longest time to live a \ + document type may declare, {} seconds", + name, seconds, max_seconds, + ))); + } + } + } + if !document_type.required_fields.contains(CREATED_AT) { + return Err(structure_error(format!( + "document type \"{}\" sets `ttl`, which is counted from a document's creation: \ + list `$createdAt` in `required`", + name, + ))); + } + if document_type.documents_keep_history { + return Err(structure_error(format!( + "document type \"{}\" sets both `documentsKeepHistory: true` and `ttl`, but the \ + storage layer refuses to delete a document whose type keeps history", + name, + ))); + } + if document_type.index_only { + return Err(structure_error(format!( + "indexOnly document type \"{}\" must not set `ttl`: there is no stored row the \ + platform could delete by id", + name, + ))); + } + if document_type + .indices + .values() + .any(|index| index.contested_index.is_some()) + { + return Err(structure_error(format!( + "document type \"{}\" has a contested index and must not set `ttl`: a contested \ + document waits in its vote poll until the poll awards it, and could expire \ + before it is stored", + name, + ))); + } + + document_type.documents_ttl_seconds = Some(seconds); + Ok(()) +} + /// Reads a doctype-level array of top-level property names (`immutable`, the /// properties frozen at document creation on a mutable type, or /// `immutableAllowSetting`, the frozen properties a replace may still set diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/documents_ttl_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/documents_ttl_tests.rs new file mode 100644 index 00000000000..c38c3283f69 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/documents_ttl_tests.rs @@ -0,0 +1,343 @@ +//! The `ttl` doctype keyword (protocol version 14): the platform deletes each document of +//! the type once `$createdAt` plus the time to live has passed. What it requires of the +//! document type, on both parse paths, and what it makes of the type's deletability. +use super::*; +use crate::data_contract::config::moderation::{ContractModerationConfig, ContractModerators}; +use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; +use platform_value::platform_value; + +fn parse_with_config( + schema: Value, + config: &DataContractConfig, + protocol_version: u32, + full_validation: bool, +) -> Result { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected platform version"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "note", + schema, + None, + &BTreeMap::new(), + config, + full_validation, + &mut vec![], + platform_version, + ) +} + +fn parse(schema: Value, full_validation: bool) -> Result { + let platform_version = PlatformVersion::latest(); + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available"); + parse_with_config( + schema, + &config, + platform_version.protocol_version, + full_validation, + ) +} + +/// A two-week note: `$createdAt` required, one plain index. +fn note_schema(extra: Value) -> Value { + let mut schema = platform_value!({ + "type": "object", + "properties": { + "text": { "type": "string", "maxLength": 50, "position": 0 }, + }, + "indices": [ + { "name": "byOwner", "properties": [{ "$ownerId": "asc" }] }, + ], + "required": ["$createdAt"], + "additionalProperties": false, + "ttl": 1_209_600, + }); + if let (Value::Map(schema_map), Value::Map(extra_map)) = (&mut schema, extra) { + for (key, value) in extra_map { + schema_map.retain(|(existing, _)| existing != &key); + schema_map.push((key, value)); + } + } + schema +} + +fn assert_refused_naming(result: Result, fragments: &[&str]) { + let error = result.expect_err("the document type must be refused"); + // A paid refusal needs the consensus variant: the data contract error variant would + // surface as an internal error in a block. + assert!( + matches!(error, ProtocolError::ConsensusError(_)), + "expected a consensus error, got {error:?}" + ); + let message = format!("{error:?}"); + for fragment in fragments { + assert!( + message.contains(fragment), + "error must name {fragment}, got {message}" + ); + } +} + +#[test] +fn should_parse_the_time_to_live() { + for full_validation in [true, false] { + let document_type = parse(note_schema(platform_value!({})), full_validation) + .expect("a two-week note parses"); + assert_eq!(document_type.documents_ttl_seconds(), Some(1_209_600)); + } +} + +#[test] +fn should_leave_documents_without_a_time_to_live_by_default() { + let document_type = parse( + platform_value!({ + "type": "object", + "properties": { "text": { "type": "string", "maxLength": 50, "position": 0 } }, + "additionalProperties": false, + }), + true, + ) + .expect("parse"); + assert_eq!(document_type.documents_ttl_seconds(), None); +} + +#[test] +fn should_make_a_type_whose_owners_can_not_delete_its_documents_disappear() { + // `canBeDeleted: false` only stops the owner; the platform still deletes, so a + // reference that must always resolve may not target the type. + let document_type = parse( + note_schema(platform_value!({ "canBeDeleted": false, "documentsMutable": false })), + true, + ) + .expect("parse"); + assert!(!document_type.documents_can_be_deleted()); + assert!(document_type.documents_can_disappear()); + + let permanent = parse( + platform_value!({ + "type": "object", + "canBeDeleted": false, + "properties": { "text": { "type": "string", "maxLength": 50, "position": 0 } }, + "additionalProperties": false, + }), + true, + ) + .expect("parse"); + assert!(!permanent.documents_can_disappear()); +} + +#[test] +fn should_refuse_a_time_to_live_without_created_at_on_both_paths() { + for full_validation in [true, false] { + assert_refused_naming( + parse( + note_schema(platform_value!({ "required": ["$updatedAt"] })), + full_validation, + ), + &["ttl", "$createdAt"], + ); + } +} + +#[test] +fn should_refuse_a_time_to_live_on_a_type_that_keeps_history_on_both_paths() { + // `canBeDeleted: false` keeps the separate keep-history-and-deletable rule out of + // the way, so the refusal is the time to live's own. + for full_validation in [true, false] { + assert_refused_naming( + parse( + note_schema(platform_value!({ + "documentsKeepHistory": true, + "canBeDeleted": false, + })), + full_validation, + ), + &["documentsKeepHistory", "ttl"], + ); + } +} + +#[test] +fn should_refuse_a_time_to_live_on_a_type_with_a_contested_index_on_both_paths() { + // A contested document waits in its vote poll until the poll awards it, keeping the + // `$createdAt` of its create: it could expire before it is stored. + for full_validation in [true, false] { + let schema = platform_value!({ + "type": "object", + "documentsMutable": false, + "ttl": 86_400, + "indices": [ + { + "name": "byLabel", + "properties": [{ "normalizedLabel": "asc" }], + "unique": true, + "contested": { + "fieldMatches": [ + { "field": "normalizedLabel", "regexPattern": "^[a-z]{3,}$" }, + ], + "resolution": 0, + }, + }, + ], + "properties": { + "normalizedLabel": { "type": "string", "maxLength": 50, "position": 0 }, + }, + "required": ["normalizedLabel", "$createdAt"], + "additionalProperties": false, + }); + assert_refused_naming(parse(schema, full_validation), &["contested index", "ttl"]); + } +} + +#[test] +fn should_refuse_a_time_to_live_on_an_index_only_type_on_both_paths() { + for full_validation in [true, false] { + let schema = platform_value!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "ttl": 86_400, + "properties": { + "hashtag": { "type": "string", "maxLength": 63, "position": 0 }, + }, + "required": ["hashtag", "$createdAt"], + "indices": [ + { + "name": "byHashtag", + "properties": [{ "hashtag": "asc" }, { "$createdAt": "asc" }], + "terminal": "$ownerId", + }, + { + "name": "byOwner", + "properties": [{ "$ownerId": "asc" }], + "terminal": "hashtag", + }, + ], + "additionalProperties": false, + }); + assert_refused_naming(parse(schema, full_validation), &["indexOnly", "ttl"]); + } +} + +#[test] +fn should_refuse_a_time_to_live_that_is_not_a_positive_number_of_seconds_on_both_paths() { + // No meta-schema stands in front of a stored contract, and no keyword is read more + // leniently there: the parser refuses the shape itself. + for full_validation in [true, false] { + for ttl in [ + platform_value!(0), + platform_value!(-5), + platform_value!(4294967296u64), + platform_value!("two weeks"), + ] { + let result = parse( + note_schema(platform_value!({ "ttl": ttl.clone() })), + full_validation, + ); + assert!( + result.is_err(), + "ttl {ttl:?} must be refused (full validation: {full_validation})" + ); + } + } +} + +#[test] +fn should_cap_the_time_to_live_at_registration_only() { + let max = PlatformVersion::latest() + .system_limits + .max_document_ttl_seconds + .expect("protocol version 14 caps the time to live"); + let document_type = + parse(note_schema(platform_value!({ "ttl": max })), true).expect("the cap itself parses"); + assert_eq!(document_type.documents_ttl_seconds(), Some(max)); + + assert_refused_naming( + parse(note_schema(platform_value!({ "ttl": max + 1 })), true), + &["ttl", "longest time to live"], + ); + // A stored contract is read back without the registration limits, like + // `max_typed_array_items`: a lower cap in a later version must not brick it. + let stored = parse(note_schema(platform_value!({ "ttl": max + 1 })), false) + .expect("the stored path does not apply the cap"); + assert_eq!(stored.documents_ttl_seconds(), Some(max + 1)); +} + +#[test] +fn should_refuse_a_time_to_live_under_the_floor_at_registration_only() { + // A document the cleanup deletes before its writer fetches the proof of its create + // would fail that proof: registration keeps every time to live above the floor. + let min = PlatformVersion::latest() + .system_limits + .min_document_ttl_seconds + .expect("protocol version 14 has a floor"); + let document_type = + parse(note_schema(platform_value!({ "ttl": min })), true).expect("the floor parses"); + assert_eq!(document_type.documents_ttl_seconds(), Some(min)); + + assert_refused_naming( + parse(note_schema(platform_value!({ "ttl": min - 1 })), true), + &["ttl", "shortest time to live"], + ); + // A stored contract is read back without the registration limits. + let stored = parse(note_schema(platform_value!({ "ttl": 60 })), false) + .expect("the stored path does not apply the floor"); + assert_eq!(stored.documents_ttl_seconds(), Some(60)); +} + +#[test] +fn should_allow_a_time_to_live_with_every_owner_and_moderation_feature() { + // Transfers and trades hand over what is left of a document's life; moderators delete + // it early; a mutable type replaces it without moving its expiry. + let document_type = parse( + note_schema(platform_value!({ + "documentsMutable": true, + "transferable": 1, + "tradeMode": 1, + "canBeDeleted": false, + })), + true, + ) + .expect("parse"); + assert_eq!(document_type.documents_ttl_seconds(), Some(1_209_600)); + + let platform_version = PlatformVersion::latest(); + let moderated = DataContractConfig::default_for_version(platform_version) + .expect("default config available") + .with_moderation(Some(ContractModerationConfig { + banlist: false, + suspensions: false, + moderators: ContractModerators::ContractOwner, + warnings: false, + })); + let document_type = parse_with_config( + note_schema(platform_value!({ + "canBeDeletedByModerators": true, + "canBeDeletedByModeratorsFor": 3600, + "documentsMutable": false, + })), + &moderated, + platform_version.protocol_version, + true, + ) + .expect("parse"); + assert!(document_type.documents_can_be_deleted_by_moderators()); + assert_eq!(document_type.documents_ttl_seconds(), Some(1_209_600)); +} + +#[test] +fn should_refuse_the_keyword_before_protocol_version_14() { + // Meta-schema v2 (protocol versions 12 and 13) does not know the keyword, and the + // generation 2 parser ignores it on the stored path, where no such contract can exist. + let platform_version = PlatformVersion::get(13).expect("expected platform version"); + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available"); + let result = parse_with_config(note_schema(platform_value!({})), &config, 13, true); + assert!(result.is_err(), "the keyword must not pass meta-schema v2"); + let stored = parse_with_config(note_schema(platform_value!({})), &config, 13, false) + .expect("generation 2 ignores the doctype keyword it predates"); + assert_eq!(stored.documents_ttl_seconds(), None); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index de027dcd0ef..6fff6574cbd 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -328,7 +328,8 @@ fn try_from_schema_generation_3( let action_fees = DocumentActionFees::try_from_document_schema(&schema, name)?; let can_be_deleted_by_moderators = common::parse_can_be_deleted_by_moderators_keyword(&schema)?; let can_be_deleted_by_moderators_for = - common::parse_can_be_deleted_by_moderators_for_keyword(&schema)?; + common::parse_seconds_keyword(&schema, property_names::CAN_BE_DELETED_BY_MODERATORS_FOR)?; + let documents_ttl = common::parse_seconds_keyword(&schema, property_names::TTL)?; let immutable_fields = common::parse_property_name_list_keyword(&schema, name, property_names::IMMUTABLE)?; let immutable_fields_allow_setting = common::parse_property_name_list_keyword( @@ -498,6 +499,14 @@ fn try_from_schema_generation_3( can_be_deleted_by_moderators_for, name, )?; + // After `apply_index_only`: `ttl` is refused on an indexOnly type. + common::apply_documents_ttl( + &mut v2, + documents_ttl, + name, + full_validation, + platform_version, + )?; // The flags are read from the parsed result (not the raw schema) so // the check sees `canBeDeleted` resolved against the contract config @@ -995,6 +1004,8 @@ impl DocumentType { } } +#[cfg(test)] +mod documents_ttl_tests; #[cfg(test)] mod immutable_tests; #[cfg(test)] diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs index 6e716d86c25..92a2115c0ec 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs @@ -66,6 +66,14 @@ impl DocumentTypeRef<'_> { return Ok(result); } + // Validate that the type keeps its time to live (the keyword arrives with + // protocol version 14, the only version selecting this generation) + let result = self.validate_documents_ttl_unchanged(new_document_type); + + if !result.is_valid() { + return Ok(result); + } + // Validate that index definitions are unchanged let result = self.validate_index_definitions_unchanged(new_document_type); @@ -312,6 +320,43 @@ impl DocumentTypeRef<'_> { ) } + /// A document type's time to live is fixed when the type is created. Every document + /// already stored has its expiry indexed from the time to live it was written with, and + /// paid for that lifetime: adding a `ttl` would leave the stored documents without an + /// entry the cleanup could find, removing it would leave entries deleting documents + /// the type says live forever, and changing it would move expiries nobody paid for. + /// The schema compatibility differ has no rule for the key, so this check has to run + /// before it. A document type added by an update declares `ttl` freely. + fn validate_documents_ttl_unchanged( + &self, + new_document_type: DocumentTypeRef, + ) -> SimpleConsensusValidationResult { + let (old_ttl, new_ttl) = ( + self.documents_ttl_seconds(), + new_document_type.documents_ttl_seconds(), + ); + if old_ttl == new_ttl { + return SimpleConsensusValidationResult::new(); + } + let describe = |ttl: Option| { + ttl.map_or("no time to live".to_string(), |seconds| { + format!("{seconds} seconds") + }) + }; + SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change the time to live of its documents: changing from {} to {}", + describe(old_ttl), + describe(new_ttl) + ), + ) + .into(), + ) + } + /// Top-level requiredness may only change in one way: a brand-new /// property may be added as required when it is annotated with /// `requiredSince` equal to the contract version this update creates. @@ -751,6 +796,77 @@ mod tests { assert!(result.is_valid(), "{:?}", result.errors); } + #[test] + fn should_return_invalid_result_when_the_time_to_live_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + let make_document_type = |ttl: Option| { + let mut schema = platform_value!({ + "type": "object", + "properties": { + "text": { "type": "string", "maxLength": 50, "position": 0 }, + }, + "required": ["$createdAt"], + "additionalProperties": false, + }); + if let Some(seconds) = ttl { + schema + .insert("ttl".to_string(), seconds.into()) + .expect("expected to set the time to live"); + } + DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + "note", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("document type should parse") + }; + + // Stored documents carry the expiry they were written and paid with: adding, + // removing, lengthening and shortening the time to live are all refused, before the + // schema compatibility differ, which has no rule for the key. + for (old_ttl, new_ttl, from, to) in [ + (Some(86400), Some(172800), "86400 seconds", "172800 seconds"), + (Some(86400), Some(3600), "86400 seconds", "3600 seconds"), + (Some(86400), None, "86400 seconds", "no time to live"), + (None, Some(86400), "no time to live", "86400 seconds"), + ] { + let result = make_document_type(old_ttl) + .as_ref() + .validate_update(make_document_type(new_ttl).as_ref(), 2, platform_version) + .expect("validate_update should not error"); + let expected = format!( + "document type can not change the time to live of its documents: changing from {from} to {to}" + ); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError(StateError::DocumentTypeUpdateError(e))] + if e.additional_message() == expected + ); + } + + // Unchanged, it passes. + let result = make_document_type(Some(86400)) + .as_ref() + .validate_update( + make_document_type(Some(86400)).as_ref(), + 2, + platform_version, + ) + .expect("validate_update should not error"); + assert!(result.is_valid(), "{:?}", result.errors); + } + #[test] fn should_reject_removing_an_immutable_property() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index f2f17ed87ff..719ece5d81d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -231,6 +231,15 @@ pub(crate) mod property_names { /// Absent means no limit. Meta-schema v3+ (protocol version 14). See /// `apply_can_be_deleted_by_moderators_for` in `try_from_schema::common`. pub const CAN_BE_DELETED_BY_MODERATORS_FOR: &str = "canBeDeletedByModeratorsFor"; + /// Doctype-level time to live, in seconds: the platform deletes each document of the + /// type once `$createdAt` plus this many seconds has passed, whoever owns it and + /// whatever `canBeDeleted` says; from then on it can no longer be changed or restored by a + /// moderator. Its documents are stored without storage flags, pay + /// for the time they live instead of perpetual storage, and refund nothing. Requires + /// `$createdAt` in `required`; refused with `documentsKeepHistory`, `indexOnly` and a + /// contested index, and fixed when the document type is created. Meta-schema v3+ + /// (protocol version 14). See `apply_documents_ttl` in `try_from_schema::common`. + pub const TTL: &str = "ttl"; } #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/packages/rs-dpp/src/data_contract/document_type/property/list_element_reference.rs b/packages/rs-dpp/src/data_contract/document_type/property/list_element_reference.rs index 4d2f9c750c0..a152d0528ad 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/list_element_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/list_element_reference.rs @@ -158,9 +158,8 @@ impl ListElementReference { pub fn referenced_side_error(&self, referenced: DocumentTypeRef) -> Option { let referenced_name = referenced.name(); let list = &self.in_list; - if referenced.documents_can_be_deleted() - || referenced.documents_can_be_deleted_by_moderators() - { + // Deleted by anyone: its owner, the moderators, or the platform when a `ttl` passes. + if referenced.documents_can_disappear() { return Some(format!( "documents of \"{referenced_name}\" can be deleted: the list must be held by a \ document that never is" diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs index 43ee0124caf..afd600a178c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs @@ -248,6 +248,16 @@ impl DocumentTypeV2Getters for DocumentTypeV2 { self.documents_can_be_deleted_by_moderators_for } + fn documents_ttl_seconds(&self) -> Option { + self.documents_ttl_seconds + } + + fn documents_can_disappear(&self) -> bool { + self.documents_can_be_deleted + || self.documents_can_be_deleted_by_moderators + || self.documents_ttl_seconds.is_some() + } + fn immutable_fields(&self) -> &BTreeSet { &self.immutable_fields } diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 89a9fd69621..3e43678a3b2 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -173,6 +173,13 @@ pub struct DocumentTypeV2 { /// parser (`apply_property_constraints`) holds every property a rule reads /// to be an integer that is neither transient nor inside a transient object. pub(in crate::data_contract) property_constraints: BTreeMap, + /// How many seconds after its creation (`$createdAt`) the platform deletes each + /// document of the type (`ttl` keyword, protocol version 14), `None` when the + /// documents live until someone deletes them. The parser (`apply_documents_ttl`) + /// requires `$createdAt` and refuses it on a type that keeps history, is indexOnly or + /// has a contested index; the references that may point at such a type treat it as + /// deletable. + pub(in crate::data_contract) documents_ttl_seconds: Option, } impl DocumentTypeBasicMethods for DocumentTypeV2 {} @@ -264,6 +271,7 @@ impl From for DocumentTypeV2 { owner_reference: None, creator_reference: None, property_constraints: BTreeMap::new(), + documents_ttl_seconds: None, } } } @@ -315,6 +323,7 @@ impl From for DocumentTypeV2 { owner_reference: None, creator_reference: None, property_constraints: BTreeMap::new(), + documents_ttl_seconds: None, } } } diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 24534df82c2..dd9287a8dc4 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -371,6 +371,7 @@ impl ErrorWithCode for StateError { Self::ReferencedDocumentLookupInvalidError(_) => 40137, Self::ReferencedDocumentListInvalidError(_) => 40138, Self::DocumentActionFeeModeratorsShareMismatchError(_) => 40139, + Self::DocumentExpiredError(_) => 40140, // Identity Errors: 40200-40299 Self::IdentityAlreadyExistsError(_) => 40200, diff --git a/packages/rs-dpp/src/errors/consensus/state/document/document_expired_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/document_expired_error.rs new file mode 100644 index 00000000000..93db88ce32c --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/document_expired_error.rs @@ -0,0 +1,96 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use crate::identity::TimestampMillis; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// A document whose type declares a `ttl` has expired (`$createdAt` plus the time to live is +/// at or before block time), so it can no longer be replaced, transferred, bought, repriced +/// or restored by a moderator. It still exists until the platform's cleanup deletes it after +/// a block's state transitions; its owner may still delete it where the type's `canBeDeleted` +/// allows. Protocol version 14. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Document {} of type \"{}\" on contract {} expired at {}, its $createdAt plus the type's time to live, which block time {} is not before", + document_id, + document_type_name, + contract_id, + expired_at, + block_time +)] +#[platform_serialize(unversioned)] +pub struct DocumentExpiredError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + document_type_name: String, + document_id: Identifier, + expired_at: TimestampMillis, + block_time: TimestampMillis, +} + +impl DocumentExpiredError { + pub fn new( + contract_id: Identifier, + document_type_name: String, + document_id: Identifier, + expired_at: TimestampMillis, + block_time: TimestampMillis, + ) -> Self { + Self { + contract_id, + document_type_name, + document_id, + expired_at, + block_time, + } + } + + pub fn contract_id(&self) -> Identifier { + self.contract_id + } + + pub fn document_type_name(&self) -> &String { + &self.document_type_name + } + + pub fn document_id(&self) -> Identifier { + self.document_id + } + + /// When the document expired, in milliseconds: its `$createdAt` plus the type's `ttl` + pub fn expired_at(&self) -> TimestampMillis { + self.expired_at + } + + /// The block time the action was judged at, in milliseconds + pub fn block_time(&self) -> TimestampMillis { + self.block_time + } +} + +impl From for ConsensusError { + fn from(err: DocumentExpiredError) -> Self { + Self::StateError(StateError::DocumentExpiredError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs index a9bcefd1d3e..e3d57d5c93b 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs @@ -10,6 +10,7 @@ pub mod document_contest_index_mismatch_error; pub mod document_contest_not_joinable_error; pub mod document_contest_not_paid_for_error; pub mod document_contest_not_required_error; +pub mod document_expired_error; pub mod document_immutable_property_changed_error; pub mod document_incorrect_purchase_price_error; pub mod document_not_for_sale_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index 5b1d47ea169..26687e70fac 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -37,6 +37,7 @@ use crate::consensus::state::data_contract::data_contract_is_readonly_error::Dat use crate::consensus::state::data_trigger::DataTriggerError; use crate::consensus::state::document::document_action_fee_agreement_mismatch_error::DocumentActionFeeAgreementMismatchError; use crate::consensus::state::document::document_action_fee_moderators_share_mismatch_error::DocumentActionFeeModeratorsShareMismatchError; +use crate::consensus::state::document::document_expired_error::DocumentExpiredError; use crate::consensus::state::document::document_action_fee_agreement_not_set_error::DocumentActionFeeAgreementNotSetError; use crate::consensus::state::document::document_action_fee_multiplier_not_tolerated_error::DocumentActionFeeMultiplierNotToleratedError; use crate::consensus::state::document::document_already_present_error::DocumentAlreadyPresentError; @@ -621,6 +622,11 @@ pub enum StateError { // 14). #[error(transparent)] ModerationReasonNotListedError(ModerationReasonNotListedError), + + // A document whose type declares a `ttl` is changed or restored after it expired + // (protocol version 14). + #[error(transparent)] + DocumentExpiredError(DocumentExpiredError), } impl From for ConsensusError { @@ -1290,12 +1296,24 @@ mod tests { 149 ); // A seated moderation team's action names a reason its proposal lists (protocol - // version 14): the tail of the enum. + // version 14). assert_eq!( discriminant_of(StateError::ModerationReasonNotListedError( ModerationReasonNotListedError::new(group_id, identity_id, None) )), 150 ); + // A document changed or restored after its time to live passed (protocol version + // 14): the tail of the enum. + assert_eq!( + discriminant_of(StateError::DocumentExpiredError(DocumentExpiredError::new( + group_id, + "note".to_string(), + identity_id, + 1_000, + 2_000, + ))), + 151 + ); } } diff --git a/packages/rs-drive-abci/src/config.rs b/packages/rs-drive-abci/src/config.rs index c63f226d17e..1a0f90a9235 100644 --- a/packages/rs-drive-abci/src/config.rs +++ b/packages/rs-drive-abci/src/config.rs @@ -6,7 +6,7 @@ use dpp::dashcore::Network; use dpp::dashcore_rpc::json::QuorumType; use dpp::util::deserializer::ProtocolVersion; use dpp::version::INITIAL_PROTOCOL_VERSION; -use drive::config::DriveConfig; +use drive::config::{DriveConfig, DEFAULT_EPOCH_TIME_LENGTH_S}; use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize}; use std::path::PathBuf; @@ -623,7 +623,7 @@ impl ExecutionConfig { } fn default_epoch_time_length_s() -> u64 { - 788400 + DEFAULT_EPOCH_TIME_LENGTH_S } } diff --git a/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs b/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs index 968882f94d9..15d8242f2f3 100644 --- a/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs @@ -396,6 +396,16 @@ where #[cfg(debug_assertions)] phases.end_phase("cleanup_recent_block_storage_address_balances"); + // Delete documents whose time to live has passed: after the block's state transitions, + // so every transition of the block still saw them, and before fees are processed and + // the app hash is taken. Added in place in this shipped generation: `expire_documents` + // is `None` in the method tables of every protocol version before 14, where the call + // returns without reading or writing anything. + self.expire_documents(&block_info, transaction, platform_version)?; + + #[cfg(debug_assertions)] + phases.end_phase("expire_documents"); + // Record shielded pool anchor if the commitment tree changed this block. // This stores block_height → anchor_bytes so shielded transactions can // reference a recent anchor for spend authorization. diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_end/expire_documents/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/block_end/expire_documents/mod.rs new file mode 100644 index 00000000000..851144931e2 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/block_end/expire_documents/mod.rs @@ -0,0 +1,48 @@ +mod v0; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use drive::grovedb::Transaction; + +impl Platform +where + C: CoreRPCLike, +{ + /// Deletes documents whose type declares a `ttl` once it has passed, after the block's + /// state transitions: at most `max_document_expirations_per_block` of them, oldest first, + /// the rest in later blocks. A transition of this block still saw every document it + /// deletes. Nobody pays: each document prepaid its deletion when it was created. + /// + /// # Parameters + /// - `block_info`: the block being processed; its time decides what has expired. + /// - `transaction`: the block's transaction. + /// - `platform_version`: selects the method version; `None` before protocol version 14. + /// + /// # Returns + /// `Ok(())` once the expired documents this block deletes are gone. + pub(in crate::execution) fn expire_documents( + &self, + block_info: &BlockInfo, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive_abci + .methods + .block_end + .expire_documents + { + None => Ok(()), + Some(0) => self.expire_documents_v0(block_info, transaction, platform_version), + Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "expire_documents".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_end/expire_documents/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/block_end/expire_documents/v0/mod.rs new file mode 100644 index 00000000000..1026f70f642 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/block_end/expire_documents/v0/mod.rs @@ -0,0 +1,40 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use drive::grovedb::Transaction; + +impl Platform +where + C: CoreRPCLike, +{ + #[inline(always)] + pub(super) fn expire_documents_v0( + &self, + block_info: &BlockInfo, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let removed = self.drive.remove_expired_documents( + block_info, + platform_version + .system_limits + .max_document_expirations_per_block, + platform_version + .system_limits + .max_document_expiration_weight_per_block, + Some(transaction), + platform_version, + )?; + if removed.deleted_documents > 0 || removed.orphaned_entries > 0 { + tracing::debug!( + height = block_info.height, + deleted_documents = removed.deleted_documents, + orphaned_entries = removed.orphaned_entries, + "expired documents removed" + ); + } + Ok(()) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_end/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/block_end/mod.rs index b81128b4e69..7c1eaaecc6e 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/block_end/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/block_end/mod.rs @@ -12,5 +12,8 @@ pub(in crate::execution) mod should_checkpoint; /// Updates checkpoints (legacy - calls should_checkpoint then creates checkpoint) pub(in crate::execution) mod update_checkpoints; +/// Deletes documents whose time to live has passed, after the block's state transitions +pub(in crate::execution) mod expire_documents; + /// Creates a GroveDB checkpoint (called after transaction commit) pub(crate) mod create_grovedb_checkpoint; diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index cd15441d2e6..194e8e45680 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -835,6 +835,12 @@ impl Platform { self.drive .insert_contract_fee_pot_trees(Some(transaction), platform_version)?; + // The documents expirations tree under `Misc`, which indexes every document of a type + // declaring a `ttl` (a keyword protocol version 14 introduces) by when it expires. + // Fresh chains call the same helper last in `create_initial_state_structure` v4. + self.drive + .insert_documents_expirations_tree(Some(transaction), platform_version)?; + Ok(()) } } @@ -847,10 +853,12 @@ mod tests { use dpp::block::block_info::BlockInfo; use dpp::block::epoch::Epoch; use dpp::version::PlatformVersion; + use drive::drive::document::expiration::paths::DOCUMENTS_EXPIRATIONS_KEY; use drive::drive::shielded::paths::{ shielded_credit_pool_path, MAIN_SHIELDED_CREDIT_POOL_KEY_U8, SHIELDED_ANCHORS_IN_POOL_KEY, SHIELDED_NOTES_KEY, SHIELDED_NULLIFIERS_KEY, }; + use drive::util::grove_operations::DirectQueryType; /// Recursively compares the GroveDB subtree rooted at `root_path` between /// two platforms and returns a list of human-readable differences (empty ⇒ @@ -2130,6 +2138,59 @@ mod tests { ); } + #[test] + fn should_create_the_documents_expirations_tree_on_transition_to_version_14() { + let platform_version = PlatformVersion::latest(); + let born_at_14 = TestPlatformBuilder::new() + .with_initial_protocol_version(14) + .build_with_mock_rpc() + .set_genesis_state(); + let upgraded = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + + let transaction = upgraded.drive.grove.start_transaction(); + let tree_exists = |transaction: &Transaction| { + upgraded + .drive + .grove_has_raw( + (&misc_path()).into(), + DOCUMENTS_EXPIRATIONS_KEY, + DirectQueryType::StatefulDirectQuery, + Some(transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("expected to query the expirations tree") + }; + assert!( + !tree_exists(&transaction), + "protocol version 13 has no documents expirations tree" + ); + + upgraded + .transition_to_version_14(&BlockInfo::default(), &transaction, platform_version) + .expect("expected version 14 transition to succeed"); + assert!( + tree_exists(&transaction), + "the documents expirations tree must exist after the transition" + ); + + let diffs = collect_subtree_diffs( + &born_at_14, + &upgraded, + &transaction, + vec![vec![RootTree::Misc as u8]], + ); + assert!( + diffs.is_empty(), + "the Misc subtree differs between a chain born at version 14 and one upgraded to \ + it:\n{}", + diffs.join("\n"), + ); + } + /// The system contracts the upgrade to 14 registers are stored without storage flags, as a /// chain born at 14 stores them at genesis. The contract elements, every tree created with /// them and, for the moderation charters contract's contested index, its trees under the diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/mod.rs index f7fa449a67b..ab0812248f6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/mod.rs @@ -3,6 +3,8 @@ pub mod asset_lock; /// The seated moderation charter of an elected contract, read from the moderation charters /// contract pub(crate) mod seated_moderation_charter; +/// Refuses changes to, and restores of, a document whose time to live has passed +pub(crate) mod validate_document_not_expired; pub mod validate_identity_exists; pub mod validate_identity_public_key_contract_bounds; pub mod validate_identity_public_key_ids_dont_exist_in_state; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_document_not_expired.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_document_not_expired.rs new file mode 100644 index 00000000000..b2c78b0c9cc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_document_not_expired.rs @@ -0,0 +1,99 @@ +use crate::error::Error; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::document::document_expired_error::DocumentExpiredError; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::document::DocumentV0Getters; +use dpp::identifier::Identifier; +use dpp::prelude::TimestampMillis; +use dpp::validation::SimpleConsensusValidationResult; +use drive::drive::document::expiration::pricing::document_expires_at; +use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_purchase_transition_action::DocumentPurchaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::DocumentReplaceTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_transfer_transition_action::DocumentTransferTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_update_price_transition_action::DocumentUpdatePriceTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; + +/// Refuses an action on a document whose type declares a `ttl` once that has passed: +/// `$createdAt` plus the time to live at or before block time, the moment the cleanup after +/// the block's state transitions may delete it. The expiry is read from the document itself +/// and its type (drive's `document_expires_at`, the rule its expirations tree is keyed by), +/// never from the documents expirations tree, so it holds whether or not the cleanup has +/// reached the document yet. +/// +/// Reachable from protocol version 14 only: `documents_ttl_seconds` is `Some` only on a +/// document type parsed from the `ttl` keyword, which earlier versions do not read. +/// +/// `created_at` is the document's stored `$createdAt`, which every action on an existing +/// document carries unchanged. +pub(crate) fn validate_document_not_expired( + contract_id: Identifier, + document_type: DocumentTypeRef, + document_id: Identifier, + created_at: Option, + block_info: &BlockInfo, +) -> Result { + let Some(ttl_seconds) = document_type.documents_ttl_seconds() else { + return Ok(SimpleConsensusValidationResult::new()); + }; + // The parser requires `$createdAt` on such a type and every write keeps it; a document + // without one has no expiry to judge. + let Some(created_at) = created_at else { + return Ok(SimpleConsensusValidationResult::new()); + }; + let expired_at = document_expires_at(created_at, ttl_seconds)?; + if block_info.time_ms < expired_at { + return Ok(SimpleConsensusValidationResult::new()); + } + Ok(SimpleConsensusValidationResult::new_with_error( + DocumentExpiredError::new( + contract_id, + document_type.name().clone(), + document_id, + expired_at, + block_info.time_ms, + ) + .into(), + )) +} + +/// [`validate_document_not_expired`] for one document action of a batch: replacing, +/// transferring, buying and repricing an expired document are refused. A create makes a new +/// document, and its owner's deletion of an expired one only removes it sooner. +pub(crate) fn validate_document_action_not_expired( + document_action: &DocumentTransitionAction, + block_info: &BlockInfo, +) -> Result { + let (base, created_at) = match document_action { + DocumentTransitionAction::ReplaceAction(action) => (action.base(), action.created_at()), + DocumentTransitionAction::TransferAction(action) => { + (action.base(), action.document().created_at()) + } + DocumentTransitionAction::PurchaseAction(action) => { + (action.base(), action.document().created_at()) + } + DocumentTransitionAction::UpdatePriceAction(action) => { + (action.base(), action.document().created_at()) + } + DocumentTransitionAction::CreateAction(_) + | DocumentTransitionAction::DeleteAction(_) + | DocumentTransitionAction::IndexOnlyDeleteAction(_) => { + return Ok(SimpleConsensusValidationResult::new()); + } + }; + let contract = &base.data_contract_fetch_info_ref().contract; + // An unknown document type is refused by the action's own validation. + let Some(document_type) = contract.document_type_optional_for_name(base.document_type_name()) + else { + return Ok(SimpleConsensusValidationResult::new()); + }; + validate_document_not_expired( + contract.id(), + document_type, + base.id(), + created_at, + block_info, + ) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index eeffbf3f392..13a63cf5378 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -979,10 +979,9 @@ fn validate_reference_target_v0( // admits only document types whose documents CAN be deleted: // the referenced document must exist now, and may be deleted // later. Deletable means by anyone, the contract's moderators - // included (`canBeDeletedByModerators`), as at contract - // registration - let target_is_deletable = referenced_document_type.documents_can_be_deleted() - || referenced_document_type.documents_can_be_deleted_by_moderators(); + // included (`canBeDeletedByModerators`), and the platform + // for a type declaring a `ttl`, as at contract registration + let target_is_deletable = referenced_document_type.documents_can_disappear(); if permanent && target_is_deletable { return Ok(SimpleConsensusValidationResult::new_with_error( ReferencedDocumentTypeDeletableError::new( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs index f3f00431ddc..3d8a431f65d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs @@ -39,6 +39,7 @@ use drive::state_transition_action::batch::batched_transition::document_transiti use drive::state_transition_action::StateTransitionAction; use drive::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; use crate::error::execution::ExecutionError; +use crate::execution::validation::state_transition::common::validate_document_not_expired::validate_document_action_not_expired; use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::state_transition_execution_context::{StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0}; use crate::execution::validation::state_transition::batch::action_validation::document::document_purchase_transition_action::DocumentPurchaseTransitionActionValidation; @@ -260,6 +261,30 @@ impl DocumentsBatchStateTransitionStructureValidationV1 for BatchTransition { } } + // A document whose type declares a `ttl` is no longer replaced, transferred, bought or + // repriced once that has passed (`DocumentExpiredError`), judged from the document the + // action carries and the block time. Here rather than in the state validation, so + // check_tx refuses the change too. + for transition in action.transitions() { + let BatchedTransitionAction::DocumentAction(document_action) = transition else { + continue; + }; + let result = validate_document_action_not_expired(document_action, block_info)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action( + document_action.base(), + self.owner_id(), + self.user_fee_increase(), + ), + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + // Next we need to validate the structure of all actions (this means with the data contract) for transition in action.transitions() { match transition { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/document_ttl.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/document_ttl.rs new file mode 100644 index 00000000000..6a0bf812295 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/document_ttl.rs @@ -0,0 +1,733 @@ +//! End-to-end coverage of the document type `ttl` keyword (protocol version 14): a document +//! created through a batch transition is stored without storage flags, indexed in the +//! documents expirations tree, priced for the time it lives with its deletion prepaid, and +//! deleted by the platform after the block its time to live passes in, refunding nobody. + +use super::*; + +mod document_ttl_tests { + use super::*; + use crate::platform_types::block_proposal::v0::BlockProposal; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::fast_forward_to_block::fast_forward_to_block; + use crate::test::helpers::setup::TempPlatform; + use dpp::data_contract::document_type::DocumentTypeRef; + use dpp::data_contract::schema::DataContractSchemaMethodsV0; + use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; + use dpp::document::Document; + use dpp::fee::fee_result::FeeResult; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::platform_value::platform_value; + use dpp::prelude::{DataContract, IdentityNonce}; + use dpp::state_transition::StateTransition; + use dpp::tests::fixtures::get_data_contract_fixture; + use drive::drive::document::expiration::paths::{ + documents_expirations_path_vec, encode_expiration_time, + }; + use drive::drive::document::expiration::pricing::document_expiration_cleanup_fee; + use drive::drive::RootTree; + use drive::grovedb::Element; + use drive::util::grove_operations::DirectQueryType; + use drive::util::storage_flags::StorageFlags; + use simple_signer::signer::SimpleSigner; + use tenderdash_abci::proto::version::Consensus; + + const START_MS: u64 = 1_700_000_000_000; + const HOUR_S: u64 = 3_600; + + /// A mutable, transferable and tradeable `note` type expiring an hour after creation, and + /// a `memo` type identical but for the `ttl`. + fn note_schema(ttl: Option) -> Value { + let mut schema = platform_value!({ + "type": "object", + "documentsMutable": true, + "transferable": 1, + "tradeMode": 1, + "properties": { + "text": { "type": "string", "maxLength": 63, "position": 0 }, + }, + "indices": [ + { "name": "byText", "properties": [{ "text": "asc" }] }, + ], + "required": ["$createdAt", "text"], + "additionalProperties": false, + }); + if let Some(ttl) = ttl { + schema + .insert("ttl".to_string(), Value::U64(ttl)) + .expect("expected to set the ttl"); + } + schema + } + + struct NotesFixture { + platform: TempPlatform, + signer: SimpleSigner, + key: IdentityPublicKey, + identity: Identity, + contract: DataContract, + next_nonce: IdentityNonce, + /// A second identity, to buy and receive notes + buyer: Identity, + buyer_signer: SimpleSigner, + buyer_key: IdentityPublicKey, + buyer_next_nonce: IdentityNonce, + } + + impl NotesFixture { + fn new() -> Self { + Self::on( + TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(), + ) + } + + /// On a platform holding the genesis state (system contracts included), which a + /// proposed block needs. + fn with_genesis_state() -> Self { + Self::on( + TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(), + ) + } + + fn on(mut platform: TempPlatform) -> Self { + let platform_version = PlatformVersion::latest(); + + let (identity, signer, key) = setup_identity(&mut platform, 971, dash_to_credits!(0.5)); + let (buyer, buyer_signer, buyer_key) = + setup_identity(&mut platform, 972, dash_to_credits!(0.5)); + + let mut contract = get_data_contract_fixture( + Some(identity.id()), + 0, + platform_version.protocol_version, + ) + .data_contract_owned(); + for (name, ttl) in [("note", Some(HOUR_S)), ("memo", None)] { + contract + .set_document_schema( + name, + note_schema(ttl), + true, + &mut Vec::new(), + platform_version, + ) + .expect("expected to add the document type"); + } + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + + Self { + platform, + signer, + key, + identity, + contract, + next_nonce: 1, + buyer, + buyer_signer, + buyer_key, + buyer_next_nonce: 1, + } + } + + fn note_type(&self) -> DocumentTypeRef<'_> { + self.contract + .document_type_for_name("note") + .expect("expected the note type") + } + + /// The note as stored now, the base of the next change to it. + fn stored_note(&self, id: Identifier) -> Document { + let Some(Element::Item(bytes, _)) = self.stored_by_id("note", id) else { + panic!("expected the note to be stored as an item"); + }; + Document::from_bytes(&bytes, self.note_type(), PlatformVersion::latest()) + .expect("expected the stored note to decode") + } + + async fn replace( + &mut self, + id: Identifier, + text: &str, + time_ms: u64, + ) -> StateTransitionExecutionResult { + let mut document = self.stored_note(id); + // A price is not document data: a replace carries only the schema's properties. + document.properties_mut().remove("$price"); + document.set("text", Value::Text(text.to_string())); + document.bump_revision(); + let transition = BatchTransition::new_document_replacement_transition_from_document( + document, + self.note_type(), + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + PlatformVersion::latest(), + None, + ) + .await + .expect("expected the replace transition"); + self.next_nonce += 1; + self.process(&transition, time_ms) + } + + async fn transfer( + &mut self, + id: Identifier, + time_ms: u64, + ) -> StateTransitionExecutionResult { + let mut document = self.stored_note(id); + document.bump_revision(); + let transition = BatchTransition::new_document_transfer_transition_from_document( + document, + self.note_type(), + self.buyer.id(), + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + PlatformVersion::latest(), + None, + ) + .await + .expect("expected the transfer transition"); + self.next_nonce += 1; + self.process(&transition, time_ms) + } + + async fn update_price( + &mut self, + id: Identifier, + price: u64, + time_ms: u64, + ) -> StateTransitionExecutionResult { + let mut document = self.stored_note(id); + document.bump_revision(); + let transition = BatchTransition::new_document_update_price_transition_from_document( + document, + self.note_type(), + price, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + PlatformVersion::latest(), + None, + ) + .await + .expect("expected the update price transition"); + self.next_nonce += 1; + self.process(&transition, time_ms) + } + + async fn purchase( + &mut self, + id: Identifier, + price: u64, + time_ms: u64, + ) -> StateTransitionExecutionResult { + let mut document = self.stored_note(id); + document.bump_revision(); + let transition = BatchTransition::new_document_purchase_transition_from_document( + document, + self.note_type(), + self.buyer.id(), + price, + &self.buyer_key, + self.buyer_next_nonce, + 0, + None, + &self.buyer_signer, + PlatformVersion::latest(), + None, + ) + .await + .expect("expected the purchase transition"); + self.buyer_next_nonce += 1; + self.process(&transition, time_ms) + } + + fn block(&self, time_ms: u64) -> BlockInfo { + BlockInfo { + time_ms, + ..Default::default() + } + } + + /// Creates a `document_type_name` document with `text` at `time_ms` and returns it + /// with the execution result. + async fn create( + &mut self, + document_type_name: &str, + text: &str, + time_ms: u64, + ) -> (Document, StateTransitionExecutionResult) { + let platform_version = PlatformVersion::latest(); + let document_type = self + .contract + .document_type_for_name(document_type_name) + .expect("expected the document type"); + let mut rng = StdRng::seed_from_u64(7_000 + self.next_nonce); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + &mut rng, + self.identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document + .set_id_for_creation(document_type, &entropy.0, self.next_nonce, platform_version) + .expect("expected to set the document id"); + document.set("text", Value::Text(text.to_string())); + + let transition = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + document_type, + entropy.0, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + self.next_nonce += 1; + let result = self.process(&transition, time_ms); + (document, result) + } + + async fn delete( + &mut self, + document: &Document, + time_ms: u64, + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let document_type = self + .contract + .document_type_for_name("note") + .expect("expected the note type"); + let transition = BatchTransition::new_document_deletion_transition_from_document( + document.clone(), + document_type, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the delete transition"); + self.next_nonce += 1; + self.process(&transition, time_ms) + } + + fn process( + &self, + transition: &StateTransition, + time_ms: u64, + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let platform_state = self.platform.state.load(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the transition to serialize"); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &self.block(time_ms), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) + } + + /// Runs the block-end cleanup of a block at `time_ms`. + fn expire(&self, time_ms: u64) { + let transaction = self.platform.drive.grove.start_transaction(); + self.platform + .platform + .expire_documents( + &self.block(time_ms), + &transaction, + PlatformVersion::latest(), + ) + .expect("expected the cleanup to run"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + } + + fn stored(&self, document_type_name: &str, document: &Document) -> Option { + self.stored_by_id(document_type_name, document.id()) + } + + fn stored_by_id(&self, document_type_name: &str, id: Identifier) -> Option { + // [DataContractDocuments, contract id, 1 (documents), document type, 0 (primary key)] + let path = vec![ + vec![RootTree::DataContractDocuments as u8], + self.contract.id().to_vec(), + vec![1], + document_type_name.as_bytes().to_vec(), + vec![0], + ]; + self.platform + .drive + .grove_get_raw_optional( + path.as_slice().into(), + id.as_slice(), + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &PlatformVersion::latest().drive, + ) + .expect("expected to read the document") + } + + fn balance(&self) -> u64 { + self.platform + .drive + .fetch_identity_balance( + self.identity.id().to_buffer(), + None, + PlatformVersion::latest(), + ) + .expect("expected to read the balance") + .expect("expected a balance") + } + + fn expiring_at(&self, time_ms: u64) -> Vec { + self.platform + .drive + .fetch_expired_documents(time_ms, 128, None, &mut vec![], PlatformVersion::latest()) + .expect("expected to read the expirations") + .into_iter() + .map(|expired| expired.document_id) + .collect() + } + } + + fn fee_of(result: &StateTransitionExecutionResult) -> FeeResult { + match result { + StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. } => { + fee_result.clone() + } + other => panic!("expected a successful execution, got {other:?}"), + } + } + + #[tokio::test] + async fn should_store_an_expiring_document_without_flags_and_index_its_expiry() { + let mut fixture = NotesFixture::new(); + let (note, result) = fixture.create("note", "hello", START_MS).await; + fee_of(&result); + + let Some(Element::Item(_, flags)) = fixture.stored("note", ¬e) else { + panic!("expected the note to be stored as an item"); + }; + assert_eq!( + flags, None, + "a document with a time to live carries no storage flags" + ); + + let expires_at = START_MS + HOUR_S * 1000; + assert!(fixture.expiring_at(expires_at - 1).is_empty()); + assert_eq!(fixture.expiring_at(expires_at), vec![note.id()]); + } + + #[tokio::test] + async fn should_price_an_hour_long_document_below_a_permanent_one_and_prepay_its_deletion() { + let mut fixture = NotesFixture::new(); + // The identity's first transition on the contract stores its contract nonce, at the + // perpetual storage price; the two compared below only replace it. + fixture.create("memo", "warm up", START_MS).await; + let (note, note_result) = fixture.create("note", "hello", START_MS).await; + let (_, memo_result) = fixture.create("memo", "hello", START_MS).await; + let note_fee = fee_of(¬e_result); + let memo_fee = fee_of(&memo_result); + + assert_eq!( + note_fee.storage_fee, 0, + "an hour of storage pays into the processing fees, not the storage pool" + ); + assert!(memo_fee.storage_fee > 0); + let note_bytes = note + .serialize( + fixture.note_type(), + &fixture.contract, + PlatformVersion::latest(), + ) + .expect("expected the note to serialize") + .len() as u64; + let cleanup_fee = document_expiration_cleanup_fee( + fixture.note_type(), + note_bytes, + &PlatformVersion::latest().fee_version, + ) + .expect("expected the cleanup fee"); + // The note's own processing, its bytes' hour of storage included, stays near the + // memo's; on top of it the note prepays its deletion. Drive's expiration tests pin + // the prepaid amount exactly. + let prepaid = note_fee + .processing_fee + .checked_sub(memo_fee.processing_fee) + .expect("the note pays more processing than the memo"); + assert!( + prepaid.abs_diff(cleanup_fee) <= 100_000, + "the note prepays its deletion as processing: {prepaid} beyond the memo, the \ + deletion costs {cleanup_fee}" + ); + assert!( + note_fee.total_base_fee() < memo_fee.total_base_fee(), + "an hour of storage costs less than perpetual storage" + ); + } + + #[tokio::test] + async fn should_delete_an_expired_document_at_the_end_of_a_proposed_block() { + // Through the production entry point: the cleanup runs inside `run_block_proposal`, + // in the block's transaction, after its state transitions. + let mut fixture = NotesFixture::with_genesis_state(); + let (note, result) = fixture.create("note", "hello", START_MS).await; + assert!(matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + )); + let expires_at = START_MS + HOUR_S * 1000; + + // The last committed block came a millisecond before the note's expiry. + fixture.platform.drive.set_genesis_time(START_MS); + fast_forward_to_block(&fixture.platform, expires_at - 1, 10, 0, 0, false); + let platform_state = fixture.platform.state.load(); + let transaction = fixture.platform.drive.grove.start_transaction(); + let raw_state_transitions = vec![]; + let protocol_version = PlatformVersion::latest().protocol_version as u64; + let proposal = BlockProposal { + consensus_versions: Consensus { + block: 1, + app: protocol_version, + }, + block_hash: None, + height: 11, + round: 0, + block_time_ms: expires_at, + core_chain_locked_height: 0, + core_chain_lock_update: None, + proposed_app_version: protocol_version, + proposer_pro_tx_hash: [0u8; 32], + validator_set_quorum_hash: [0u8; 32], + raw_state_transitions: &raw_state_transitions, + }; + // What the proposal does after the block-end cleanups (its validator set update + // against this test's empty quorum hash) is not under test. + let _ = fixture.platform.run_block_proposal( + proposal, + false, + &platform_state, + &transaction, + None, + ); + + let path = vec![ + vec![RootTree::DataContractDocuments as u8], + fixture.contract.id().to_vec(), + vec![1], + b"note".to_vec(), + vec![0], + ]; + let stored = fixture + .platform + .drive + .grove_get_raw_optional( + path.as_slice().into(), + note.id().as_slice(), + DirectQueryType::StatefulDirectQuery, + Some(&transaction), + &mut vec![], + &PlatformVersion::latest().drive, + ) + .expect("expected to read the note"); + assert!( + stored.is_none(), + "the block's cleanup deletes the expired note" + ); + } + + #[tokio::test] + async fn should_delete_expired_documents_after_the_block_and_refund_nobody() { + let mut fixture = NotesFixture::new(); + let (first, _) = fixture.create("note", "first", START_MS).await; + let (second, _) = fixture.create("note", "second", START_MS).await; + let (later, _) = fixture.create("note", "later", START_MS + 60_000).await; + let (memo, _) = fixture.create("memo", "stays", START_MS).await; + let expires_at = START_MS + HOUR_S * 1000; + + // A block a millisecond early deletes nothing. + fixture.expire(expires_at - 1); + assert!(fixture.stored("note", &first).is_some()); + + let balance_before = fixture.balance(); + fixture.expire(expires_at); + assert!(fixture.stored("note", &first).is_none()); + assert!(fixture.stored("note", &second).is_none()); + assert!( + fixture.stored("note", &later).is_some(), + "it expires a minute later" + ); + assert!( + fixture.stored("memo", &memo).is_some(), + "a memo never expires" + ); + assert_eq!( + fixture.balance(), + balance_before, + "the cleanup refunds nothing and charges nobody" + ); + assert_eq!(fixture.expiring_at(u64::MAX), vec![later.id()]); + + fixture.expire(expires_at + 60_000); + assert!(fixture.stored("note", &later).is_none()); + assert!(fixture.expiring_at(u64::MAX).is_empty()); + } + + fn assert_expired(result: &StateTransitionExecutionResult) { + match result { + StateTransitionExecutionResult::PaidConsensusError { error, .. } => assert!( + matches!( + error, + ConsensusError::StateError(StateError::DocumentExpiredError(_)) + ), + "expected the document to be refused as expired, got {error:?}" + ), + other => panic!("expected a paid refusal, got {other:?}"), + } + } + + #[tokio::test] + async fn should_refuse_changing_an_expired_document_its_owner_may_still_delete() { + let mut fixture = NotesFixture::new(); + let (note, _) = fixture.create("note", "hello", START_MS).await; + let note_id = note.id(); + let expires_at = START_MS + HOUR_S * 1000; + + // While it has time to live, it changes as any note does. + fee_of(&fixture.replace(note_id, "edited", START_MS + 60_000).await); + fee_of( + &fixture + .update_price(note_id, 1_000_000, START_MS + 120_000) + .await, + ); + + // From its expiry on, before the cleanup reaches it, nothing changes it any more. + assert_expired(&fixture.replace(note_id, "too late", expires_at).await); + assert_expired(&fixture.transfer(note_id, expires_at).await); + assert_expired(&fixture.update_price(note_id, 2_000_000, expires_at).await); + assert_expired(&fixture.purchase(note_id, 1_000_000, expires_at + 1).await); + let stored = fixture.stored_note(note_id); + assert_eq!( + stored.owner_id(), + fixture.identity.id(), + "still the owner's" + ); + assert_eq!( + stored.properties().get("text"), + Some(&Value::Text("edited".to_string())) + ); + + // Its owner may still delete it: that only removes it sooner. + let result = fixture.delete(&stored, expires_at + 2).await; + fee_of(&result); + assert!(fixture.stored("note", &stored).is_none()); + assert!(fixture.expiring_at(u64::MAX).is_empty()); + } + + #[tokio::test] + async fn should_sell_an_expiring_document_before_it_expires() { + let mut fixture = NotesFixture::new(); + let (note, _) = fixture.create("note", "hello", START_MS).await; + let note_id = note.id(); + fee_of( + &fixture + .update_price(note_id, 1_000_000, START_MS + 1_000) + .await, + ); + fee_of(&fixture.purchase(note_id, 1_000_000, START_MS + 2_000).await); + let stored = fixture.stored_note(note_id); + assert_eq!(stored.owner_id(), fixture.buyer.id()); + // The buyer bought what was left of its life: it expires when it always did. + assert_eq!(fixture.expiring_at(START_MS + HOUR_S * 1000), vec![note_id]); + } + + #[tokio::test] + async fn should_let_the_owner_delete_an_expiring_document_without_a_refund() { + let mut fixture = NotesFixture::new(); + let (note, _) = fixture.create("note", "hello", START_MS).await; + + let result = fixture.delete(¬e, START_MS + 60_000).await; + let fee = fee_of(&result); + assert!( + fee.fee_refunds.0.is_empty(), + "a document with a time to live refunds nothing to its owner" + ); + assert!(fixture.stored("note", ¬e).is_none()); + assert!( + fixture.expiring_at(u64::MAX).is_empty(), + "the deletion removes the document's expirations tree entry" + ); + // It was the last entry of its expiry time, so the tree of that time went with it. + let expiry_tree = fixture + .platform + .drive + .grove_get_raw_optional( + documents_expirations_path_vec().as_slice().into(), + &encode_expiration_time(START_MS + HOUR_S * 1000), + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &PlatformVersion::latest().drive, + ) + .expect("expected to read the expirations tree"); + assert!(expiry_tree.is_none()); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index 0eed77de6f7..28fb6c8511e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -4,6 +4,7 @@ mod creation; mod deletable_document_reference; mod deletion; mod distinct_from; +mod document_ttl; mod dpns; mod encrypted_for; mod gas_sponsorship; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs index 1bd1675c507..24f4a46a3ab 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs @@ -3960,7 +3960,10 @@ mod token_burn_tests { PlatformVersion::latest().protocol_version, // PROTOCOL_VERSION_14: +400 — genesis system documents now carry // the contract-version stamp, shifting byte-billed subtree reads - 4_369_020, // +740 per document write from protocol version 14: the contract's version item is one more node to rehash + // +740 per document write from protocol version 14: the contract's version item is + // one more node to rehash; -12_820: the documents expirations tree joins `Misc` + // beside the token supplies tree the burn rewrites, reshaping the `Misc` Merk + 4_356_200, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs index c3cafc07ea6..816a5a5f6d4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs @@ -29,8 +29,10 @@ mod token_selling_tests { // byte-billed subtree reads // +740 per document write from protocol version 14: the contract's version item is // one more node to rehash. +8_420 from direct purchase state validation 1, which - // reads the total supply even though the token sets no max supply. - 699_868_037_020, + // reads the total supply even though the token sets no max supply. 12_820 credits + // less in fees: the documents expirations tree joins `Misc` beside the token + // supplies tree the purchase rewrites, reshaping the `Misc` Merk + 699_868_049_840, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs index bff338957c2..2716ed084bc 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs @@ -7,6 +7,7 @@ use crate::execution::types::state_transition_execution_context::{ use crate::execution::validation::state_transition::common::seated_moderation_charter::{ fetch_seated_moderation_charter, SeatedModerationCharter, }; +use crate::execution::validation::state_transition::common::validate_document_not_expired::validate_document_not_expired; use crate::execution::validation::state_transition::common::validate_identity_exists::validate_identity_exists; use crate::execution::validation::state_transition::state_transitions::batch::fetch_document_with_id; use crate::platform_types::platform::PlatformRef; @@ -733,6 +734,24 @@ fn transform_document_restore_v0( ); } + // A document whose type declares a `ttl` and has expired stays deleted: the cleanup after + // this block's state transitions would delete it again, and the record would say restored + // for a document that no longer exists. Judged from its `$createdAt`, which the hash above + // pins to the document as it was. + if let Some(error) = validate_document_not_expired( + contract_id, + document_type, + document_id, + document.created_at(), + block_info, + )? + .errors + .into_iter() + .next() + { + return refuse(error); + } + // What the hash does not pin: another document may have taken a value of one of the // type's unique indexes while the document was gone, and would clash with it. if document_type.indexes().values().any(|index| index.unique) { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs index c1652d73abc..8565073736f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs @@ -101,6 +101,7 @@ const CONTRACT_DOCUMENT_REMOVAL_NOT_FOUND: u32 = 41119; const DOCUMENT_RESTORE_WINDOW_ELAPSED: u32 = 41120; const DOCUMENT_RESTORE_HASH_MISMATCH: u32 = 41121; const CONTRACT_DOCUMENT_ALREADY_RESTORED: u32 = 41122; +const DOCUMENT_EXPIRED: u32 = 40140; const DECODING_DOCUMENT: u32 = 10223; const DUPLICATE_UNIQUE_INDEX: u32 = 40105; const INVALID_DOCUMENT_TYPE: u32 = 10406; @@ -3671,6 +3672,94 @@ async fn should_refuse_a_document_restore_that_breaks_a_rule() { ); } +#[tokio::test] +async fn should_refuse_to_restore_a_post_whose_time_to_live_has_passed() { + // Posts that live an hour: a moderator's deletion can be undone within the week, but not + // once the post's hour is up, since the cleanup after the block would delete it again. + let setup = Setup::new_at_with( + Some(moderators_without_lists()), + PlatformVersion::latest(), + |contract| { + add_document_type( + contract, + POST, + post_schema_with(platform_value!({ + "ttl": 3600, + "required": ["text", "$createdAt"], + })), + ) + }, + ) + .await; + + let transaction = setup.platform.drive.grove.start_transaction(); + let (post, create) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create, &transaction)); + setup.commit(transaction); + let stored = setup + .stored_document(POST, post.id(), None) + .expect("expected the post to be stored"); + let bytes = setup.document_bytes(POST, &stored); + let delete = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + assert_success(&setup.process(&delete, &transaction)); + setup.commit(transaction); + + // The deletion took the post's expirations tree entry with it. + let expires_at = BLOCK_TIME_MS + 3_600_000; + let expiring = |transaction: &Transaction| { + setup + .platform + .drive + .fetch_expired_documents( + expires_at, + 128, + Some(transaction), + &mut vec![], + PlatformVersion::latest(), + ) + .expect("expected to read the expirations") + .into_iter() + .map(|expired| expired.document_id) + .collect::>() + }; + + // At the post's expiry, well inside the restore window: refused, paid, nothing restored. + let transaction = setup.platform.drive.grove.start_transaction(); + assert!(expiring(&transaction).is_empty()); + let too_late = setup + .moderate(&setup.owner, restore_action(POST, bytes.clone())) + .await; + assert_paid_with_code( + &setup.process_at(&too_late, expires_at, &transaction), + DOCUMENT_EXPIRED, + ); + assert_eq!( + setup.stored_document(POST, post.id(), Some(&transaction)), + None + ); + assert_eq!( + setup + .post_removal(post.id(), Some(&transaction)) + .map(|removal| removal.restoration), + Some(None) + ); + + // A millisecond before, the post still had time to live: it comes back. + let in_time = setup + .moderate(&setup.owner, restore_action(POST, bytes)) + .await; + assert_success(&setup.process_at(&in_time, expires_at - 1, &transaction)); + assert_eq!( + setup.stored_document(POST, post.id(), Some(&transaction)), + Some(stored) + ); + // Back with its entry, keyed by its original expiry: the cleanup still deletes it then. + assert_eq!(expiring(&transaction), vec![post.id()]); +} + #[tokio::test] async fn should_refuse_to_restore_a_post_whose_unique_value_another_post_took_meanwhile() { let setup = Setup::new_at_with( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs index 9b0516f2190..f4394d88cc3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs @@ -449,10 +449,10 @@ fn validate_reference_target_declaration_v0( // allows it, so the declaration always states which guarantee // the reference carries. Deletable means by anyone: a document type moderators // can delete from is deletable whatever its `canBeDeleted` says about a document's - // own owner, since a reference to it could dangle. Neither flag can change on an - // update, so the answer holds for good. - let target_is_deletable = referenced_document_type.documents_can_be_deleted() - || referenced_document_type.documents_can_be_deleted_by_moderators(); + // own owner, since a reference to it could dangle, and so is one whose documents the + // platform deletes when their `ttl` passes. None of the three can change on an update, + // so the answer holds for good. + let target_is_deletable = referenced_document_type.documents_can_disappear(); if permanent && target_is_deletable { return Ok(SimpleConsensusValidationResult::new_with_error( ReferencedDocumentTypeDeletableError::new( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs index 2f6e3cb689d..50a5dee44ed 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs @@ -5684,6 +5684,41 @@ mod tests { ); } + #[tokio::test] + async fn should_reject_a_permanent_reference_to_a_type_whose_documents_expire() { + // The target forbids its owners to delete, but declares a `ttl`: the platform + // deletes its documents, so a permanentDocument reference could dangle. + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-permanent-doc-registration-expiring.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentTypeDeletableError(_) + ), + .. + } + ); + } + + #[tokio::test] + async fn should_register_a_deletable_reference_to_a_type_whose_documents_expire() { + // `canBeDeleted: false` alone would refuse a deletableDocument reference; the + // `ttl` makes the target deletable, so it is accepted. + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-deletable-doc-registration-expiring.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + #[tokio::test] async fn should_reject_contract_referencing_unknown_own_document_type() { let result = run_contract_create( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs index a1f1976b355..2332d7d399d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs @@ -348,17 +348,19 @@ mod tests { async fn test_identity_create_validation_latest_protocol_version() { run_test_identity_create_validation_at_protocol_version( PlatformVersion::latest().protocol_version, - 1919540, - 99913867460, + // PROTOCOL_VERSION_14: 4,960 credits less, see the protocol version 13 twin + 1914580, + 99913872420, ) .await; } - /// PROTOCOL_VERSION_13: the same fee as at the latest version. v14 adds the - /// `ContractGroups` root tree at key 124, under the `Versions` node that no fee-bearing - /// transition rewrites, so the asset lock outpoint write costs the same on both sides of - /// the boundary; this pin is what fails if a root tree ever lands under the asset lock - /// path. Pinned so v13 chain history stays bit-for-bit reproducible. + /// PROTOCOL_VERSION_13: 4,960 credits more processing than at the latest version. v14 + /// adds the documents expirations tree under `Misc` (key `E`), beside the total system + /// credits item this transition rewrites, and the extra key reshapes the `Misc` Merk + /// the write rehashes. v14's `ContractGroups` root tree (key 124) sits under the + /// `Versions` node no fee-bearing transition rewrites and changes nothing here. Pinned so + /// v13 chain history stays bit-for-bit reproducible. #[tokio::test] async fn test_identity_create_validation_protocol_version_13() { run_test_identity_create_validation_at_protocol_version(13, 1919540, 99913867460).await; @@ -1090,17 +1092,19 @@ mod tests { async fn test_identity_create_asset_lock_reuse_after_issue_latest_protocol_version() { run_test_identity_create_asset_lock_reuse_after_issue_at_protocol_version( PlatformVersion::latest().protocol_version, - 2195200, - 99909262100, + // PROTOCOL_VERSION_14: 4,960 credits less, see the protocol version 13 twin + 2190240, + 99909267060, ) .await; } - /// PROTOCOL_VERSION_13: the same fee as at the latest version. v14 adds the - /// `ContractGroups` root tree at key 124, under the `Versions` node that no fee-bearing - /// transition rewrites, so the asset lock outpoint write costs the same on both sides of - /// the boundary; this pin is what fails if a root tree ever lands under the asset lock - /// path. Pinned so v13 chain history stays bit-for-bit reproducible. + /// PROTOCOL_VERSION_13: 4,960 credits more processing than at the latest version. v14 + /// adds the documents expirations tree under `Misc` (key `E`), beside the total system + /// credits item this transition rewrites, and the extra key reshapes the `Misc` Merk + /// the write rehashes. v14's `ContractGroups` root tree (key 124) sits under the + /// `Versions` node no fee-bearing transition rewrites and changes nothing here. Pinned so + /// v13 chain history stays bit-for-bit reproducible. #[tokio::test] async fn test_identity_create_asset_lock_reuse_after_issue_protocol_version_13() { run_test_identity_create_asset_lock_reuse_after_issue_at_protocol_version( @@ -2065,17 +2069,19 @@ mod tests { async fn test_identity_create_asset_lock_replay_attack_latest_protocol_version() { run_test_identity_create_asset_lock_replay_attack_at_protocol_version( PlatformVersion::latest().protocol_version, - 2195200, - 99909262100, + // PROTOCOL_VERSION_14: 4,960 credits less, see the protocol version 13 twin + 2190240, + 99909267060, ) .await; } - /// PROTOCOL_VERSION_13: the same fee as at the latest version. v14 adds the - /// `ContractGroups` root tree at key 124, under the `Versions` node that no fee-bearing - /// transition rewrites, so the asset lock outpoint write costs the same on both sides of - /// the boundary; this pin is what fails if a root tree ever lands under the asset lock - /// path. Pinned so v13 chain history stays bit-for-bit reproducible. + /// PROTOCOL_VERSION_13: 4,960 credits more processing than at the latest version. v14 + /// adds the documents expirations tree under `Misc` (key `E`), beside the total system + /// credits item this transition rewrites, and the extra key reshapes the `Misc` Merk + /// the write rehashes. v14's `ContractGroups` root tree (key 124) sits under the + /// `Versions` node no fee-bearing transition rewrites and changes nothing here. Pinned so + /// v13 chain history stays bit-for-bit reproducible. #[tokio::test] async fn test_identity_create_asset_lock_replay_attack_protocol_version_13() { run_test_identity_create_asset_lock_replay_attack_at_protocol_version( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs index 8fde64da40d..d6a54ae5397 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up/mod.rs @@ -259,16 +259,18 @@ mod tests { fn test_identity_top_up_validation_latest_version() { run_test_identity_top_up_validation_at_protocol_version( PlatformVersion::latest().protocol_version, - 588840, - 149993606160, + // PROTOCOL_VERSION_14: 4,960 credits less, see the protocol version 13 twin + 583880, + 149993611120, ); } - /// PROTOCOL_VERSION_13: the same fee as at the latest version. v14 adds the - /// `ContractGroups` root tree at key 124, under the `Versions` node that no fee-bearing - /// transition rewrites, so the asset lock outpoint write costs the same on both sides of - /// the boundary; this pin is what fails if a root tree ever lands under the asset lock - /// path. Pinned so v13 chain history stays bit-for-bit reproducible. + /// PROTOCOL_VERSION_13: 4,960 credits more processing than at the latest version. v14 + /// adds the documents expirations tree under `Misc` (key `E`), beside the total system + /// credits item this transition rewrites, and the extra key reshapes the `Misc` Merk + /// the write rehashes. v14's `ContractGroups` root tree (key 124) sits under the + /// `Versions` node no fee-bearing transition rewrites and changes nothing here. Pinned so + /// v13 chain history stays bit-for-bit reproducible. #[test] fn test_identity_top_up_validation_protocol_version_13() { run_test_identity_top_up_validation_at_protocol_version(13, 588840, 149993606160); diff --git a/packages/rs-drive-abci/src/platform_types/platform/mod.rs b/packages/rs-drive-abci/src/platform_types/platform/mod.rs index 9f1946493d1..a0e1b5dbec9 100644 --- a/packages/rs-drive-abci/src/platform_types/platform/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/platform/mod.rs @@ -154,8 +154,12 @@ impl Platform { } }; + // The epoch length is the execution config's; Drive routes the price of documents with + // a time to live by it, so it gets the same value rather than a setting of its own. + let mut drive_config = config.drive.clone(); + drive_config.epoch_time_length_s = config.execution.epoch_time_length_s; let (drive, current_platform_version) = - Drive::open(&config.db_path, Some(config.drive.clone())).map_err(Error::Drive)?; + Drive::open(&config.db_path, Some(drive_config)).map_err(Error::Drive)?; // Finish any TTL bucket-drop reclamation a crash interrupted // (grovedb#848 / PR #849): committed redo records survive restarts, diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/identity_and_document_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/identity_and_document_tests.rs index e544057cd5d..9a8fb2d8fd4 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/identity_and_document_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/identity_and_document_tests.rs @@ -187,7 +187,11 @@ mod tests { .expect("expected to fetch balances") .expect("expected to have an identity to get balance from"); - assert_eq!(balance, 99864009940) + // PROTOCOL_VERSION_14: the identity pays 41_080 credits more in fees than at protocol + // version 13. The documents expirations tree joins `Misc` (key `E`) beside the total + // system credits item an identity created from an asset lock rewrites, and the extra + // key reshapes the `Misc` Merk that write rehashes. + assert_eq!(balance, 99863968860) } #[tokio::test] @@ -196,11 +200,12 @@ mod tests { // gates active from v14 derive their inspection from data the merk // apply already loads, so they are cost-neutral, and the ContractGroups // root tree v14 adds sits at key 124 under Versions (120), a node no - // fee-bearing transition rewrites: this balance is identical to the - // latest-version test's, and the pair proves the v13 -> v14 boundary - // changes nothing about this run's fees. A root tree placed under the - // asset lock path would have moved it, as happened once before when - // GroupActions was added: + // fee-bearing transition rewrites. The one v14 change this run's fees + // see is the documents expirations tree v14 adds under `Misc`, which + // reshapes the Merk of the total system credits item an asset lock + // rewrites (see the latest-version test); this pin holds v13 at the + // fee it had before. A root tree placed under the asset lock path + // moves it too, as happened once before when GroupActions was added: // DataContract_Documents 64 // / \ // Identities 32 Balances 96 @@ -352,7 +357,8 @@ mod tests { assert_eq!(outcome.identities.len(), 100); } - #[tokio::test] + #[stack_size(4 * 1024 * 1024)] + #[test] async fn run_chain_insert_one_new_identity_per_block_with_epoch_change() { let strategy = NetworkStrategy { strategy: Strategy { diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-deletable-doc-registration-expiring.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-deletable-doc-registration-expiring.json new file mode 100644 index 00000000000..9e941af62f4 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-deletable-doc-registration-expiring.json @@ -0,0 +1,45 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "expiringNote": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "ttl": 86400, + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 100 + } + }, + "required": [ + "$createdAt" + ], + "additionalProperties": false + }, + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "noteId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "deletableDocument", + "documentType": "expiringNote" + } + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-permanent-doc-registration-expiring.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-permanent-doc-registration-expiring.json new file mode 100644 index 00000000000..bf0db7161aa --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-permanent-doc-registration-expiring.json @@ -0,0 +1,45 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "expiringNote": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "ttl": 86400, + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 100 + } + }, + "required": [ + "$createdAt" + ], + "additionalProperties": false + }, + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "noteId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "expiringNote" + } + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 343665de9b8..329491ef4f8 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -2708,7 +2708,7 @@ "EpochOwned", "None" ], - "flags_note": "The owner is the owner of the document, who is refunded when it is deleted. Documents the system writes carry no flags.", + "flags_note": "The owner is the owner of the document, who is refunded when it is deleted. Documents the system writes, and documents of a type declaring a `ttl`, carry no flags.", "value": "serialized document", "since": 1, "presence": "always", @@ -2787,7 +2787,7 @@ "EpochOwned", "None" ], - "flags_note": "The owner is the owner of the document that created this level of the index. Levels created with the contract carry the contract owner, and levels of system data carry no flags.", + "flags_note": "The owner is the owner of the document that created this level of the index. Levels created with the contract carry the contract owner, and levels of system data, or created by a document of a type declaring a `ttl`, carry no flags.", "since": 1, "presence": "always", "source": "packages/rs-drive/src/drive/document/index_level_tree_types.rs", @@ -2818,7 +2818,7 @@ "EpochOwned", "None" ], - "flags_note": "The owner is the owner of the document that created this level of the index. Levels created with the contract carry the contract owner, and levels of system data carry no flags.", + "flags_note": "The owner is the owner of the document that created this level of the index. Levels created with the contract carry the contract owner, and levels of system data, or created by a document of a type declaring a `ttl`, carry no flags.", "since": 1, "presence": "always", "source": "packages/rs-drive/src/drive/document/index_level_tree_types.rs", @@ -2849,7 +2849,7 @@ "EpochOwned", "None" ], - "flags_note": "The owner is the owner of the document that created this level of the index. Levels created with the contract carry the contract owner, and levels of system data carry no flags.", + "flags_note": "The owner is the owner of the document that created this level of the index. Levels created with the contract carry the contract owner, and levels of system data, or created by a document of a type declaring a `ttl`, carry no flags.", "reference": "contracts.contract.documents.document_type.primary_key.document", "since": 1, "presence": "lazy", @@ -2879,7 +2879,7 @@ "EpochOwned", "None" ], - "flags_note": "The owner is the owner of the document, who is refunded when it is deleted. Documents the system writes carry no flags.", + "flags_note": "The owner is the owner of the document, who is refunded when it is deleted. Documents the system writes, and documents of a type declaring a `ttl`, carry no flags.", "value": "for index only document types, a 32 byte row commitment", "reference": "contracts.contract.documents.document_type.primary_key.document", "since": 1, @@ -2916,7 +2916,7 @@ "EpochOwned", "None" ], - "flags_note": "The owner is the owner of the document that created this level of the index. Levels created with the contract carry the contract owner, and levels of system data carry no flags.", + "flags_note": "The owner is the owner of the document that created this level of the index. Levels created with the contract carry the contract owner, and levels of system data, or created by a document of a type declaring a `ttl`, carry no flags.", "since": 1, "presence": "always", "source": "packages/rs-drive/src/drive/document/index_level_tree_types.rs", @@ -3896,7 +3896,7 @@ "since": 1, "presence": "always", "source": "packages/rs-drive/src/drive/mod.rs", - "description": "Chain wide values: total credits, total token supplies, the genesis core height.", + "description": "Chain wide values: total credits, total token supplies, the genesis core height, and the documents waiting to expire.", "children": [ { "id": "misc.genesis_core_height", @@ -3936,6 +3936,70 @@ "description": "Every credit in Platform. Must equal the sum of all balance and pool trees.", "children": [] }, + { + "id": "misc.documents_expirations", + "key": { + "type": "fixed", + "hex": "45", + "label": "DocumentsExpirations", + "constant": "DOCUMENTS_EXPIRATIONS_KEY", + "ascii": true + }, + "kinds": [ + "Tree" + ], + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/document/expiration/paths.rs", + "book": "data-model/document-ttl.md", + "description": "Every document whose type declares a `ttl`, by the time it expires. After each block's state transitions the platform deletes the expired ones, oldest first, a versioned number per block.", + "children": [ + { + "id": "misc.documents_expirations.expiry_time", + "key": { + "type": "dynamic", + "name": "expires_at", + "matcher": { + "type": "len", + "len": 8 + }, + "encoding": "u64_be", + "description": "When the documents under it expire, in ms" + }, + "kinds": [ + "Tree" + ], + "since": 14, + "presence": "lazy", + "source": "packages/rs-drive/src/drive/document/expiration/paths.rs", + "description": "The documents expiring at one time: created in one block by types of one time to live. Removed with its last entry.", + "children": [ + { + "id": "misc.documents_expirations.expiry_time.document", + "key": { + "type": "dynamic", + "name": "document_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The document id" + }, + "kinds": [ + "Item" + ], + "value": "contract id (32 bytes), then the document type name", + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/document/expiration/paths.rs", + "description": "Where the expiring document is. Removed with the document, whoever deletes it. No flags: the document's creation paid for the time it lives.", + "children": [] + } + ] + } + ] + }, { "id": "misc.total_token_supplies", "key": { @@ -5322,9 +5386,12 @@ "misc": { "origin": "genesis@14", "tree": { - "hex": "54", + "hex": "45", "left": { "hex": "44" + }, + "right": { + "hex": "54" } } }, diff --git a/packages/rs-drive/src/config.rs b/packages/rs-drive/src/config.rs index 04eef6a5d81..504d74af42b 100644 --- a/packages/rs-drive/src/config.rs +++ b/packages/rs-drive/src/config.rs @@ -18,6 +18,9 @@ pub const DEFAULT_QUERY_LIMIT: u16 = 100; pub const DEFAULT_MAX_QUERY_LIMIT: u16 = 100; /// Default maximum number of contracts in cache pub const DEFAULT_DATA_CONTRACTS_CACHE_SIZE: u64 = 500; +/// The default length of an epoch in seconds: mainnet's, and the node's `ExecutionConfig` +/// default +pub const DEFAULT_EPOCH_TIME_LENGTH_S: u64 = 788400; #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -120,6 +123,18 @@ pub struct DriveConfig { serde(skip_deserializing, default = "DriveConfig::default_network") )] pub network: Network, + + /// How long an epoch lasts, in seconds. Neither read nor written with the rest of the + /// config: the node sets it from its execution config's `epoch_time_length_s` when it + /// opens Drive, so there is one source. Drive reads it only to route the price of a + /// document whose type declares a `ttl` to the processing fees (a `ttl` under the fee + /// schedule's `processing_route_below_epochs` epochs) or the storage fee pool; the price + /// itself follows the schedule's fixed period. + #[cfg_attr( + feature = "serde", + serde(skip, default = "default_epoch_time_length_s") + )] + pub epoch_time_length_s: u64, } // TODO: some weird envy behavior requires this to exist @@ -174,6 +189,10 @@ const fn default_epochs_per_era() -> u16 { DEFAULT_EPOCHS_PER_ERA } +const fn default_epoch_time_length_s() -> u64 { + DEFAULT_EPOCH_TIME_LENGTH_S +} + const fn default_max_query_limit() -> u16 { DEFAULT_MAX_QUERY_LIMIT } @@ -204,6 +223,7 @@ impl Default for DriveConfig { #[cfg(feature = "grovedbg")] grovedb_visualizer_enabled: false, network: Network::Mainnet, + epoch_time_length_s: default_epoch_time_length_s(), } } } diff --git a/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs b/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs index 127c5ac2fba..5f17c93b725 100644 --- a/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs +++ b/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs @@ -679,7 +679,7 @@ fn delete_post_by_moderator<'a>( contract: &'a DataContract, document_id: Identifier, ) -> DriveOperation<'a> { - DocumentOperation(DocumentOperationType::DeleteDocumentByModerator { + DocumentOperation(DocumentOperationType::ForceDeleteDocument { document_id, contract_info: DataContractInfo::BorrowedDataContract(contract), document_type_info: DocumentTypeInfo::DocumentTypeName(POST.to_string()), diff --git a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_operations/v0/mod.rs index 0134b878d25..0abf3ea7fbd 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_document_for_contract_operations/v0/mod.rs @@ -1,3 +1,5 @@ +use crate::drive::document::expiration::pricing::document_expires_at; +use crate::drive::document::expiration::DocumentExpirationEntry; use crate::drive::document::primary_key_tree_type::DocumentTypePrimaryKeyTreeType; use grovedb::batch::KeyInfoPath; @@ -8,18 +10,21 @@ use dpp::data_contract::document_type::DocumentTypeRef; use std::collections::HashMap; use crate::drive::document::paths::contract_documents_primary_key_path; +use crate::util::object_size_info::DocumentInfo; use crate::util::object_size_info::DocumentInfo::{ DocumentEstimatedAverageSize, DocumentOwnedInfo, }; use crate::util::storage_flags::StorageFlags; use dpp::data_contract::DataContract; -use dpp::document::Document; +use dpp::document::{Document, DocumentV0Getters}; use crate::drive::Drive; use crate::util::grove_operations::DirectQueryType; use crate::util::grove_operations::QueryTarget::QueryTargetValue; -use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use crate::util::object_size_info::{ + DocumentAndContractInfo, DocumentInfoV0Methods, OwnedDocumentInfo, +}; use crate::error::drive::DriveError; @@ -177,6 +182,47 @@ impl Drive { ))); }; + self.delete_read_document_for_contract_operations_v0( + document_id, + document_info, + contract, + document_type, + previous_batch_operations, + estimated_costs_only_with_layer_info, + block_time_ms, + transaction, + batch_operations, + platform_version, + ) + } + + /// The part of [`Self::force_delete_document_for_contract_operations_v0`] after the + /// document is read from its primary storage: removes it there, removes its index + /// entries and, for a type with a `ttl`, its expirations tree entry, appending to + /// `batch_operations`. Split out, with the operations and their order unchanged, so the + /// document expiry cleanup (protocol version 14), which reads the document to check it + /// first, deletes it without reading it a second time. + #[allow(clippy::too_many_arguments)] + pub(in crate::drive::document) fn delete_read_document_for_contract_operations_v0( + &self, + document_id: Identifier, + document_info: DocumentInfo, + contract: &DataContract, + document_type: DocumentTypeRef, + previous_batch_operations: Option<&mut Vec>, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + block_time_ms: u64, + transaction: TransactionArg, + mut batch_operations: Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let contract_documents_primary_key_path = contract_documents_primary_key_path( + contract.id_ref().as_bytes(), + document_type.name().as_str(), + ); + // third we need to delete the document for it's primary key self.remove_document_from_primary_storage( document_id, @@ -206,6 +252,44 @@ impl Drive { &mut batch_operations, platform_version, )?; + + // A document whose type declares a `ttl` has an entry in the documents expirations + // tree, keyed by when it expires: it goes with the document, whoever deletes it (its + // owner, a moderator, or the expiry cleanup). In place in this shipped generation: + // `documents_ttl_seconds` is `Some` only on a document type parsed by generation 3 + // from a `ttl` keyword, which only protocol version 14 reads and every earlier + // meta-schema refuses, so no protocol version before 14 reaches this branch. + if let Some(ttl_seconds) = document_type.documents_ttl_seconds() { + let entry_value_size = + DocumentExpirationEntry::serialized_size(document_type.name().as_str()); + let expires_at_ms = match document_and_contract_info + .owned_document_info + .document_info + .get_borrowed_document() + { + Some(document) => { + let created_at = document.created_at().ok_or(Error::Drive( + DriveError::CorruptedDriveState( + "a document of a type with a time to live has no creation time" + .to_string(), + ), + ))?; + document_expires_at(created_at, ttl_seconds)? + } + // A worst-case estimate has no document: any time key prices the same. + None => document_expires_at(block_time_ms, ttl_seconds)?, + }; + self.remove_document_expiration_operations( + document_id.to_buffer(), + expires_at_ms, + entry_value_size, + estimated_costs_only_with_layer_info, + &previous_batch_operations, + transaction, + &mut batch_operations, + platform_version, + )?; + } Ok(batch_operations) } } diff --git a/packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/mod.rs b/packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/mod.rs new file mode 100644 index 00000000000..4aa9d92520e --- /dev/null +++ b/packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/mod.rs @@ -0,0 +1,71 @@ +mod v0; + +use crate::drive::document::expiration::DocumentExpirationEntry; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Gathers the operations writing a document's entry in the documents expirations tree: + /// the tree of the documents expiring at `expires_at_ms` if it does not exist yet, and + /// the entry under it keyed by the document's id. Neither carries storage flags. + /// + /// # Parameters + /// - `document_id`: the document's id; `None` in a worst-case estimate that has no + /// document, where the key is estimated at its size. + /// - `entry`: where the document is. + /// - `expires_at_ms`: when the document expires. + /// - `estimated_costs_only_with_layer_info`: set in a dry run. + /// - `previous_batch_operations`: operations queued earlier in the batch, so the tree of + /// one expiry time is queued once. + /// - `transaction`: the transaction to read in. + /// - `batch_operations`: receives the operations. + /// - `platform_version`: selects the method version. + /// + /// # Returns + /// `Ok(())` once the operations are queued. + #[allow(clippy::too_many_arguments)] + pub(crate) fn add_document_expiration_operations( + &self, + document_id: Option<[u8; 32]>, + entry: &DocumentExpirationEntry, + expires_at_ms: TimestampMillis, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + previous_batch_operations: &mut Option<&mut Vec>, + transaction: TransactionArg, + batch_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .document + .expiration + .add_document_expiration_operations + { + 0 => self.add_document_expiration_operations_v0( + document_id, + entry, + expires_at_ms, + estimated_costs_only_with_layer_info, + previous_batch_operations, + transaction, + batch_operations, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_document_expiration_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/v0/mod.rs new file mode 100644 index 00000000000..0944b6b54c1 --- /dev/null +++ b/packages/rs-drive/src/drive/document/expiration/add_document_expiration_operations/v0/mod.rs @@ -0,0 +1,104 @@ +use crate::drive::document::expiration::paths::{ + documents_expirations_at_time_path_vec, documents_expirations_path_vec, encode_expiration_time, +}; +use crate::drive::document::expiration::DocumentExpirationEntry; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchInsertTreeApplyType; +use crate::util::object_size_info::PathKeyElementInfo::{PathKeyElement, PathKeyElementSize}; +use crate::util::object_size_info::{DriveKeyInfo, PathInfo, PathKeyElementInfo}; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use grovedb::batch::key_info::KeyInfo; +use grovedb::batch::KeyInfoPath; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; +use std::collections::HashMap; + +impl Drive { + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn add_document_expiration_operations_v0( + &self, + document_id: Option<[u8; 32]>, + entry: &DocumentExpirationEntry, + expires_at_ms: TimestampMillis, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + previous_batch_operations: &mut Option<&mut Vec>, + transaction: TransactionArg, + batch_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let entry_bytes = entry.to_bytes(); + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_document_expiration( + expires_at_ms, + entry_bytes.len() as u32, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + // Misc / E + // / \ + // expires at t1 expires at t2 + // / \ | + // document 1 document 2 document 3 + + // The tree of the documents expiring at this time, unless a document created earlier + // (in this block or the batch) already made it. + let time_key = DriveKeyInfo::Key(encode_expiration_time(expires_at_ms).to_vec()); + let path_key_info = + time_key.add_path_info::<0>(PathInfo::PathAsVec(documents_expirations_path_vec())); + let 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, + apply_type, + transaction, + previous_batch_operations, + batch_operations, + &platform_version.drive, + )?; + + // The entry itself, keyed by the document id: a document id is unique, so the key is + // free and a plain insert suffices. + let time_path = documents_expirations_at_time_path_vec(expires_at_ms); + let item = Element::Item(entry_bytes, None); + let path_key_element_info: PathKeyElementInfo<'_, 0> = match document_id { + Some(document_id) if estimated_costs_only_with_layer_info.is_none() => { + PathKeyElement((time_path, document_id.to_vec(), item)) + } + Some(document_id) => PathKeyElementSize(( + KeyInfoPath::from_known_owned_path(time_path), + KeyInfo::KnownKey(document_id.to_vec()), + item, + )), + None => PathKeyElementSize(( + KeyInfoPath::from_known_owned_path(time_path), + KeyInfo::MaxKeySize { + unique_id: b"document_expiration_entry".to_vec(), + max_size: 32, + }, + item, + )), + }; + self.batch_insert( + path_key_element_info, + batch_operations, + &platform_version.drive, + ) + } +} diff --git a/packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/mod.rs b/packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/mod.rs new file mode 100644 index 00000000000..e2d0c0e435a --- /dev/null +++ b/packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/mod.rs @@ -0,0 +1,53 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::prelude::TimestampMillis; +use dpp::version::drive_versions::DriveVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerInformation; +use std::collections::HashMap; + +impl Drive { + /// Adds the layers a write to the documents expirations tree goes through to a dry run's + /// estimated layer information: the root, `Misc`, the expirations tree and the tree of the + /// documents expiring at `expires_at_ms`. + /// + /// # Parameters + /// - `expires_at_ms`: the time key of the entry written or removed. + /// - `entry_value_size`: the size of the entry's value + /// (`DocumentExpirationEntry::serialized_size`). + /// - `estimated_costs_only_with_layer_info`: the dry run's layer information. + /// - `drive_version`: selects the method version. + /// + /// # Returns + /// `Ok(())` once the layers are added. + pub(crate) fn add_estimation_costs_for_document_expiration( + expires_at_ms: TimestampMillis, + entry_value_size: u32, + estimated_costs_only_with_layer_info: &mut HashMap, + drive_version: &DriveVersion, + ) -> Result<(), Error> { + match drive_version + .methods + .document + .expiration + .add_estimation_costs_for_document_expiration + { + 0 => { + Self::add_estimation_costs_for_document_expiration_v0( + expires_at_ms, + entry_value_size, + estimated_costs_only_with_layer_info, + ); + Ok(()) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_estimation_costs_for_document_expiration".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/v0/mod.rs b/packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/v0/mod.rs new file mode 100644 index 00000000000..9a5d08103c9 --- /dev/null +++ b/packages/rs-drive/src/drive/document/expiration/add_estimation_costs_for_document_expiration/v0/mod.rs @@ -0,0 +1,86 @@ +use crate::drive::document::expiration::paths::{ + documents_expirations_at_time_path_vec, documents_expirations_path_vec, +}; +use crate::drive::system::misc_path_vec; +use crate::drive::Drive; +use crate::util::type_constants::{DEFAULT_HASH_SIZE_U8, U64_SIZE_U8}; +use dpp::prelude::TimestampMillis; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerCount::{ApproximateElements, EstimatedLevel}; +use grovedb::EstimatedLayerSizes::{AllItems, AllSubtrees}; +use grovedb::EstimatedSumTrees::{NoSumTrees, SomeSumTrees}; +use grovedb::{EstimatedLayerInformation, TreeType}; +use std::collections::HashMap; + +impl Drive { + #[inline(always)] + pub(super) fn add_estimation_costs_for_document_expiration_v0( + expires_at_ms: TimestampMillis, + entry_value_size: u32, + estimated_costs_only_with_layer_info: &mut HashMap, + ) { + // The root: `Misc` sits on the fourth level of the root tree, below `Votes`, and the + // root holds sum trees beside plain ones. This is the estimate every write under `Misc` + // uses (`add_estimation_costs_for_total_system_credits_update`). It replaces the level 0 + // estimate a document write puts there (`DataContract_Documents` is the root's top + // node), which only raises the batch's estimate: a document with a time to live + // writes below both. + 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( + 12, + SomeSumTrees { + sum_trees_weight: 1, + big_sum_trees_weight: 0, + count_trees_weight: 0, + count_sum_trees_weight: 0, + non_sum_trees_weight: 2, + provable_sum_trees_weight: 0, + provable_count_trees_weight: 0, + provable_count_sum_trees_weight: 0, + provable_count_provable_sum_trees_weight: 0, + }, + None, + ), + }, + ); + + // `Misc`: a handful of single-byte keys, items and trees. + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(misc_path_vec()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(4), + estimated_layer_sizes: AllSubtrees(1, NoSumTrees, None), + }, + ); + + // The expirations tree: one tree per distinct expiry time. A block every five + // seconds expiring documents of one time to live, for a year of the longest time to + // live, bounds it from above. + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(documents_expirations_path_vec()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(6_307_200), + estimated_layer_sizes: AllSubtrees(U64_SIZE_U8, NoSumTrees, None), + }, + ); + + // The documents expiring at one time: those one block created in one document type, + // or several types of one time to live. + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(documents_expirations_at_time_path_vec( + expires_at_ms, + )), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(16), + estimated_layer_sizes: AllItems(DEFAULT_HASH_SIZE_U8, entry_value_size, None), + }, + ); + } +} diff --git a/packages/rs-drive/src/drive/document/expiration/expiration_tests.rs b/packages/rs-drive/src/drive/document/expiration/expiration_tests.rs new file mode 100644 index 00000000000..af538d66257 --- /dev/null +++ b/packages/rs-drive/src/drive/document/expiration/expiration_tests.rs @@ -0,0 +1,949 @@ +//! Documents whose type declares a `ttl`: their expirations tree entries, their flagless +//! storage, their pricing and the cleanup that deletes them. + +use crate::drive::document::expiration::paths::{ + documents_expirations_at_time_path_vec, documents_expirations_path_vec, encode_expiration_time, +}; +use crate::drive::document::expiration::pricing::document_expiration_cleanup_fee; +use crate::drive::document::expiration::{DocumentExpirationEntry, RemovedExpiredDocuments}; +use crate::drive::document::paths::{ + contract_document_type_path_vec, contract_documents_primary_key_path, +}; +use crate::drive::Drive; +use crate::util::grove_operations::DirectQueryType; +use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; +use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; +use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::DataContractFactory; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; +use dpp::fee::fee_result::FeeResult; +use dpp::platform_value::{platform_value, Identifier, Value}; +use dpp::prelude::DataContract; +use dpp::version::PlatformVersion; +use grovedb::Element; +use std::borrow::Cow; +use std::collections::BTreeMap; + +const OWNER: [u8; 32] = [42; 32]; +const START_MS: u64 = 1_700_000_000_000; +const TWO_WEEKS_S: u32 = 1_209_600; + +/// A contract with a `note` type expiring after `ttl_seconds` and a `memo` type identical +/// but for the `ttl`, both with two indexes. +fn contract_with_ttl(ttl_seconds: u32) -> DataContract { + let note = |ttl: Option| { + let mut schema = platform_value!({ + "type": "object", + "documentsMutable": true, + "properties": { + "text": { "type": "string", "maxLength": 50, "position": 0 }, + }, + "indices": [ + { "name": "byOwner", "properties": [{ "$ownerId": "asc" }] }, + { "name": "byText", "properties": [{ "text": "asc" }] }, + ], + "required": ["$createdAt", "text"], + "additionalProperties": false, + }); + if let Some(ttl) = ttl { + schema + .insert("ttl".to_string(), Value::U32(ttl)) + .expect("expected to set the ttl"); + } + schema + }; + DataContractFactory::new(PlatformVersion::latest().protocol_version) + .expect("factory") + .create_with_value_config( + Identifier::from([9; 32]), + 0, + platform_value!({ "note": note(Some(ttl_seconds)), "memo": note(None) }), + None, + None, + ) + .expect("contract") + .data_contract_owned() +} + +fn setup(ttl_seconds: u32) -> (Drive, DataContract) { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = contract_with_ttl(ttl_seconds); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("contract applies"); + (drive, contract) +} + +fn note(marker: u8, created_at: u64, text: &str) -> Document { + let mut id = [marker; 32]; + id[1..9].copy_from_slice(&created_at.to_be_bytes()); + Document::V0(DocumentV0 { + id: Identifier::from(id), + owner_id: Identifier::from(OWNER), + properties: BTreeMap::from([("text".to_string(), Value::Text(text.to_string()))]), + revision: Some(1), + created_at: Some(created_at), + ..Default::default() + }) +} + +/// The storage flags a create or replace transition writes a document with: the owner's, +/// in the current epoch. +fn owner_flags() -> Option> { + Some(Cow::Owned(StorageFlags::new_single_epoch(0, Some(OWNER)))) +} + +fn block_at(time_ms: u64) -> BlockInfo { + BlockInfo { + time_ms, + ..Default::default() + } +} + +/// Inserts `document` into `document_type_name` at its creation time, the way a create +/// transition does: with the owner's storage flags, which a type with a `ttl` must drop. +fn insert( + drive: &Drive, + contract: &DataContract, + document_type_name: &str, + document: &Document, + apply: bool, +) -> FeeResult { + insert_at_version( + drive, + contract, + document_type_name, + document, + apply, + PlatformVersion::latest(), + ) +} + +/// [`insert`] under a given platform version, a changed fee schedule for one. +fn insert_at_version( + drive: &Drive, + contract: &DataContract, + document_type_name: &str, + document: &Document, + apply: bool, + platform_version: &PlatformVersion, +) -> FeeResult { + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((document, owner_flags())), + owner_id: Some(OWNER), + }, + contract, + document_type: contract + .document_type_for_name(document_type_name) + .expect("document type"), + }, + false, + block_at(document.created_at().expect("created at")), + apply, + None, + platform_version, + None, + ) + .expect("document inserts") +} + +fn entry_element(drive: &Drive, expires_at_ms: u64, document_id: Identifier) -> Option { + drive + .grove_get_raw_optional( + documents_expirations_at_time_path_vec(expires_at_ms) + .as_slice() + .into(), + document_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &PlatformVersion::latest().drive, + ) + .unwrap_or(None) +} + +fn stored_document( + drive: &Drive, + contract: &DataContract, + document_type_name: &str, + id: Identifier, +) -> Option { + let path = + contract_documents_primary_key_path(contract.id_ref().as_bytes(), document_type_name); + drive + .grove_get_raw_optional( + (&path).into(), + id.as_slice(), + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &PlatformVersion::latest().drive, + ) + .expect("read") +} + +fn expiry_tree_exists(drive: &Drive, expires_at_ms: u64) -> bool { + drive + .grove_get_raw_optional( + documents_expirations_path_vec().as_slice().into(), + &encode_expiration_time(expires_at_ms), + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &PlatformVersion::latest().drive, + ) + .expect("read") + .is_some() +} + +fn remove_expired(drive: &Drive, time_ms: u64, limit: u16) -> RemovedExpiredDocuments { + remove_expired_within( + drive, + time_ms, + limit, + PlatformVersion::latest() + .system_limits + .max_document_expiration_weight_per_block, + ) +} + +fn remove_expired_within( + drive: &Drive, + time_ms: u64, + limit: u16, + weight_budget: u32, +) -> RemovedExpiredDocuments { + let transaction = drive.grove.start_transaction(); + let removed = drive + .remove_expired_documents( + &block_at(time_ms), + limit, + weight_budget, + Some(&transaction), + PlatformVersion::latest(), + ) + .expect("cleanup runs"); + drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("commits"); + removed +} + +#[test] +fn should_index_a_document_with_a_time_to_live_by_its_expiry() { + let (drive, contract) = setup(TWO_WEEKS_S); + let document = note(1, START_MS, "hello"); + insert(&drive, &contract, "note", &document, true); + let expires_at = START_MS + u64::from(TWO_WEEKS_S) * 1000; + + let entry = entry_element(&drive, expires_at, document.id()).expect("the entry exists"); + let Element::Item(bytes, flags) = entry else { + panic!("the entry must be an item"); + }; + assert_eq!(flags, None, "the entry carries no storage flags"); + assert_eq!( + DocumentExpirationEntry::from_bytes(&bytes).expect("decodes"), + DocumentExpirationEntry { + contract_id: contract.id(), + document_type_name: "note".to_string(), + } + ); + + let platform_version = PlatformVersion::latest(); + let early = drive + .fetch_expired_documents(expires_at - 1, 128, None, &mut vec![], platform_version) + .expect("fetch"); + assert!(early.is_empty(), "nothing has expired a ms early"); + let due = drive + .fetch_expired_documents(expires_at, 128, None, &mut vec![], platform_version) + .expect("fetch"); + assert_eq!(due.len(), 1); + assert_eq!(due[0].document_id, document.id()); + assert_eq!(due[0].expires_at_ms, expires_at); + + // A document of the type without a `ttl` gets no entry. + let memo = note(2, START_MS, "memo"); + insert(&drive, &contract, "memo", &memo, true); + let due = drive + .fetch_expired_documents(u64::MAX, 128, None, &mut vec![], platform_version) + .expect("fetch"); + assert_eq!(due.len(), 1); +} + +#[test] +fn should_store_a_document_with_a_time_to_live_without_storage_flags() { + let (drive, contract) = setup(TWO_WEEKS_S); + let document = note(1, START_MS, "hello"); + insert(&drive, &contract, "note", &document, true); + let memo = note(2, START_MS, "hello"); + insert(&drive, &contract, "memo", &memo, true); + + let Some(Element::Item(_, flags)) = stored_document(&drive, &contract, "note", document.id()) + else { + panic!("the document is stored as an item"); + }; + assert_eq!(flags, None, "the document carries no storage flags"); + let Some(Element::Item(_, memo_flags)) = stored_document(&drive, &contract, "memo", memo.id()) + else { + panic!("the memo is stored as an item"); + }; + assert!( + memo_flags.is_some(), + "a document without a time to live keeps its owner's flags" + ); + + // Its index entries carry none either: the `byText` value tree it created and the + // reference under it. + let text_value_path: Vec> = { + let mut path = contract_document_type_path_vec(contract.id_ref().as_bytes(), "note"); + path.push(b"text".to_vec()); + path + }; + let value_tree = drive + .grove_get_raw_optional( + text_value_path.as_slice().into(), + b"hello", + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &PlatformVersion::latest().drive, + ) + .expect("read") + .expect("the value tree exists"); + assert_eq!(value_tree.get_flags(), &None); +} + +#[test] +fn should_delete_expired_documents_oldest_first_and_drop_their_trees() { + let (drive, contract) = setup(TWO_WEEKS_S); + let ttl_ms = u64::from(TWO_WEEKS_S) * 1000; + let documents: Vec = (0..3u64) + .map(|i| note(1 + i as u8, START_MS + i * 1000, &format!("note {i}"))) + .collect(); + for document in &documents { + insert(&drive, &contract, "note", document, true); + } + + // At the second document's expiry the first two go; the third stays. + let removed = remove_expired(&drive, START_MS + 1000 + ttl_ms, 128); + assert_eq!( + removed, + RemovedExpiredDocuments { + deleted_documents: 2, + orphaned_entries: 0, + } + ); + assert!(stored_document(&drive, &contract, "note", documents[0].id()).is_none()); + assert!(stored_document(&drive, &contract, "note", documents[1].id()).is_none()); + assert!(stored_document(&drive, &contract, "note", documents[2].id()).is_some()); + assert!(!expiry_tree_exists(&drive, START_MS + ttl_ms)); + assert!(!expiry_tree_exists(&drive, START_MS + 1000 + ttl_ms)); + assert!(expiry_tree_exists(&drive, START_MS + 2000 + ttl_ms)); + + // Their index entries went with them: the text values of the deleted notes are gone. + let mut text_path = contract_document_type_path_vec(contract.id_ref().as_bytes(), "note"); + text_path.push(b"text".to_vec()); + let value = |text: &str| { + drive + .grove_get_raw_optional( + text_path.as_slice().into(), + text.as_bytes(), + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &PlatformVersion::latest().drive, + ) + .expect("read") + }; + assert!(value("note 0").is_none()); + assert!(value("note 1").is_none()); + assert!(value("note 2").is_some()); + + let removed = remove_expired(&drive, START_MS + 2000 + ttl_ms, 128); + assert_eq!(removed.deleted_documents, 1); + assert!(!expiry_tree_exists(&drive, START_MS + 2000 + ttl_ms)); +} + +#[test] +fn should_delete_at_most_the_limit_per_run_and_keep_a_partly_drained_tree() { + let (drive, contract) = setup(TWO_WEEKS_S); + let expires_at = START_MS + u64::from(TWO_WEEKS_S) * 1000; + // Five documents created in one block expire together. + for i in 0..5u8 { + insert( + &drive, + &contract, + "note", + ¬e(10 + i, START_MS, &format!("same block {i}")), + true, + ); + } + + let first = remove_expired(&drive, expires_at, 2); + assert_eq!(first.deleted_documents, 2); + assert!(expiry_tree_exists(&drive, expires_at)); + + let second = remove_expired(&drive, expires_at, 2); + assert_eq!(second.deleted_documents, 2); + assert!(expiry_tree_exists(&drive, expires_at)); + + let third = remove_expired(&drive, expires_at, 2); + assert_eq!(third.deleted_documents, 1); + assert!(!expiry_tree_exists(&drive, expires_at)); + + assert_eq!( + remove_expired(&drive, expires_at, 2), + RemovedExpiredDocuments::default() + ); +} + +#[test] +fn should_remove_the_entry_when_the_owner_deletes_the_document() { + let (drive, contract) = setup(TWO_WEEKS_S); + let expires_at = START_MS + u64::from(TWO_WEEKS_S) * 1000; + let document = note(1, START_MS, "hello"); + insert(&drive, &contract, "note", &document, true); + + let fee = drive + .delete_document_for_contract( + document.id(), + &contract, + "note", + block_at(START_MS + 60_000), + true, + None, + PlatformVersion::latest(), + None, + ) + .expect("the owner deletes"); + assert!( + fee.fee_refunds.0.is_empty(), + "a document with a time to live refunds nothing" + ); + assert!(entry_element(&drive, expires_at, document.id()).is_none()); + // Its entry was the last of its expiry time, so the tree of that time went with it. + assert!(!expiry_tree_exists(&drive, expires_at)); + assert_eq!( + remove_expired(&drive, expires_at, 128), + RemovedExpiredDocuments::default() + ); +} + +#[test] +fn should_keep_the_tree_of_an_expiry_time_until_its_last_entry_goes() { + let (drive, contract) = setup(TWO_WEEKS_S); + let expires_at = START_MS + u64::from(TWO_WEEKS_S) * 1000; + let first = note(1, START_MS, "first"); + let second = note(2, START_MS, "second"); + insert(&drive, &contract, "note", &first, true); + insert(&drive, &contract, "note", &second, true); + let delete = |document: &Document| { + drive + .delete_document_for_contract( + document.id(), + &contract, + "note", + block_at(START_MS + 60_000), + true, + None, + PlatformVersion::latest(), + None, + ) + .expect("the owner deletes"); + }; + + delete(&first); + assert!(expiry_tree_exists(&drive, expires_at)); + assert!(entry_element(&drive, expires_at, second.id()).is_some()); + delete(&second); + assert!(!expiry_tree_exists(&drive, expires_at)); +} + +#[test] +fn should_not_let_documents_deleted_early_delay_the_ones_that_expire() { + // Each owner deletion takes its expiry time's tree with it, so the documents deleted + // early leave nothing the cleanup would read ahead of a document that is due. + let (drive, contract) = setup(TWO_WEEKS_S); + let ttl_ms = u64::from(TWO_WEEKS_S) * 1000; + let documents: Vec = (0..4u64) + .map(|i| note(1 + i as u8, START_MS + i * 1000, &format!("note {i}"))) + .collect(); + for document in &documents { + insert(&drive, &contract, "note", document, true); + } + for document in &documents[..3] { + drive + .delete_document_for_contract( + document.id(), + &contract, + "note", + block_at(START_MS + 60_000), + true, + None, + PlatformVersion::latest(), + None, + ) + .expect("the owner deletes"); + } + + let removed = remove_expired(&drive, START_MS + 3000 + ttl_ms, 1); + assert_eq!(removed.deleted_documents, 1); + assert!(stored_document(&drive, &contract, "note", documents[3].id()).is_none()); +} + +#[test] +fn should_keep_the_expiry_and_the_flagless_storage_when_a_document_is_replaced() { + let (drive, contract) = setup(TWO_WEEKS_S); + let expires_at = START_MS + u64::from(TWO_WEEKS_S) * 1000; + let document = note(1, START_MS, "hello"); + insert(&drive, &contract, "note", &document, true); + + let mut replaced = document.clone(); + replaced.set("text", Value::Text("a longer text than before".to_string())); + replaced.bump_revision(); + let platform_version = PlatformVersion::latest(); + drive + .update_document_for_contract( + &replaced, + &contract, + contract.document_type_for_name("note").expect("type"), + Some(OWNER), + block_at(START_MS + 3_600_000), + true, + owner_flags(), + None, + platform_version, + None, + ) + .expect("the document is replaced"); + + let Some(Element::Item(_, flags)) = stored_document(&drive, &contract, "note", document.id()) + else { + panic!("the document is stored as an item"); + }; + assert_eq!(flags, None, "a replace does not add storage flags"); + assert!(entry_element(&drive, expires_at, document.id()).is_some()); + + let removed = remove_expired(&drive, expires_at, 128); + assert_eq!(removed.deleted_documents, 1); + assert!(stored_document(&drive, &contract, "note", document.id()).is_none()); +} + +#[test] +fn should_delete_an_expired_document_its_owner_may_not_delete() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = DataContractFactory::new(platform_version.protocol_version) + .expect("factory") + .create_with_value_config( + Identifier::from([9; 32]), + 0, + platform_value!({ "note": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "ttl": 3600, + "properties": { "text": { "type": "string", "maxLength": 50, "position": 0 } }, + "required": ["$createdAt", "text"], + "additionalProperties": false, + }}), + None, + None, + ) + .expect("contract") + .data_contract_owned(); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("contract applies"); + let mut document = note(1, START_MS, "permanent to its owner"); + document.set_revision(None); + insert(&drive, &contract, "note", &document, true); + + let removed = remove_expired(&drive, START_MS + 3_600_000, 128); + assert_eq!(removed.deleted_documents, 1); + assert!(stored_document(&drive, &contract, "note", document.id()).is_none()); +} + +#[test] +fn should_remove_an_entry_whose_document_is_gone() { + let (drive, contract) = setup(TWO_WEEKS_S); + let expires_at = START_MS + 5_000; + // An entry no document stands behind, as a bug could leave: the cleanup must not fail + // the block, only drop the entry and its tree. + let transaction = drive.grove.start_transaction(); + let platform_version = PlatformVersion::latest(); + let mut operations = vec![]; + drive + .add_document_expiration_operations( + Some([77; 32]), + &DocumentExpirationEntry { + contract_id: contract.id(), + document_type_name: "note".to_string(), + }, + expires_at, + &mut None, + &mut None, + Some(&transaction), + &mut operations, + platform_version, + ) + .expect("queued"); + drive + .apply_batch_low_level_drive_operations( + None, + Some(&transaction), + operations, + &mut vec![], + &platform_version.drive, + ) + .expect("applied"); + drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("commits"); + + assert_eq!( + remove_expired(&drive, expires_at, 128), + RemovedExpiredDocuments { + deleted_documents: 0, + orphaned_entries: 1, + } + ); + assert!(!expiry_tree_exists(&drive, expires_at)); +} + +#[test] +fn should_remove_an_orphaned_entry_and_a_document_of_one_expiry_time_with_their_tree() { + // Both removals share the block's batch, each built against the other: the second sees + // the first's queued removal, so the tree of the time goes with the last entry. + let (drive, contract) = setup(TWO_WEEKS_S); + let expires_at = START_MS + u64::from(TWO_WEEKS_S) * 1000; + // Ids sort by their first byte: the orphan's, 1, is read before the document's, 2. + let document = note(2, START_MS, "live"); + insert(&drive, &contract, "note", &document, true); + let transaction = drive.grove.start_transaction(); + let platform_version = PlatformVersion::latest(); + let mut operations = vec![]; + drive + .add_document_expiration_operations( + Some([1; 32]), + &DocumentExpirationEntry { + contract_id: contract.id(), + document_type_name: "note".to_string(), + }, + expires_at, + &mut None, + &mut None, + Some(&transaction), + &mut operations, + platform_version, + ) + .expect("queued"); + drive + .apply_batch_low_level_drive_operations( + None, + Some(&transaction), + operations, + &mut vec![], + &platform_version.drive, + ) + .expect("applied"); + drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("commits"); + + assert_eq!( + remove_expired(&drive, expires_at, 128), + RemovedExpiredDocuments { + deleted_documents: 1, + orphaned_entries: 1, + } + ); + assert!(stored_document(&drive, &contract, "note", document.id()).is_none()); + assert!(!expiry_tree_exists(&drive, expires_at)); +} + +#[test] +fn should_stop_the_cleanup_at_the_block_weight_budget() { + // A note weighs 3: itself and its type's two single-property indexes. + let (drive, contract) = setup(TWO_WEEKS_S); + let expires_at = START_MS + u64::from(TWO_WEEKS_S) * 1000; + for i in 0..5u8 { + insert( + &drive, + &contract, + "note", + ¬e(10 + i, START_MS, &format!("same block {i}")), + true, + ); + } + + // 3 + 3 fits in 7, a third note would not. + assert_eq!( + remove_expired_within(&drive, expires_at, 128, 7).deleted_documents, + 2 + ); + // The first removal of a block always runs, however little the budget. + assert_eq!( + remove_expired_within(&drive, expires_at, 128, 1).deleted_documents, + 1 + ); + assert_eq!(remove_expired(&drive, expires_at, 128).deleted_documents, 2); + assert!(!expiry_tree_exists(&drive, expires_at)); +} + +#[test] +fn should_estimate_a_change_without_the_deletion_its_creation_prepaid() { + // A replace keeps the entry and the deletion its creation prepaid, so its dry run must + // not count them: raising their price changes nothing. + let platform_version = PlatformVersion::latest(); + let (drive, contract) = setup(TWO_WEEKS_S); + let document = note(1, START_MS, "hello"); + insert(&drive, &contract, "note", &document, true); + let mut replaced = document.clone(); + replaced.set("text", Value::Text("a longer text than before".to_string())); + replaced.bump_revision(); + let estimate = |platform_version: &PlatformVersion| { + drive + .update_document_for_contract( + &replaced, + &contract, + contract.document_type_for_name("note").expect("type"), + Some(OWNER), + block_at(START_MS + 60_000), + false, + owner_flags(), + None, + platform_version, + None, + ) + .expect("the replace is estimated") + }; + let mut dearer_creation = platform_version.clone(); + dearer_creation + .fee_version + .document_ttl + .cleanup_base_processing_cost += 1_000_000_000; + dearer_creation + .fee_version + .document_ttl + .cleanup_processing_cost_per_index_level += 1_000_000_000; + assert_eq!(estimate(platform_version), estimate(&dearer_creation)); +} + +#[test] +fn should_prepay_the_deletion_of_the_bytes_a_change_adds() { + let platform_version = PlatformVersion::latest(); + let mut free_bytes = platform_version.clone(); + free_bytes + .fee_version + .document_ttl + .cleanup_processing_cost_per_document_byte = 0; + let document = note(1, START_MS, "hello"); + let mut replaced = document.clone(); + replaced.set("text", Value::Text("a longer text than before".to_string())); + replaced.bump_revision(); + let replace_fee = |platform_version: &PlatformVersion| { + let (drive, contract) = setup(TWO_WEEKS_S); + insert(&drive, &contract, "note", &document, true); + let note_type = contract.document_type_for_name("note").expect("type"); + let added_bytes = replaced + .serialize(note_type, &contract, platform_version) + .expect("serializes") + .len() + - document + .serialize(note_type, &contract, platform_version) + .expect("serializes") + .len(); + let fee = drive + .update_document_for_contract( + &replaced, + &contract, + note_type, + Some(OWNER), + block_at(START_MS + 60_000), + true, + owner_flags(), + None, + platform_version, + None, + ) + .expect("the replace runs"); + (fee, added_bytes as u64) + }; + let (fee, added_bytes) = replace_fee(platform_version); + let (without_prepay, _) = replace_fee(&free_bytes); + assert!(added_bytes > 0); + assert_eq!( + fee.processing_fee - without_prepay.processing_fee, + added_bytes + * platform_version + .fee_version + .document_ttl + .cleanup_processing_cost_per_document_byte + ); +} + +#[test] +fn should_price_a_short_lived_document_into_processing_and_prepay_its_deletion() { + let platform_version = PlatformVersion::latest(); + let (drive, contract) = setup(3_600); + let note_fee = insert(&drive, &contract, "note", ¬e(1, START_MS, "hello"), true); + let memo_fee = insert(&drive, &contract, "memo", ¬e(2, START_MS, "hello"), true); + + assert_eq!( + note_fee.storage_fee, 0, + "a lifetime under two epochs pays nothing into the storage pool" + ); + assert!(memo_fee.storage_fee > 0); + let note_type = contract.document_type_for_name("note").expect("type"); + let note_bytes = note(1, START_MS, "hello") + .serialize(note_type, &contract, platform_version) + .expect("serializes") + .len() as u64; + let cleanup_fee = + document_expiration_cleanup_fee(note_type, note_bytes, &platform_version.fee_version) + .expect("fee"); + let schedule = &platform_version.fee_version.document_ttl; + assert_eq!( + cleanup_fee, + schedule.cleanup_base_processing_cost + + 2 * schedule.cleanup_processing_cost_per_index_level + + note_bytes * schedule.cleanup_processing_cost_per_document_byte, + "two single-property indexes are two index levels, plus the document's bytes" + ); + // The same create under a schedule whose deletion costs nothing: the difference is the + // prepaid deletion, exactly. + let mut free_deletion = platform_version.clone(); + free_deletion + .fee_version + .document_ttl + .cleanup_base_processing_cost = 0; + free_deletion + .fee_version + .document_ttl + .cleanup_processing_cost_per_index_level = 0; + free_deletion + .fee_version + .document_ttl + .cleanup_processing_cost_per_document_byte = 0; + let (other_drive, other_contract) = setup(3_600); + let without_prepay = insert_at_version( + &other_drive, + &other_contract, + "note", + ¬e(1, START_MS, "hello"), + true, + &free_deletion, + ); + assert_eq!( + note_fee.processing_fee - without_prepay.processing_fee, + cleanup_fee, + "the processing fee carries the prepaid deletion" + ); + assert!( + note_fee.total_base_fee() < memo_fee.total_base_fee(), + "an hour of storage costs less than perpetual storage" + ); +} + +#[test] +fn should_price_a_long_lived_document_into_the_storage_pool() { + let (drive, contract) = setup(31_536_000); + let note_fee = insert(&drive, &contract, "note", ¬e(1, START_MS, "hello"), true); + let memo_fee = insert(&drive, &contract, "memo", ¬e(2, START_MS, "hello"), true); + let per_period = PlatformVersion::latest() + .fee_version + .document_ttl + .credit_per_byte_per_period; + // A year of 365 days is exactly 40 pricing periods of 9.125 days. + assert!(note_fee.storage_fee > 0); + assert_eq!(note_fee.storage_fee % (40 * per_period), 0); + assert!(note_fee.storage_fee < memo_fee.storage_fee); +} + +#[test] +fn should_estimate_at_least_what_a_document_with_a_time_to_live_costs() { + for ttl in [3_600, TWO_WEEKS_S, 31_536_000] { + let (drive, contract) = setup(ttl); + let document = note(1, START_MS, "hello"); + let estimated = insert(&drive, &contract, "note", &document, false); + let actual = insert(&drive, &contract, "note", &document, true); + assert!( + estimated.storage_fee >= actual.storage_fee, + "ttl {ttl}: estimated storage {} below actual {}", + estimated.storage_fee, + actual.storage_fee + ); + assert!( + estimated.total_base_fee() >= actual.total_base_fee(), + "ttl {ttl}: estimated {} below actual {}", + estimated.total_base_fee(), + actual.total_base_fee() + ); + } +} + +#[test] +fn should_estimate_at_least_what_replacing_a_document_with_a_time_to_live_costs() { + let platform_version = PlatformVersion::latest(); + for ttl in [3_600, TWO_WEEKS_S, 31_536_000] { + let (drive, contract) = setup(ttl); + let document = note(1, START_MS, "hello"); + insert(&drive, &contract, "note", &document, true); + let mut replaced = document.clone(); + replaced.set("text", Value::Text("a longer text than before".to_string())); + replaced.bump_revision(); + let replace = |apply: bool| { + drive + .update_document_for_contract( + &replaced, + &contract, + contract.document_type_for_name("note").expect("type"), + Some(OWNER), + block_at(START_MS + 60_000), + apply, + owner_flags(), + None, + platform_version, + None, + ) + .expect("the replace runs") + }; + let estimated = replace(false); + let actual = replace(true); + assert!( + estimated.total_base_fee() >= actual.total_base_fee(), + "ttl {ttl}: estimated {} below actual {}", + estimated.total_base_fee(), + actual.total_base_fee() + ); + } +} diff --git a/packages/rs-drive/src/drive/document/expiration/fetch_expired_documents/mod.rs b/packages/rs-drive/src/drive/document/expiration/fetch_expired_documents/mod.rs new file mode 100644 index 00000000000..7164e502fbc --- /dev/null +++ b/packages/rs-drive/src/drive/document/expiration/fetch_expired_documents/mod.rs @@ -0,0 +1,69 @@ +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::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +/// A document whose time to live has passed, as its expirations tree entry records it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExpiredDocument { + /// When the document expired + pub expires_at_ms: TimestampMillis, + /// The document's id + pub document_id: Identifier, + /// The document's contract + pub contract_id: Identifier, + /// The name of the document's type + pub document_type_name: String, +} + +impl Drive { + /// Reads the documents whose time to live has passed at `block_time_ms` from the documents + /// expirations tree: oldest expiry time first, then by id, at most `limit` of them. Every + /// tree of an expiry time holds at least one entry (the last entry removed takes its tree + /// with it), so the read visits at most `limit` trees. + /// + /// # Parameters + /// - `block_time_ms`: documents expiring at or before this time have expired. + /// - `limit`: the most documents to read. + /// - `transaction`: the transaction to read in. + /// - `drive_operations`: receives the costs of the read. + /// - `platform_version`: selects the method version. + /// + /// # Returns + /// The expired documents, oldest first. + pub fn fetch_expired_documents( + &self, + block_time_ms: TimestampMillis, + limit: u16, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .document + .expiration + .fetch_expired_documents + { + 0 => self.fetch_expired_documents_v0( + block_time_ms, + limit, + transaction, + drive_operations, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_expired_documents".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/document/expiration/fetch_expired_documents/v0/mod.rs b/packages/rs-drive/src/drive/document/expiration/fetch_expired_documents/v0/mod.rs new file mode 100644 index 00000000000..db5728ae7a0 --- /dev/null +++ b/packages/rs-drive/src/drive/document/expiration/fetch_expired_documents/v0/mod.rs @@ -0,0 +1,94 @@ +use crate::drive::document::expiration::paths::{ + decode_expiration_time, documents_expirations_path_vec, encode_expiration_time, +}; +use crate::drive::document::expiration::{DocumentExpirationEntry, ExpiredDocument}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::query::GroveError; +use dpp::identifier::Identifier; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use grovedb::query_result_type::QueryResultType; +use grovedb::{Element, PathQuery, Query, QueryItem, SizedQuery, TransactionArg}; + +impl Drive { + #[inline(always)] + pub(super) fn fetch_expired_documents_v0( + &self, + block_time_ms: TimestampMillis, + limit: u16, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if limit == 0 { + return Ok(vec![]); + } + + // The expiry times that have passed, oldest first, and the entries under each. + let mut times_query = Query::new_single_query_item(QueryItem::RangeToInclusive( + ..=encode_expiration_time(block_time_ms).to_vec(), + )); + times_query.set_subquery(Query::new_range_full()); + let path_query = PathQuery::new( + documents_expirations_path_vec(), + SizedQuery::new(times_query, Some(limit), None), + ); + let entries = match self.grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryPathKeyElementTrioResultType, + drive_operations, + &platform_version.drive, + ) { + Ok((entries, _)) => entries, + // The tree is created with the initial state and on the first block of protocol + // version 14; without it nothing has expired. + Err(Error::GroveDB(e)) + if matches!( + e.as_ref(), + GroveError::PathKeyNotFound(_) + | GroveError::PathNotFound(_) + | GroveError::PathParentLayerNotFound(_) + ) => + { + return Ok(vec![]); + } + Err(e) => return Err(e), + }; + + entries + .to_path_key_elements() + .into_iter() + .map(|(path, document_id, element)| { + let expires_at_ms = path + .last() + .and_then(|time_key| decode_expiration_time(time_key)) + .ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState( + "a documents expirations tree key must be an 8 byte time".to_string(), + )) + })?; + let Element::Item(entry_bytes, _) = element else { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "a document expiration entry must be an item".to_string(), + ))); + }; + let entry = DocumentExpirationEntry::from_bytes(&entry_bytes)?; + let document_id = Identifier::from_bytes(&document_id).map_err(|_| { + Error::Drive(DriveError::CorruptedDriveState( + "a document expiration entry must be keyed by a 32 byte id".to_string(), + )) + })?; + Ok(ExpiredDocument { + expires_at_ms, + document_id, + contract_id: entry.contract_id, + document_type_name: entry.document_type_name, + }) + }) + .collect() + } +} diff --git a/packages/rs-drive/src/drive/document/expiration/insert_documents_expirations_tree/mod.rs b/packages/rs-drive/src/drive/document/expiration/insert_documents_expirations_tree/mod.rs new file mode 100644 index 00000000000..78809da6e0b --- /dev/null +++ b/packages/rs-drive/src/drive/document/expiration/insert_documents_expirations_tree/mod.rs @@ -0,0 +1,42 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Creates the documents expirations tree under `Misc` if it does not exist yet. + /// + /// Called when the initial state structure of protocol version 14 is created and on the + /// first block of protocol version 14, so a new chain and an upgraded one hold the same + /// tree. + /// + /// # Parameters + /// - `transaction`: the transaction to write in. + /// - `platform_version`: selects the method version. + /// + /// # Returns + /// `Ok(())` once the tree exists. + pub fn insert_documents_expirations_tree( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .document + .expiration + .insert_documents_expirations_tree + { + 0 => self.insert_documents_expirations_tree_v0(transaction, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "insert_documents_expirations_tree".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/document/expiration/insert_documents_expirations_tree/v0/mod.rs b/packages/rs-drive/src/drive/document/expiration/insert_documents_expirations_tree/v0/mod.rs new file mode 100644 index 00000000000..e7618affb49 --- /dev/null +++ b/packages/rs-drive/src/drive/document/expiration/insert_documents_expirations_tree/v0/mod.rs @@ -0,0 +1,26 @@ +use crate::drive::document::expiration::paths::DOCUMENTS_EXPIRATIONS_KEY; +use crate::drive::system::misc_path; +use crate::drive::Drive; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::{Element, TransactionArg}; + +impl Drive { + #[inline(always)] + pub(super) fn insert_documents_expirations_tree_v0( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + // No storage flags: the tree is system structure, and every entry under it is flagless. + self.grove_insert_if_not_exists( + (&misc_path()).into(), + DOCUMENTS_EXPIRATIONS_KEY, + Element::empty_tree(), + transaction, + None, + &platform_version.drive, + )?; + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/document/expiration/mod.rs b/packages/rs-drive/src/drive/document/expiration/mod.rs new file mode 100644 index 00000000000..49f1544d1e5 --- /dev/null +++ b/packages/rs-drive/src/drive/document/expiration/mod.rs @@ -0,0 +1,114 @@ +//! Document expiry (protocol version 14): documents of a type declaring a `ttl`. +//! +//! Every such document gets an entry in the documents expirations tree under `Misc` when it +//! is created, keyed by the time it expires (`$createdAt` plus the time to live) and its id: +//! +//! ```text +//! Misc / E / / -> contract id ++ document type name +//! ``` +//! +//! After each block's state transitions the platform deletes up to +//! `max_document_expirations_per_block` expired documents, oldest first +//! ([`Drive::remove_expired_documents`]). Deleting such a document any other way (its owner, +//! a moderator) removes its entry in the same batch, so an entry exists exactly while its +//! document does. The last entry of an expiry time takes the tree of that time with it, so +//! every tree of an expiry time holds at least one entry. +//! +//! A document of such a type is written without storage flags and refunds nothing when it +//! goes. Its bytes, its index entries' and its expiration entry's, are priced for the time +//! they will live by the fee schedule's `document_ttl` group ([`pricing`]), and its creation +//! prepays its deletion as processing. +//! +//! [`Drive::remove_expired_documents`]: crate::drive::Drive::remove_expired_documents + +mod add_document_expiration_operations; +mod add_estimation_costs_for_document_expiration; +mod fetch_expired_documents; +mod insert_documents_expirations_tree; +/// Paths of the documents expirations tree +pub mod paths; +/// Prices of the bytes and the deletion of documents with a time to live +pub mod pricing; +mod remove_document_expiration_operations; +mod remove_expired_documents; + +pub use fetch_expired_documents::ExpiredDocument; +pub use remove_expired_documents::RemovedExpiredDocuments; + +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::identifier::Identifier; + +/// What an entry of the documents expirations tree stores: where the document is. Its id is +/// the entry's key, the time it expires the key of the tree holding the entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentExpirationEntry { + /// The contract of the document + pub contract_id: Identifier, + /// The name of the document's type + pub document_type_name: String, +} + +impl DocumentExpirationEntry { + /// The stored form: the 32 bytes of the contract id, then the document type name. + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity(32 + self.document_type_name.len()); + bytes.extend_from_slice(self.contract_id.as_slice()); + bytes.extend_from_slice(self.document_type_name.as_bytes()); + bytes + } + + /// The size of the stored form of an entry for a document type of this name. + pub fn serialized_size(document_type_name: &str) -> u32 { + 32 + document_type_name.len() as u32 + } + + /// Reads the stored form back. + pub fn from_bytes(bytes: &[u8]) -> Result { + let Some((contract_id, name)) = bytes.split_first_chunk::<32>() else { + return Err(Error::Drive(DriveError::CorruptedSerialization( + "a document expiration entry must start with a 32 byte contract id".to_string(), + ))); + }; + let document_type_name = String::from_utf8(name.to_vec()).map_err(|_| { + Error::Drive(DriveError::CorruptedSerialization( + "the document type name of a document expiration entry must be UTF-8".to_string(), + )) + })?; + Ok(DocumentExpirationEntry { + contract_id: Identifier::new(*contract_id), + document_type_name, + }) + } +} + +#[cfg(test)] +mod tests { + use super::DocumentExpirationEntry; + use dpp::identifier::Identifier; + + #[test] + fn should_round_trip_an_expiration_entry() { + let entry = DocumentExpirationEntry { + contract_id: Identifier::new([7; 32]), + document_type_name: "note".to_string(), + }; + let bytes = entry.to_bytes(); + assert_eq!( + bytes.len() as u32, + DocumentExpirationEntry::serialized_size("note") + ); + assert_eq!( + DocumentExpirationEntry::from_bytes(&bytes).expect("decodes"), + entry + ); + } + + #[test] + fn should_refuse_an_entry_shorter_than_a_contract_id() { + assert!(DocumentExpirationEntry::from_bytes(&[1; 31]).is_err()); + } +} + +#[cfg(test)] +mod expiration_tests; diff --git a/packages/rs-drive/src/drive/document/expiration/paths.rs b/packages/rs-drive/src/drive/document/expiration/paths.rs new file mode 100644 index 00000000000..ca5288a498a --- /dev/null +++ b/packages/rs-drive/src/drive/document/expiration/paths.rs @@ -0,0 +1,41 @@ +use crate::drive::RootTree; +use dpp::prelude::TimestampMillis; + +/// The key of the documents expirations tree under `Misc`. Protocol version 14. +pub const DOCUMENTS_EXPIRATIONS_KEY: &[u8; 1] = b"E"; + +/// The path of the documents expirations tree +pub fn documents_expirations_path() -> [&'static [u8]; 2] { + [ + Into::<&[u8; 1]>::into(RootTree::Misc), + DOCUMENTS_EXPIRATIONS_KEY, + ] +} + +/// The path of the documents expirations tree, as a vector +pub fn documents_expirations_path_vec() -> Vec> { + vec![ + Into::<&[u8; 1]>::into(RootTree::Misc).to_vec(), + DOCUMENTS_EXPIRATIONS_KEY.to_vec(), + ] +} + +/// The key of the tree holding the documents that expire at `expires_at_ms`: the time as a +/// big endian u64, so the trees sort by time. +pub fn encode_expiration_time(expires_at_ms: TimestampMillis) -> [u8; 8] { + expires_at_ms.to_be_bytes() +} + +/// The time a key of the documents expirations tree stands for, `None` if it is not one. +pub fn decode_expiration_time(key: &[u8]) -> Option { + Some(TimestampMillis::from_be_bytes(key.try_into().ok()?)) +} + +/// The path of the tree holding the documents that expire at `expires_at_ms` +pub fn documents_expirations_at_time_path_vec(expires_at_ms: TimestampMillis) -> Vec> { + vec![ + Into::<&[u8; 1]>::into(RootTree::Misc).to_vec(), + DOCUMENTS_EXPIRATIONS_KEY.to_vec(), + encode_expiration_time(expires_at_ms).to_vec(), + ] +} diff --git a/packages/rs-drive/src/drive/document/expiration/pricing.rs b/packages/rs-drive/src/drive/document/expiration/pricing.rs new file mode 100644 index 00000000000..84295ce6def --- /dev/null +++ b/packages/rs-drive/src/drive/document/expiration/pricing.rs @@ -0,0 +1,269 @@ +//! What a document whose type declares a `ttl` pays, from the fee schedule's `document_ttl` +//! group (see `FeeDocumentTtlVersion` for the schedule itself). + +use crate::error::drive::DriveError; +use crate::error::fee::FeeError; +use crate::error::Error; +use crate::fees::op::EphemeralPricing; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::fee::Credits; +use dpp::prelude::TimestampMillis; +use platform_version::version::fee::FeeVersion; + +/// How the bytes a document writes are priced when it has `remaining_lifetime_ms` left to +/// live: the first tier covering that lifetime, or past the last tier the schedule's price +/// per pricing period times the periods it spans, rounded up. A document of a type whose +/// `ttl_seconds` is shorter than the schedule's `processing_route_below_epochs` epochs of +/// `epoch_time_length_s` pays into the processing fees, one of a longer `ttl` into the +/// storage fee pool. +/// +/// The price never decreases with the lifetime, which keeps an estimate made at an earlier +/// block time (a longer remaining lifetime) an upper bound of the price at execution. The +/// route depends on the declared `ttl` alone, so the estimate and the execution take the +/// same one: the fee increase a writer offers multiplies processing only. +pub fn document_ttl_pricing( + remaining_lifetime_ms: u64, + ttl_seconds: u32, + epoch_time_length_s: u64, + fee_version: &FeeVersion, +) -> Result { + let schedule = &fee_version.document_ttl; + let tier_price = schedule + .tiers + .iter() + .find(|tier| remaining_lifetime_ms <= u64::from(tier.max_ttl_seconds) * 1000) + .map(|tier| tier.credit_per_byte); + let credit_per_byte = match tier_price { + Some(credit_per_byte) => credit_per_byte, + None => { + let period_ms = u64::from(schedule.pricing_period_seconds) * 1000; + if period_ms == 0 { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "the document ttl pricing period must be a positive number of seconds", + ))); + } + schedule + .credit_per_byte_per_period + .checked_mul(remaining_lifetime_ms.div_ceil(period_ms)) + .ok_or(Error::Fee(FeeError::Overflow( + "overflow pricing the periods a document with a time to live spans", + )))? + } + }; + let storage_pool_from_seconds = u64::from(schedule.processing_route_below_epochs) + .checked_mul(epoch_time_length_s) + .ok_or(Error::Fee(FeeError::Overflow( + "overflow computing the storage pool route of a document with a time to live", + )))?; + Ok(EphemeralPricing::DocumentTtl { + credit_per_byte, + storage_pool: u64::from(ttl_seconds) >= storage_pool_from_seconds, + }) +} + +/// When a document created at `created_at` expires under a time to live of `ttl_seconds`: +/// the one definition of a document's expiry, which its entry in the documents expirations +/// tree is keyed by and every expiry check reads. +pub fn document_expires_at( + created_at: TimestampMillis, + ttl_seconds: u32, +) -> Result { + created_at + .checked_add(u64::from(ttl_seconds) * 1000) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "a document's expiry time overflows", + ))) +} + +/// The lifetime a document has left at `block_time_ms`: from then to its expiry, zero once +/// past. A document written without a known creation time (a worst-case estimate) is priced +/// for the whole time to live, the most it can have left. +pub fn document_remaining_lifetime_ms( + created_at: Option, + ttl_seconds: u32, + block_time_ms: TimestampMillis, +) -> Result { + match created_at { + Some(created_at) => { + Ok(document_expires_at(created_at, ttl_seconds)?.saturating_sub(block_time_ms)) + } + None => Ok(u64::from(ttl_seconds) * 1000), + } +} + +/// The index levels a document of this type writes and its deletion removes: every index +/// counts its properties, and an index bucketing time into overlapping windows counts them +/// once per window holding the document. +pub fn document_type_weighted_index_levels(document_type: DocumentTypeRef) -> u64 { + document_type + .indexes() + .values() + .map(|index| { + let windows = index + .time_range + .as_ref() + .map_or(1, |transform| transform.overlap_factor().max(1)); + (index.properties.len() as u64).saturating_mul(windows) + }) + .fold(0u64, u64::saturating_add) +} + +/// The processing a document of this type, `document_bytes` long when stored, prepays for its +/// deletion when it is created: the schedule's base cost, its cost per index level of the +/// type, and its cost per document byte. +pub fn document_expiration_cleanup_fee( + document_type: DocumentTypeRef, + document_bytes: u64, + fee_version: &FeeVersion, +) -> Result { + let schedule = &fee_version.document_ttl; + schedule + .cleanup_processing_cost_per_index_level + .checked_mul(document_type_weighted_index_levels(document_type)) + .and_then(|levels_cost| levels_cost.checked_add(schedule.cleanup_base_processing_cost)) + .ok_or(Error::Fee(FeeError::Overflow( + "overflow pricing the deletion of a document with a time to live", + )))? + .checked_add(document_expiration_cleanup_fee_for_bytes( + document_bytes, + fee_version, + )?) + .ok_or(Error::Fee(FeeError::Overflow( + "overflow pricing the deletion of a document with a time to live", + ))) +} + +/// The processing the deletion of `document_bytes` bytes of a document costs, prepaid for +/// the whole document when it is created and for the bytes a change adds to it. +pub fn document_expiration_cleanup_fee_for_bytes( + document_bytes: u64, + fee_version: &FeeVersion, +) -> Result { + fee_version + .document_ttl + .cleanup_processing_cost_per_document_byte + .checked_mul(document_bytes) + .ok_or(Error::Fee(FeeError::Overflow( + "overflow pricing the deletion of the bytes of a document with a time to live", + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + use platform_version::version::PlatformVersion; + + const MAINNET_EPOCH_S: u64 = 788_400; + const TESTNET_EPOCH_S: u64 = 3_600; + const HOUR_MS: u64 = 3_600_000; + const DAY_MS: u64 = 86_400_000; + const WEEK_S: u32 = 604_800; + + fn price_on(lifetime_ms: u64, ttl_seconds: u32, epoch_s: u64) -> (Credits, bool) { + match document_ttl_pricing( + lifetime_ms, + ttl_seconds, + epoch_s, + &PlatformVersion::latest().fee_version, + ) + .expect("prices") + { + EphemeralPricing::DocumentTtl { + credit_per_byte, + storage_pool, + } => (credit_per_byte, storage_pool), + other => panic!("expected a document ttl price, got {other:?}"), + } + } + + /// The price of a document created now: its whole time to live is left. + fn price(lifetime_ms: u64) -> (Credits, bool) { + let ttl_seconds = u32::try_from(lifetime_ms.div_ceil(1000)).expect("fits"); + price_on(lifetime_ms, ttl_seconds, MAINNET_EPOCH_S) + } + + #[test] + fn should_price_each_short_lifetime_by_its_tier() { + let tiers = PlatformVersion::latest().fee_version.document_ttl.tiers; + assert_eq!(price(1), (tiers[0].credit_per_byte, false)); + assert_eq!(price(HOUR_MS), (tiers[0].credit_per_byte, false)); + assert_eq!(price(HOUR_MS + 1), (tiers[1].credit_per_byte, false)); + assert_eq!(price(DAY_MS), (tiers[1].credit_per_byte, false)); + assert_eq!(price(DAY_MS + 1), (tiers[2].credit_per_byte, false)); + assert_eq!(price(2 * DAY_MS), (tiers[2].credit_per_byte, false)); + assert_eq!(price(2 * DAY_MS + 1), (tiers[3].credit_per_byte, false)); + assert_eq!(price(4 * DAY_MS), (tiers[3].credit_per_byte, false)); + assert_eq!(price(4 * DAY_MS + 1), (tiers[4].credit_per_byte, false)); + assert_eq!(price(7 * DAY_MS), (tiers[4].credit_per_byte, false)); + } + + #[test] + fn should_price_longer_lifetimes_by_the_periods_they_span_rounded_up() { + let schedule = &PlatformVersion::latest().fee_version.document_ttl; + let per_period = schedule.credit_per_byte_per_period; + let period_ms = u64::from(schedule.pricing_period_seconds) * 1000; + // Past seven days but inside the first period: one period. + assert_eq!(price(7 * DAY_MS + 1), (per_period, false)); + assert_eq!(price(period_ms), (per_period, false)); + assert_eq!(price(period_ms + 1), (2 * per_period, false)); + assert_eq!(price(365 * DAY_MS), (40 * per_period, true)); + } + + #[test] + fn should_route_by_the_declared_time_to_live_in_epochs() { + let epoch_s = u32::try_from(MAINNET_EPOCH_S).expect("fits"); + // A type of two epochs or more pays into the storage fee pool, whatever is left. + assert!(price_on(1, 2 * epoch_s, MAINNET_EPOCH_S).1); + assert!(price_on(u64::from(2 * epoch_s) * 1000, 2 * epoch_s, MAINNET_EPOCH_S).1); + // A shorter type pays into the processing fees. + assert!(!price_on(1, 2 * epoch_s - 1, MAINNET_EPOCH_S).1); + // The network's epochs decide the route: two hours spans two testnet epochs. + assert!(price_on(HOUR_MS, 7_200, TESTNET_EPOCH_S).1); + assert!(!price_on(HOUR_MS, 7_200, MAINNET_EPOCH_S).1); + } + + #[test] + fn should_price_a_lifetime_alike_whatever_the_epoch_length() { + // The price per byte comes from the schedule's own period: a network of one-hour + // epochs prices a year like mainnet does. + for lifetime_ms in [HOUR_MS, 3 * DAY_MS, 8 * DAY_MS, 30 * DAY_MS, 365 * DAY_MS] { + let ttl_seconds = u32::try_from(lifetime_ms / 1000).expect("fits"); + assert_eq!( + price_on(lifetime_ms, ttl_seconds, TESTNET_EPOCH_S).0, + price_on(lifetime_ms, ttl_seconds, MAINNET_EPOCH_S).0, + "{lifetime_ms} ms" + ); + } + } + + #[test] + fn should_never_price_a_longer_lifetime_below_a_shorter_one() { + let mut previous = 0; + for lifetime_ms in (0..=400 * DAY_MS).step_by((HOUR_MS / 2) as usize) { + let (credit_per_byte, _) = price_on(lifetime_ms, WEEK_S, MAINNET_EPOCH_S); + assert!( + credit_per_byte >= previous, + "{lifetime_ms} ms costs {credit_per_byte}, less than {previous}" + ); + previous = credit_per_byte; + } + } + + #[test] + fn should_count_the_remaining_lifetime_from_creation() { + let remaining = |created_at, block_time_ms| { + document_remaining_lifetime_ms(created_at, 10, block_time_ms).expect("fits") + }; + assert_eq!(remaining(Some(1_000), 1_000), 10_000); + assert_eq!(remaining(Some(1_000), 6_000), 5_000); + assert_eq!(remaining(Some(1_000), 60_000), 0); + assert_eq!(remaining(None, 60_000), 10_000); + } + + #[test] + fn should_refuse_an_expiry_past_the_end_of_time() { + assert!(document_expires_at(u64::MAX, 1).is_err()); + assert!(document_remaining_lifetime_ms(Some(u64::MAX), 1, 0).is_err()); + } +} diff --git a/packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/mod.rs b/packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/mod.rs new file mode 100644 index 00000000000..49f15f362a7 --- /dev/null +++ b/packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/mod.rs @@ -0,0 +1,70 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Gathers the operations removing a document's entry from the documents expirations + /// tree, and the tree of its expiry time with it when that was its last entry: no tree of + /// an expiry time is ever left empty, so every one the cleanup reads holds a document. + /// Entries the rest of the batch removes or adds under the same time count. + /// + /// # Parameters + /// - `document_id`: the document's id. + /// - `expires_at_ms`: when the document expires, the key of the tree holding its entry. + /// - `entry_value_size`: the size of the entry's value, for a dry run. + /// - `estimated_costs_only_with_layer_info`: set in a dry run, which prices the removal + /// of the tree too. + /// - `check_existing_operations`: the operations of the rest of the batch. + /// - `transaction`: the transaction to read in. + /// - `batch_operations`: receives the operations. + /// - `platform_version`: selects the method version. + /// + /// # Returns + /// `Ok(())` once the operations are queued. + #[allow(clippy::too_many_arguments)] + pub(crate) fn remove_document_expiration_operations( + &self, + document_id: [u8; 32], + expires_at_ms: TimestampMillis, + entry_value_size: u32, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + check_existing_operations: &Option<&mut Vec>, + transaction: TransactionArg, + batch_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .document + .expiration + .remove_document_expiration_operations + { + 0 => self.remove_document_expiration_operations_v0( + document_id, + expires_at_ms, + entry_value_size, + estimated_costs_only_with_layer_info, + check_existing_operations, + transaction, + batch_operations, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "remove_document_expiration_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/v0/mod.rs new file mode 100644 index 00000000000..1224ca46379 --- /dev/null +++ b/packages/rs-drive/src/drive/document/expiration/remove_document_expiration_operations/v0/mod.rs @@ -0,0 +1,92 @@ +use crate::drive::document::expiration::paths::documents_expirations_at_time_path_vec; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchDeleteUpTreeApplyType; +use crate::util::type_constants::{DEFAULT_HASH_SIZE_U8, U64_SIZE_U8}; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerCount::ApproximateElements; +use grovedb::EstimatedLayerSizes::{AllItems, AllSubtrees}; +use grovedb::EstimatedSumTrees::NoSumTrees; +use grovedb::{EstimatedLayerInformation, MaybeTree, TransactionArg, TreeType}; +use intmap::IntMap; +use std::collections::HashMap; + +/// Where the removal stops climbing: it removes the entry (a key of `Misc / E / (app: &A) -> Result<(), Error> where A: BlockExecutionApplication, { - let block_execution_context_guard = app.block_execution_context().read().unwrap(); + let block_execution_context_guard = app + .block_execution_context() + .read() + .expect("poisoned only after a panic, which stops the node"); let block_execution_context = block_execution_context_guard .as_ref() @@ -128,14 +131,14 @@ where app.unsigned_withdrawal_txs_by_round() .write() - .unwrap() + .expect("poisoned only after a panic, which stops the node") .insert( block_state_info.height(), block_state_info.round(), block_hash, block_execution_context .unsigned_withdrawal_transactions() - .clone(), + .into(), ); Ok(()) diff --git a/packages/rs-drive-abci/src/abci/handler/verify_vote_extension.rs b/packages/rs-drive-abci/src/abci/handler/verify_vote_extension.rs index e9ebea4ec31..ce15941e3ae 100644 --- a/packages/rs-drive-abci/src/abci/handler/verify_vote_extension.rs +++ b/packages/rs-drive-abci/src/abci/handler/verify_vote_extension.rs @@ -3,7 +3,6 @@ use crate::error::Error; use crate::rpc::core::CoreRPCLike; use tenderdash_abci::proto::abci as proto; use tenderdash_abci::proto::abci::response_verify_vote_extension::VerifyStatus; -use tenderdash_abci::proto::abci::ExtendVoteExtension; /// Verifies that another validator's precommit asks for signatures on exactly the withdrawal /// transactions this node built for the same block. @@ -31,25 +30,21 @@ where let height: u64 = height as u64; let round: u32 = round as u32; - // Each round of a height has its own proposal, and its withdrawal transactions carry that - // proposal's chain-locked core height as their request height. A later round whose proposer - // saw a newer chain lock asks validators to sign different transactions, so a vote is - // compared with what we built for the block it is for, never with another round's. + // A vote is compared with what we built for the block it is for, never with the last + // proposal processed (see `UnsignedWithdrawalTxsByRound`). let withdrawals_by_round = app.unsigned_withdrawal_txs_by_round().read().unwrap(); - let Some(expected_withdrawals) = withdrawals_by_round.get(height, round, &hash) else { + let Some(expected_extensions) = withdrawals_by_round.get(height, round, &hash) else { // We have not accepted the block this vote is for: its proposal has not reached us yet, - // or it belongs to another height. We reject it, because nothing else we could check - // tells an honest vote from one a relaying peer altered. The block signature does not - // cover vote extensions, and ours carry a sign request id that binds them to neither - // height nor round, so any peer can drop some or all of a precommit's extensions, or - // swap in the same validator's extensions from another round, and the vote still - // verifies. Counting such votes could let extensions other than the block's reach the - // recovery threshold, and the commit they form would then fail in `finalize_block`. + // or it belongs to another height. The block signature does not cover vote extensions, + // and ours carry a sign request id that binds them to neither height nor round, so any + // peer can drop some or all of a precommit's extensions, or swap in the same validator's + // extensions from another round, and the vote still verifies. Counting such votes could + // let extensions other than the block's reach the recovery threshold, and the commit + // they form would then fail in `finalize_block`. // - // A dropped vote is not lost for good: a peer that learns we lack it can send it again - // once we have accepted the block, and a node that falls behind catches up through the - // commit. + // A rejected vote is sent again only while we stay in its round and a peer holds +2/3 + // precommits for one block. Otherwise this node catches up once the others commit. tracing::debug!( block_hash = hex::encode(&hash), "votes extensions for height: {}, round: {} are rejected because we have not accepted a proposal for that block", @@ -62,9 +57,7 @@ where }); }; - if expected_withdrawals != vote_extensions.as_slice() { - let expected_extensions: Vec = expected_withdrawals.into(); - + if expected_extensions != vote_extensions.as_slice() { tracing::error!( received_extensions = ?vote_extensions, ?expected_extensions, @@ -97,6 +90,7 @@ mod tests { use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; use crate::test::helpers::withdrawals::unsigned_withdrawal_transactions; + use tenderdash_abci::proto::abci::ExtendVoteExtension; const HEIGHT: u64 = 10; const ROUND_0_BLOCK: [u8; 32] = [0xA0; 32]; @@ -147,12 +141,7 @@ mod tests { app.unsigned_withdrawal_txs_by_round .write() .unwrap() - .insert( - HEIGHT, - 0, - ROUND_0_BLOCK, - unsigned_withdrawal_transactions(ROUND_0_CORE_HEIGHT), - ); + .insert(HEIGHT, 0, ROUND_0_BLOCK, round_0_extensions()); } /// Round 1 accepted at `HEIGHT`, at the newer `ROUND_1_CORE_HEIGHT` @@ -160,17 +149,12 @@ mod tests { app.unsigned_withdrawal_txs_by_round .write() .unwrap() - .insert( - HEIGHT, - 1, - ROUND_1_BLOCK, - unsigned_withdrawal_transactions(ROUND_1_CORE_HEIGHT), - ); + .insert(HEIGHT, 1, ROUND_1_BLOCK, round_1_extensions()); } /// Round 1 was proposed at a newer chain-locked core height than round 0, and this node /// processed it last. A round 0 precommit carries round 0's withdrawal transactions and is - /// valid; it used to be compared with round 1's and rejected. + /// valid. #[test] fn should_accept_a_vote_for_an_earlier_round_at_an_older_core_height() { let platform = platform(); @@ -259,7 +243,7 @@ mod tests { app.unsigned_withdrawal_txs_by_round .write() .unwrap() - .insert(HEIGHT, 0, ROUND_0_BLOCK, UnsignedWithdrawalTxs::default()); + .insert(HEIGHT, 0, ROUND_0_BLOCK, vec![]); assert_eq!( verify(&app, HEIGHT, 0, ROUND_0_BLOCK, vec![]), @@ -271,10 +255,8 @@ mod tests { ); } - /// Only round 0 is accepted. Nothing tells an honest round 1 vote from one whose - /// extensions a relaying peer stripped, or replaced with the same validator's round 0 - /// extensions, whose signatures are bound to neither height nor round. The last case - /// matches the only proposal this node processed, and used to be accepted. + /// Only round 0 is accepted. Nothing tells an honest round 1 vote from one whose extensions + /// a relaying peer stripped or replaced with the same validator's round 0 extensions. #[test] fn should_reject_a_vote_for_a_round_this_node_has_not_accepted() { let platform = platform(); @@ -331,4 +313,39 @@ mod tests { ); } } + + /// A block this node accepted in round 0, re-proposed and voted for in round 1, asks for the + /// same signatures. + #[test] + fn should_accept_a_vote_for_a_block_accepted_in_another_round() { + let platform = platform(); + let app = FullAbciApplication::::new(&platform.platform); + accept_round_0(&app); + + assert_eq!( + verify(&app, HEIGHT, 1, ROUND_0_BLOCK, round_0_extensions()), + VerifyStatus::Accept as i32 + ); + assert_eq!( + verify(&app, HEIGHT, 1, ROUND_0_BLOCK, vec![]), + VerifyStatus::Reject as i32 + ); + } + + /// An empty vote for a block this node has not accepted is rejected too, even when the block + /// it accepted at the height has no withdrawals. + #[test] + fn should_reject_an_empty_vote_for_a_block_this_node_has_not_accepted() { + let platform = platform(); + let app = FullAbciApplication::::new(&platform.platform); + app.unsigned_withdrawal_txs_by_round + .write() + .unwrap() + .insert(HEIGHT, 0, ROUND_0_BLOCK, vec![]); + + assert_eq!( + verify(&app, HEIGHT, 1, ROUND_1_BLOCK, vec![]), + VerifyStatus::Reject as i32 + ); + } } diff --git a/packages/rs-drive-abci/src/platform_types/withdrawal/unsigned_withdrawal_txs_by_round.rs b/packages/rs-drive-abci/src/platform_types/withdrawal/unsigned_withdrawal_txs_by_round.rs index fcd373973b1..6b18623e7b5 100644 --- a/packages/rs-drive-abci/src/platform_types/withdrawal/unsigned_withdrawal_txs_by_round.rs +++ b/packages/rs-drive-abci/src/platform_types/withdrawal/unsigned_withdrawal_txs_by_round.rs @@ -1,23 +1,24 @@ //! The unsigned withdrawal transactions of every proposal accepted at the current height -use crate::platform_types::withdrawal::unsigned_withdrawal_txs::v0::UnsignedWithdrawalTxs; use std::collections::BTreeMap; +use tenderdash_abci::proto::abci::ExtendVoteExtension; -/// The unsigned withdrawal transactions of one accepted proposal +/// The vote extensions validators sign for the withdrawal transactions of one accepted proposal #[derive(Debug, Clone)] struct AcceptedProposalWithdrawals { block_hash: [u8; 32], - transactions: UnsignedWithdrawalTxs, + vote_extensions: Vec, } /// The unsigned withdrawal transactions of every proposal this node accepted at the current -/// height, by round. +/// height, by round, kept as the vote extensions validators sign for them. /// /// A withdrawal transaction carries the chain-locked core height of the proposal that built it /// as its request height, so two rounds of one height whose proposers saw different chain locks /// ask validators to sign different transactions. A vote extension is verified against the -/// proposal of its own round, which the block execution context alone cannot give: it only -/// holds the last proposal processed. +/// block it is for, which the block execution context alone cannot give: it only holds the last +/// proposal processed. A block's withdrawal transactions do not depend on the round it is +/// proposed in, so a block re-proposed in a later round asks for the same signatures. /// /// This is node memory, not consensus state. #[derive(Debug, Default, Clone)] @@ -27,15 +28,14 @@ pub struct UnsignedWithdrawalTxsByRound { } impl UnsignedWithdrawalTxsByRound { - /// Keeps the withdrawal transactions of the block `block_hash`, accepted at `height` and - /// `round`, in place of any block kept for that round. Blocks of another height are - /// forgotten. + /// Keeps the vote extensions of the block `block_hash`, accepted at `height` and `round`, in + /// place of any block kept for that round. Blocks of another height are forgotten. pub fn insert( &mut self, height: u64, round: u32, block_hash: [u8; 32], - transactions: UnsignedWithdrawalTxs, + vote_extensions: Vec, ) { if self.height != height { self.rounds.clear(); @@ -46,32 +46,32 @@ impl UnsignedWithdrawalTxsByRound { round, AcceptedProposalWithdrawals { block_hash, - transactions, + vote_extensions, }, ); } - /// The withdrawal transactions of the block `block_hash` at `height` and `round`, or `None` - /// when this node has not accepted that block. + /// The vote extensions of the block `block_hash` at `height`, as accepted at `round` or, when + /// this node accepted that block in another round, as accepted there. `None` when this node + /// has not accepted that block. pub fn get( &self, height: u64, round: u32, block_hash: &[u8], - ) -> Option<&UnsignedWithdrawalTxs> { + ) -> Option<&[ExtendVoteExtension]> { if self.height != height { return None; } + let is_block = + |proposal: &&AcceptedProposalWithdrawals| proposal.block_hash.as_slice() == block_hash; + self.rounds .get(&round) - .filter(|proposal| proposal.block_hash.as_slice() == block_hash) - .map(|proposal| &proposal.transactions) - } - - /// Forgets every block, once their height is finalized - pub fn clear(&mut self) { - self.rounds.clear(); + .filter(is_block) + .or_else(|| self.rounds.values().find(is_block)) + .map(|proposal| proposal.vote_extensions.as_slice()) } } @@ -79,55 +79,74 @@ impl UnsignedWithdrawalTxsByRound { mod tests { use super::*; use crate::test::helpers::withdrawals::unsigned_withdrawal_transactions; - use tenderdash_abci::proto::abci::ExtendVoteExtension; const ROUND_0_BLOCK: [u8; 32] = [0xA0; 32]; const ROUND_1_BLOCK: [u8; 32] = [0xA1; 32]; - fn extensions(transactions: &UnsignedWithdrawalTxs) -> Vec { - transactions.into() + fn extensions(core_height: u32) -> Vec { + (&unsigned_withdrawal_transactions(core_height)).into() } #[test] fn should_keep_the_withdrawals_of_each_round_apart() { - let round_0 = unsigned_withdrawal_transactions(1000); - let round_1 = unsigned_withdrawal_transactions(1001); - let mut by_round = UnsignedWithdrawalTxsByRound::default(); - by_round.insert(10, 0, ROUND_0_BLOCK, round_0.clone()); - by_round.insert(10, 1, ROUND_1_BLOCK, round_1.clone()); - - let kept_round_0 = by_round - .get(10, 0, &ROUND_0_BLOCK) - .expect("round 0 is kept after round 1"); - let kept_round_1 = by_round - .get(10, 1, &ROUND_1_BLOCK) - .expect("round 1 is kept"); - - assert_eq!(extensions(kept_round_0), extensions(&round_0)); - assert_eq!(extensions(kept_round_1), extensions(&round_1)); + by_round.insert(10, 0, ROUND_0_BLOCK, extensions(1000)); + by_round.insert(10, 1, ROUND_1_BLOCK, extensions(1001)); + + assert_eq!( + by_round.get(10, 0, &ROUND_0_BLOCK), + Some(extensions(1000).as_slice()), + "round 0 is kept after round 1" + ); + assert_eq!( + by_round.get(10, 1, &ROUND_1_BLOCK), + Some(extensions(1001).as_slice()) + ); assert_ne!( - extensions(kept_round_0), - extensions(kept_round_1), + extensions(1000), + extensions(1001), "test premise: the request height makes the two rounds' transactions differ" ); } #[test] - fn should_not_answer_for_a_block_or_round_it_did_not_accept() { + fn should_answer_for_a_block_accepted_in_another_round() { + let mut by_round = UnsignedWithdrawalTxsByRound::default(); + by_round.insert(10, 0, ROUND_0_BLOCK, extensions(1000)); + + assert_eq!( + by_round.get(10, 1, &ROUND_0_BLOCK), + Some(extensions(1000).as_slice()) + ); + } + + #[test] + fn should_prefer_the_block_accepted_in_the_same_round() { + let mut by_round = UnsignedWithdrawalTxsByRound::default(); + by_round.insert(10, 0, ROUND_0_BLOCK, extensions(1000)); + by_round.insert(10, 1, ROUND_0_BLOCK, extensions(1001)); + + assert_eq!( + by_round.get(10, 1, &ROUND_0_BLOCK), + Some(extensions(1001).as_slice()) + ); + } + + #[test] + fn should_not_answer_for_a_block_it_did_not_accept() { let mut by_round = UnsignedWithdrawalTxsByRound::default(); - by_round.insert(10, 0, ROUND_0_BLOCK, unsigned_withdrawal_transactions(1000)); + by_round.insert(10, 0, ROUND_0_BLOCK, extensions(1000)); assert!(by_round.get(10, 0, &ROUND_1_BLOCK).is_none()); - assert!(by_round.get(10, 1, &ROUND_0_BLOCK).is_none()); + assert!(by_round.get(10, 1, &ROUND_1_BLOCK).is_none()); assert!(by_round.get(11, 0, &ROUND_0_BLOCK).is_none()); } #[test] fn should_replace_the_block_kept_for_a_round() { let mut by_round = UnsignedWithdrawalTxsByRound::default(); - by_round.insert(10, 0, ROUND_0_BLOCK, unsigned_withdrawal_transactions(1000)); - by_round.insert(10, 0, ROUND_1_BLOCK, unsigned_withdrawal_transactions(1001)); + by_round.insert(10, 0, ROUND_0_BLOCK, extensions(1000)); + by_round.insert(10, 0, ROUND_1_BLOCK, extensions(1001)); assert!(by_round.get(10, 0, &ROUND_0_BLOCK).is_none()); assert!(by_round.get(10, 0, &ROUND_1_BLOCK).is_some()); @@ -136,23 +155,11 @@ mod tests { #[test] fn should_start_a_new_height_empty() { let mut by_round = UnsignedWithdrawalTxsByRound::default(); - by_round.insert(10, 0, ROUND_0_BLOCK, unsigned_withdrawal_transactions(1000)); - by_round.insert(11, 1, ROUND_1_BLOCK, unsigned_withdrawal_transactions(1001)); + by_round.insert(10, 0, ROUND_0_BLOCK, vec![]); + by_round.insert(11, 1, ROUND_1_BLOCK, extensions(1001)); assert!(by_round.get(10, 0, &ROUND_0_BLOCK).is_none()); assert!(by_round.get(11, 0, &ROUND_0_BLOCK).is_none()); assert!(by_round.get(11, 1, &ROUND_1_BLOCK).is_some()); } - - #[test] - fn should_forget_every_block_when_cleared() { - let mut by_round = UnsignedWithdrawalTxsByRound::default(); - by_round.insert(10, 0, ROUND_0_BLOCK, unsigned_withdrawal_transactions(1000)); - by_round.insert(10, 1, ROUND_1_BLOCK, unsigned_withdrawal_transactions(1001)); - - by_round.clear(); - - assert!(by_round.get(10, 0, &ROUND_0_BLOCK).is_none()); - assert!(by_round.get(10, 1, &ROUND_1_BLOCK).is_none()); - } } diff --git a/packages/rs-drive-abci/src/test/helpers/mod.rs b/packages/rs-drive-abci/src/test/helpers/mod.rs index 7b7e45e3ade..ebc1ad5982b 100644 --- a/packages/rs-drive-abci/src/test/helpers/mod.rs +++ b/packages/rs-drive-abci/src/test/helpers/mod.rs @@ -9,7 +9,6 @@ pub mod setup; #[cfg(test)] pub mod state_mutation_guard; /// Withdrawal fixtures -#[cfg(test)] pub mod withdrawals; // TODO: Move tests to appropriate place diff --git a/packages/rs-drive-abci/src/test/helpers/withdrawals.rs b/packages/rs-drive-abci/src/test/helpers/withdrawals.rs index 63872c0cd1d..bc8d9c3d4f1 100644 --- a/packages/rs-drive-abci/src/test/helpers/withdrawals.rs +++ b/packages/rs-drive-abci/src/test/helpers/withdrawals.rs @@ -8,30 +8,36 @@ use dpp::dashcore::transaction::special_transaction::asset_unlock::unqualified_a }; use dpp::dashcore::{QuorumHash, ScriptBuf, TxOut}; +/// The bytes of an untied withdrawal transaction with index `index`, as the withdrawal queue +/// holds it: 100_000 duffs paid out plus a 2_000 duff fee, so 102_000_000 credits. +pub fn untied_withdrawal_transaction_bytes(index: u64) -> Vec { + let untied_transaction = AssetUnlockBaseTransactionInfo { + version: 1, + lock_time: 0, + output: vec![TxOut { + value: 100_000, + script_pubkey: ScriptBuf::from_bytes(vec![0x51]), + }], + base_payload: AssetUnlockBasePayload { + version: 1, + index, + fee: 2_000, + }, + }; + + let mut untied_transaction_bytes = vec![]; + untied_transaction + .consensus_encode(&mut untied_transaction_bytes) + .expect("expected to encode an untied withdrawal transaction"); + + untied_transaction_bytes +} + /// Two unsigned withdrawal transactions, with indices 0 and 1, built the way a proposal at /// chain-locked core height `request_height` builds them. pub fn unsigned_withdrawal_transactions(request_height: u32) -> UnsignedWithdrawalTxs { let transactions = (0..2) .map(|index| { - let untied_transaction = AssetUnlockBaseTransactionInfo { - version: 1, - lock_time: 0, - output: vec![TxOut { - value: 100_000, - script_pubkey: ScriptBuf::from_bytes(vec![0x51]), - }], - base_payload: AssetUnlockBasePayload { - version: 1, - index, - fee: 2_000, - }, - }; - - let mut untied_transaction_bytes = vec![]; - untied_transaction - .consensus_encode(&mut untied_transaction_bytes) - .expect("expected to encode an untied withdrawal transaction"); - let request_info = AssetUnlockRequestInfo { request_height, quorum_hash: QuorumHash::from_byte_array([7u8; 32]), @@ -40,7 +46,7 @@ pub fn unsigned_withdrawal_transactions(request_height: u32) -> UnsignedWithdraw let mut unsigned_transaction_bytes = vec![]; request_info .consensus_append_to_base_encode( - untied_transaction_bytes, + untied_withdrawal_transaction_bytes(index), &mut unsigned_transaction_bytes, ) .expect("expected to append the request info"); diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs index d3e7ff67ab5..1b2d3d59453 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs @@ -1,30 +1,24 @@ //! Vote extensions of different rounds of one height. //! -//! Each round of a height has its own proposal, and the unsigned withdrawal transactions a -//! proposal asks validators to sign carry its chain-locked core height as their request height. -//! When a new chain lock arrives between two rounds, the later proposal asks for signatures on -//! different transactions. Precommits of the earlier round are still valid and can still arrive -//! after this node processed the later proposal, so each vote must be verified against the -//! proposal of its own round. A vote for a block this node has not accepted cannot be checked, -//! since a relaying peer can strip its extensions or swap in another round's, and is rejected. +//! When a new chain lock arrives between two rounds, the later proposal asks validators to sign +//! different withdrawal transactions. Precommits of the earlier round are still valid and can +//! still arrive after this node processed the later proposal, so each vote must be verified +//! against the block it is for. A vote for a block this node has not accepted is rejected. #[cfg(test)] mod tests { use crate::execution::run_chain_for_strategy; use crate::strategy::{ChainExecutionOutcome, NetworkStrategy}; use dpp::block::block_info::BlockInfo; use dpp::block::extended_block_info::v0::ExtendedBlockInfoV0Setters; - use dpp::dashcore::consensus::Encodable; use dpp::dashcore::hashes::Hash; - use dpp::dashcore::transaction::special_transaction::asset_unlock::unqualified_asset_unlock::{ - AssetUnlockBasePayload, AssetUnlockBaseTransactionInfo, - }; - use dpp::dashcore::{ScriptBuf, TxOut}; - use drive_abci::config::{ExecutionConfig, PlatformConfig, PlatformTestConfig}; + use drive_abci::config::{PlatformConfig, PlatformTestConfig}; + use drive_abci::platform_types::platform::Platform; use drive_abci::platform_types::platform_state::PlatformStateV0Methods; + use drive_abci::rpc::core::MockCoreRPCLike; use drive_abci::test::helpers::setup::TestPlatformBuilder; + use drive_abci::test::helpers::withdrawals::untied_withdrawal_transaction_bytes; use platform_version::version::PlatformVersion; use std::sync::Arc; - use strategy_tests::{IdentityInsertInfo, StartAddresses, StartIdentities, Strategy}; use tenderdash_abci::proto::abci::response_process_proposal::ProposalStatus; use tenderdash_abci::proto::abci::response_verify_vote_extension::VerifyStatus; use tenderdash_abci::proto::abci::{ @@ -32,6 +26,7 @@ mod tests { }; use tenderdash_abci::proto::google::protobuf::Timestamp; use tenderdash_abci::proto::version::Consensus; + use tenderdash_abci::proto::FromMillis; use tenderdash_abci::Application; const BLOCK_SPACING_MS: u64 = 3000; @@ -40,71 +35,39 @@ mod tests { fn strategy() -> NetworkStrategy { NetworkStrategy { - strategy: Strategy { - start_contracts: vec![], - operations: vec![], - start_identities: StartIdentities::default(), - start_addresses: StartAddresses::default(), - identity_inserts: IdentityInsertInfo::default(), - identity_contract_nonce_gaps: None, - signer: None, - }, total_hpmns: 50, - extra_normal_mns: 0, validator_quorum_count: 10, chain_lock_quorum_count: 10, - upgrading_info: None, - proposer_strategy: Default::default(), - rotate_quorums: false, - failure_testing: None, - query_testing: None, - verify_state_transition_results: false, ..Default::default() } } fn config() -> PlatformConfig { PlatformConfig { - execution: ExecutionConfig { - verify_sum_trees: true, - ..Default::default() - }, block_spacing_ms: BLOCK_SPACING_MS, testing_configs: PlatformTestConfig::default_minimal_verifications(), ..Default::default() } } - /// Queues one untied withdrawal transaction, as a committed block that pooled it would - /// have left it, so that the next height dequeues it and asks validators to sign it. + async fn run_chain(platform: &mut Platform) -> ChainExecutionOutcome<'_> { + run_chain_for_strategy(platform, 2, strategy(), config(), 15, &mut None, &mut None).await + } + + /// Puts one untied withdrawal transaction in the queue the next height dequeues from, and + /// moves the committed app hash with it. Only the queue entry is written: there is no + /// matching withdrawal document, the transaction index counter is not advanced, and the + /// amount is recorded at block time 0. fn queue_withdrawal_transaction(outcome: &ChainExecutionOutcome) { let platform = outcome.abci_app.platform; let platform_version = PlatformVersion::latest(); - let untied_transaction = AssetUnlockBaseTransactionInfo { - version: 1, - lock_time: 0, - output: vec![TxOut { - value: 100_000, - script_pubkey: ScriptBuf::from_bytes(vec![0x51]), - }], - base_payload: AssetUnlockBasePayload { - version: 1, - index: 0, - fee: 2_000, - }, - }; - let mut untied_transaction_bytes = vec![]; - untied_transaction - .consensus_encode(&mut untied_transaction_bytes) - .expect("expected to encode an untied withdrawal transaction"); - let transaction = platform.drive.grove.start_transaction(); let mut drive_operations = vec![]; platform .drive .add_enqueue_untied_withdrawal_transaction_operations( - vec![(0, untied_transaction_bytes)], + vec![(0, untied_withdrawal_transaction_bytes(0))], 102_000_000, &mut drive_operations, platform_version, @@ -126,7 +89,6 @@ mod tests { .commit_transaction(transaction, &platform_version.drive) .expect("expected to commit the queued withdrawal transaction"); - // The committed app hash moved with the queue, as it would have in a real block. let app_hash = platform .drive .grove @@ -160,10 +122,7 @@ mod tests { misbehavior: vec![], hash: hash.to_vec(), height: height as i64, - time: Some(Timestamp { - seconds: (time_ms / 1000) as i64, - nanos: ((time_ms % 1000) * 1_000_000) as i32, - }), + time: Some(Timestamp::from_millis(time_ms).expect("expected a block time")), next_validators_hash: [0u8; 32].to_vec(), round: round as i32, core_chain_locked_height, @@ -218,26 +177,14 @@ mod tests { } /// Round 0 is processed at the last chain-locked core height, then round 1 at a newer one. - /// A round 0 precommit is still accepted afterwards, which it was not when every vote was - /// compared with the last proposal processed. A vote whose withdrawals differ from its own - /// round's is rejected, and so is a vote for a round not processed yet. + /// A round 0 precommit is still accepted afterwards. A vote whose withdrawals differ from its + /// own round's is rejected, and so is a vote for a round not processed yet. #[tokio::test] async fn should_verify_each_rounds_vote_extensions_against_its_own_withdrawals() { - let config = config(); let mut platform = TestPlatformBuilder::new() - .with_config(config.clone()) + .with_config(config()) .build_with_mock_rpc(); - - let outcome = run_chain_for_strategy( - &mut platform, - 2, - strategy(), - config, - 15, - &mut None, - &mut None, - ) - .await; + let outcome = run_chain(&mut platform).await; queue_withdrawal_transaction(&outcome); @@ -258,8 +205,8 @@ mod tests { let round_0_extensions = extend_vote(&outcome, &round_0); // Before round 1 is processed, a round 1 precommit carrying the same validator's round 0 - // extensions verifies in Tenderdash, as their signatures are bound to neither height nor - // round, and matches the only proposal this node processed. It must still be rejected. + // extensions still verifies in Tenderdash and matches the only proposal this node + // processed. It must be rejected. assert_eq!( verify(&outcome, &round_1, round_0_extensions.clone()), VerifyStatus::Reject as i32, @@ -303,4 +250,59 @@ mod tests { "a round 0 precommit carrying round 1's withdrawals must be rejected" ); } + + /// A proposal this node rejects is not kept, even though its block execution context replaced + /// the accepted round's: a vote for it is rejected, and votes for the accepted round are still + /// verified. + #[tokio::test] + async fn should_not_verify_votes_against_a_rejected_proposal() { + let mut platform = TestPlatformBuilder::new() + .with_config(config()) + .build_with_mock_rpc(); + let outcome = run_chain(&mut platform).await; + + queue_withdrawal_transaction(&outcome); + + let round_0_core_height = outcome + .abci_app + .platform + .state + .load() + .last_committed_core_height(); + let round_0 = proposal(&outcome, 0, round_0_core_height, ROUND_0_BLOCK); + let mut rejected_round_1 = proposal(&outcome, 1, round_0_core_height + 1, ROUND_1_BLOCK); + // Bytes that decode to no state transition make the proposal unacceptable + rejected_round_1.txs = vec![vec![0u8; 10]]; + + let response = outcome + .abci_app + .process_proposal(round_0.clone()) + .expect("expected to process the round 0 proposal"); + assert_eq!(response.status, ProposalStatus::Accept as i32); + let round_0_extensions = extend_vote(&outcome, &round_0); + + let response = outcome + .abci_app + .process_proposal(rejected_round_1.clone()) + .expect("expected to process the round 1 proposal"); + assert_eq!(response.status, ProposalStatus::Reject as i32); + let rejected_extensions = extend_vote(&outcome, &rejected_round_1); + + assert_eq!( + rejected_extensions.len(), + 1, + "test premise: the rejected proposal built a withdrawal transaction" + ); + + assert_eq!( + verify(&outcome, &rejected_round_1, rejected_extensions), + VerifyStatus::Reject as i32, + "a vote for a rejected proposal must be rejected" + ); + assert_eq!( + verify(&outcome, &round_0, round_0_extensions), + VerifyStatus::Accept as i32, + "votes for the accepted round must still be verified" + ); + } } From fb73ef88847366fb756007859a6285d96f87e9c7 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 11:04:21 +0700 Subject: [PATCH 004/113] perf(drive-abci): read shielded encrypted notes in one chunk-aligned range read (#5030) Co-authored-by: Claude Opus 5.5 --- .../query/shielded/encrypted_notes/v0/mod.rs | 223 +++++++++++++++--- 1 file changed, 192 insertions(+), 31 deletions(-) diff --git a/packages/rs-drive-abci/src/query/shielded/encrypted_notes/v0/mod.rs b/packages/rs-drive-abci/src/query/shielded/encrypted_notes/v0/mod.rs index d3c8f7b3dcc..92d2e3b4179 100644 --- a/packages/rs-drive-abci/src/query/shielded/encrypted_notes/v0/mod.rs +++ b/packages/rs-drive-abci/src/query/shielded/encrypted_notes/v0/mod.rs @@ -19,7 +19,6 @@ use drive::drive::shielded::paths::{ SHIELDED_NOTES_KEY, }; use drive::grovedb::{PathQuery, Query, QueryItem, SizedQuery, SubqueryBranch}; -use drive::grovedb_path::SubtreePath; use drive::util::grove_operations::GroveDBToUse; impl Platform { @@ -114,38 +113,39 @@ impl Platform { metadata: Some(self.response_metadata_v0(platform_state, grovedb_used)), } } else { - // Non-proved: loop over commitment_tree_get_value for each position + // Non-proved: one chunk-aligned range read. Each compacted chunk + // the page overlaps is read and deserialized once, where reading + // position by position would deserialize the whole chunk blob + // again for every note in it. let pool_path = shielded_credit_pool_path(); - let pool_subtree: SubtreePath<&[u8]> = (&pool_path).into(); - let notes_key: &[u8] = &[SHIELDED_NOTES_KEY]; - - let mut entries = Vec::with_capacity(limit as usize); - for pos in start_index..(start_index + limit as u64) { - let maybe_value = self - .drive - .grove - .commitment_tree_get_value( - pool_subtree.clone(), - notes_key, - pos, - None, - &platform_version.drive.grove_version, - ) - .unwrap() - .map_err(|e| Error::Drive(drive::error::Error::GroveDB(Box::new(e))))?; - - match maybe_value { - // Stored value = cmx (32) || rho (32) || cv_net (32) || encrypted_note (rest) - Some(value) if value.len() > 96 => { - entries.push(EncryptedNote { - cmx: value[..32].to_vec(), - nullifier: value[32..64].to_vec(), - cv_net: value[64..96].to_vec(), - encrypted_note: value[96..].to_vec(), - }); - } - _ => break, // past end of tree + let page = self + .drive + .grove + .commitment_tree_get_range( + &pool_path, + &[SHIELDED_NOTES_KEY], + start_index, + limit, + None, + &platform_version.drive.grove_version, + ) + .unwrap() + .map_err(|e| Error::Drive(drive::error::Error::GroveDB(Box::new(e))))?; + + // The page is contiguous from `start_index` and already stops at + // the end of the tree. + let mut entries = Vec::with_capacity(page.entries.len()); + for (_, value) in page.entries { + // Stored value = cmx (32) || rho (32) || cv_net (32) || encrypted_note (rest) + if value.len() <= 96 { + break; } + entries.push(EncryptedNote { + cmx: value[..32].to_vec(), + nullifier: value[32..64].to_vec(), + cv_net: value[64..96].to_vec(), + encrypted_note: value[96..].to_vec(), + }); } GetShieldedEncryptedNotesResponseV0 { @@ -166,7 +166,10 @@ impl Platform { mod tests { use super::*; use crate::query::tests::setup_platform; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; use dpp::dashcore::Network; + use grovedb_commitment_tree::{DashMemo, NoteBytesData, TransmittedNoteCiphertext}; /// MMR chunk size used for alignment. Derived from /// `SHIELDED_NOTES_CHUNK_POWER`; independent of `max_query_chunks`. @@ -371,6 +374,164 @@ mod tests { assert!(result.errors.is_empty(), "{:?}", result.errors); } + /// 32 bytes naming a note position and one of its fields, so a page that + /// returns the wrong position or misplaces a field cannot compare equal. + /// Small enough to be a valid Pallas base element when used as a cmx. + fn position_tag(pos: u64, field: u64) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&(pos + 1).to_le_bytes()); + bytes[8..16].copy_from_slice(&field.to_le_bytes()); + bytes + } + + /// Appends `count` notes whose cmx, rho, cv_net and ciphertext all carry + /// the note's `position_tag`. + fn insert_position_tagged_notes( + platform: &TempPlatform, + count: u64, + version: &PlatformVersion, + ) { + let pool_path = shielded_credit_pool_path(); + let transaction = platform.drive.grove.start_transaction(); + for pos in 0..count { + let ciphertext: TransmittedNoteCiphertext = + TransmittedNoteCiphertext::from_parts( + position_tag(pos, 4), + NoteBytesData([(pos % 251) as u8; 104]), + [(pos % 241) as u8; 80], + ); + platform + .drive + .grove + .commitment_tree_insert( + &pool_path, + &[SHIELDED_NOTES_KEY], + position_tag(pos, 1), + position_tag(pos, 2), + position_tag(pos, 3), + ciphertext, + Some(&transaction), + &version.drive.grove_version, + ) + .unwrap() + .expect("should insert note"); + } + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("should commit notes"); + } + + /// The non-proved read as it was before the range read: one + /// `commitment_tree_get_value` per position, stopping at the first + /// position that holds no note. + fn notes_read_one_position_at_a_time( + platform: &TempPlatform, + start_index: u64, + limit: u16, + version: &PlatformVersion, + ) -> Vec { + let pool_path = shielded_credit_pool_path(); + let mut entries = Vec::new(); + for pos in start_index..(start_index + limit as u64) { + let maybe_value = platform + .drive + .grove + .commitment_tree_get_value( + &pool_path, + &[SHIELDED_NOTES_KEY], + pos, + None, + &version.drive.grove_version, + ) + .unwrap() + .expect("should read note"); + match maybe_value { + Some(value) if value.len() > 96 => entries.push(EncryptedNote { + cmx: value[..32].to_vec(), + nullifier: value[32..64].to_vec(), + cv_net: value[64..96].to_vec(), + encrypted_note: value[96..].to_vec(), + }), + _ => break, + } + } + entries + } + + #[test] + fn test_v0_range_read_matches_per_position_reads_across_chunk_and_buffer() { + // One compacted chunk plus a few notes in the dense buffer, so pages + // cover the chunk alone, the buffer alone, and a span across both. + let (platform, state, version) = setup_platform(None, Network::Testnet, None); + let chunk = mmr_chunk_size(); + let buffered = 5; + let total = chunk + buffered; + insert_position_tagged_notes(&platform, total, version); + + // The pre-change read over the whole pool is the reference: a + // per-position loop over any page equals the matching slice of it. + let max = max_notes(version) as u64; + assert!(max > total, "one page must be able to hold the whole pool"); + let reference = notes_read_one_position_at_a_time(&platform, 0, max as u16, version); + assert_eq!(reference.len() as u64, total); + for (pos, note) in reference.iter().enumerate() { + let pos = pos as u64; + assert_eq!(note.cmx, position_tag(pos, 1), "cmx at {}", pos); + assert_eq!(note.nullifier, position_tag(pos, 2), "rho at {}", pos); + assert_eq!(note.cv_net, position_tag(pos, 3), "cv_net at {}", pos); + } + + let pages: [(u64, u32); 9] = [ + (0, 0), // default: the whole pool + (0, 1), // first note of the chunk + (0, chunk as u32 - 1), // chunk minus its last note + (0, chunk as u32), // exactly the chunk + (0, chunk as u32 + 2), // chunk plus part of the buffer + (0, max as u32 + 1), // over the cap: clamped to max + (chunk, 3), // part of the buffer + (chunk, 0), // buffer to the end of the tree + (chunk * 2, 0), // past the end of the tree + ]; + for (start_index, count) in pages { + let effective = if count == 0 || count as u64 > max { + max + } else { + count as u64 + }; + let first = start_index.min(total) as usize; + let last = (start_index + effective).min(total) as usize; + let expected = &reference[first..last]; + + let result = platform + .query_shielded_encrypted_notes_v0( + GetShieldedEncryptedNotesRequestV0 { + start_index, + count, + prove: false, + }, + &state, + version, + ) + .expect("expected query to succeed"); + assert!(result.errors.is_empty(), "{:?}", result.errors); + match result.data.and_then(|data| data.result) { + Some(get_shielded_encrypted_notes_response_v0::Result::EncryptedNotes(notes)) => { + assert_eq!( + notes.entries.as_slice(), + expected, + "start_index {} count {}", + start_index, + count + ); + } + other => panic!("expected EncryptedNotes, got {:?}", other), + } + } + } + #[test] fn test_v0_start_index_zero_is_always_aligned() { // start_index = 0 is always aligned (any X % chunk_size for 0 is 0). From b43b030f473bc00773892aa42f3f42cdb634fd5e Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 11:07:52 +0700 Subject: [PATCH 005/113] chore: open every pr-description output with a basic explanation section (#5031) Co-authored-by: Claude Opus 5.5 --- .claude/skills/pr-description/SKILL.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.claude/skills/pr-description/SKILL.md b/.claude/skills/pr-description/SKILL.md index bd7a072f40e..985d2a10b09 100644 --- a/.claude/skills/pr-description/SKILL.md +++ b/.claude/skills/pr-description/SKILL.md @@ -25,6 +25,8 @@ Generate a pull request title and description for the current branch using the p - What specific code changes were made - Whether there are breaking changes - What tests were added or modified + - What value the change adds, and for whom + - What could go wrong: consensus impact, behaviour users could notice, slow or flaky tests 4. Output a suggested PR title using conventional commits format: - Scopes: `sdk`, `drive`, `dpp`, `dapi`, `dashmate`, `wasm-dpp`, `wasm-sdk`, `platform` @@ -32,12 +34,20 @@ Generate a pull request title and description for the current branch using the p - Add `!` after the type for breaking changes (e.g. `feat!:`) - Format: **Suggested title:** `type(scope): description` -5. Fill in this PR template (preserve all HTML comments exactly as shown): +5. Fill in this PR template (preserve all HTML comments exactly as shown). The `## Basic explanation` section always comes first; the sections after it follow `.github/PULL_REQUEST_TEMPLATE.md`: ```markdown +## Basic explanation + +**What this does:** + +**Value:** + +**Risks:** + ## Issue being fixed or feature implemented @@ -80,6 +90,7 @@ Output the entire PR description (title + body) as a single raw Markdown code bl ## Guidelines +- Always open with `## Basic explanation`: three short paragraphs (what it does, value, risks) that someone outside this code can follow. For a security fix, keep it neutral: describe what the fix does, not how the bug could be exploited - Keep the description **concise** — avoid walls of text. Prefer short bullet points over paragraphs - Be specific — reference file paths, struct/function names, and types - For "How Has This Been Tested?", check `git diff` for new `*test*`, `*spec*` files. Briefly describe what tests cover (1 line per test file), not every individual test case From 298f013e147f52adf34ffdf581df83a402d60808 Mon Sep 17 00:00:00 2001 From: PastaPastaPasta <6443210+PastaPastaPasta@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:13:03 -0500 Subject: [PATCH 006/113] fix(sdk): don't panic in DapiClient::new on an empty address list (#4964) Co-authored-by: Claude Opus 5.5 (1M context) --- .../rs-dapi-client/src/connection_pool.rs | 5 ++- packages/rs-dapi-client/src/dapi_client.rs | 31 ++++++++++++++++--- .../tests/empty_address_list.rs | 28 +++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 packages/rs-dapi-client/tests/empty_address_list.rs diff --git a/packages/rs-dapi-client/src/connection_pool.rs b/packages/rs-dapi-client/src/connection_pool.rs index b8e7b74775a..bf51838d64f 100644 --- a/packages/rs-dapi-client/src/connection_pool.rs +++ b/packages/rs-dapi-client/src/connection_pool.rs @@ -11,6 +11,9 @@ use crate::{ Uri, }; +/// Default capacity of the [ConnectionPool]. +pub(crate) const DEFAULT_POOL_CAPACITY: usize = 50; + /// ConnectionPool represents pool of connections to DAPI nodes. /// /// It can be cloned and shared between threads. @@ -38,7 +41,7 @@ impl ConnectionPool { impl Default for ConnectionPool { fn default() -> Self { - Self::new(50) + Self::new(DEFAULT_POOL_CAPACITY) } } diff --git a/packages/rs-dapi-client/src/dapi_client.rs b/packages/rs-dapi-client/src/dapi_client.rs index 6569b1ba4a3..e37111ed0be 100644 --- a/packages/rs-dapi-client/src/dapi_client.rs +++ b/packages/rs-dapi-client/src/dapi_client.rs @@ -9,7 +9,7 @@ use std::time::Duration; use tracing::Instrument; use crate::address_list::AddressListError; -use crate::connection_pool::ConnectionPool; +use crate::connection_pool::{ConnectionPool, DEFAULT_POOL_CAPACITY}; use crate::request_settings::AppliedRequestSettings; use crate::transport::{self, TransportError}; use crate::{ @@ -117,14 +117,18 @@ pub struct DapiClient { impl DapiClient { /// Initialize new [DapiClient] and optionally override default settings. + /// + /// `address_list` may be empty; addresses added later to the shared list + /// (or a clone of it) are used by this client. pub fn new(address_list: AddressList, settings: RequestSettings) -> Self { - // multiply by 3 as we need to store core and platform addresses, and we want some spare capacity just in case - let address_count = 3 * address_list.len(); + // multiply by 3 as we need to store core and platform addresses, and we want some spare capacity just in case; + // never go below the default, as the list can be empty and addresses can be added later + let pool_capacity = (3 * address_list.len()).max(DEFAULT_POOL_CAPACITY); Self { address_list, settings, - pool: ConnectionPool::new(address_count), + pool: ConnectionPool::new(pool_capacity), #[cfg(feature = "dump")] dump_dir: None, #[cfg(not(target_arch = "wasm32"))] @@ -281,6 +285,25 @@ mod tests { } } + #[tokio::test] + async fn test_new_with_empty_address_list() { + let client = DapiClient::new(AddressList::new(), RequestSettings::default()); + assert!(client.address_list().is_empty()); + + let request = dapi_grpc::platform::v0::GetIdentityRequest::default(); + let err = client + .execute(request, RequestSettings::default()) + .await + .expect_err("no addresses to execute the request on"); + assert!(matches!(err.inner, DapiClientError::NoAvailableAddresses)); + + // The address list is shared, so addresses added later are visible to the client. + let mut address_list = client.address_list().clone(); + assert!(address_list.add(mock_address())); + assert_eq!(client.get_live_addresses(), vec![mock_address()]); + // Execution on the added address: tests/empty_address_list.rs. + } + #[test] fn test_can_retry_no_available_addresses() { let err = DapiClientError::NoAvailableAddresses; diff --git a/packages/rs-dapi-client/tests/empty_address_list.rs b/packages/rs-dapi-client/tests/empty_address_list.rs new file mode 100644 index 00000000000..518081607d8 --- /dev/null +++ b/packages/rs-dapi-client/tests/empty_address_list.rs @@ -0,0 +1,28 @@ +//! A [DapiClient] built on an empty address list executes requests on +//! addresses added to the shared list after construction. + +mod common; + +use common::{FakeResponse, ScriptedRequest}; +use rs_dapi_client::{Address, AddressList, DapiClient, DapiRequestExecutor, RequestSettings}; + +#[tokio::test] +async fn executes_on_address_added_after_construction() { + let client = DapiClient::new(AddressList::new(), RequestSettings::default()); + let request = ScriptedRequest::new(|_uri| Ok(FakeResponse)); + + let address: Address = "http://127.0.0.1:10001".parse().expect("valid address"); + let mut address_list = client.address_list().clone(); + assert!(address_list.add(address.clone())); + + let response = client + .execute(request.clone(), RequestSettings::default()) + .await + .expect("request should succeed on the added address"); + + assert_eq!(response.address, address); + assert_eq!( + *request.hit_uris.lock().unwrap(), + vec![address.uri().clone()] + ); +} From 52cd646e36f66e4c68e7820f9fd70078a2f59339 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 11:15:07 +0700 Subject: [PATCH 007/113] chore: sync the pr-description skill with the PR template and title check (#5032) Co-authored-by: Claude Opus 5.5 --- .claude/skills/pr-description/SKILL.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.claude/skills/pr-description/SKILL.md b/.claude/skills/pr-description/SKILL.md index 985d2a10b09..52a19f9dd0c 100644 --- a/.claude/skills/pr-description/SKILL.md +++ b/.claude/skills/pr-description/SKILL.md @@ -13,7 +13,8 @@ Generate a pull request title and description for the current branch using the p 1. Determine the base branch: - Use the argument if provided - Otherwise, auto-detect: `git remote set-head origin --auto >/dev/null 2>&1 && git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/||'` - - Fall back to `v4.0-dev` if the command above fails + - If that fails, use the repository's default branch on GitHub: `gh repo view --json defaultBranchRef -q .defaultBranchRef.name` + - If both fail, ask the user rather than guessing a version branch 2. Gather context by running these git commands: - `git log --oneline $(git merge-base HEAD )..HEAD` — all commits on this branch @@ -29,12 +30,13 @@ Generate a pull request title and description for the current branch using the p - What could go wrong: consensus impact, behaviour users could notice, slow or flaky tests 4. Output a suggested PR title using conventional commits format: - - Scopes: `sdk`, `drive`, `dpp`, `dapi`, `dashmate`, `wasm-dpp`, `wasm-sdk`, `platform` - - Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `build` + - Types and scopes: use only the `types:` and `scopes:` lists in `.github/workflows/pr.yml`. The PR title check rejects anything else, so read them from that file rather than from memory + - The scope is optional: leave it out (e.g. `chore: ...`) when the change spans several packages or no listed scope fits. Never invent a scope + - The subject must not start with an uppercase letter - Add `!` after the type for breaking changes (e.g. `feat!:`) - Format: **Suggested title:** `type(scope): description` -5. Fill in this PR template (preserve all HTML comments exactly as shown). The `## Basic explanation` section always comes first; the sections after it follow `.github/PULL_REQUEST_TEMPLATE.md`: +5. Fill in this PR template (preserve all HTML comments exactly as shown). The `## Basic explanation` section always comes first; the sections after it follow `.github/PULL_REQUEST_TEMPLATE.md`. If that file differs from the copy below, follow the file: ```markdown @@ -79,6 +81,7 @@ Generate a pull request title and description for the current branch using the p - [ ] I have added or updated relevant unit/integration/functional/e2e tests - [ ] I have added "!" to the title and described breaking changes in the corresponding section if my code contains any - [ ] I have made corresponding changes to the documentation if needed +- [ ] If I added or changed GroveDB structure, I described it in the area's `structure.rs`, regenerated `grovedb-structure.json`, and checked the structure viewer link posted on this pull request **For repository code-owners and collaborators only** - [ ] I have assigned this pull request to a milestone From f4426b26b375db92e82cac148fbc90da78c60917 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 11:31:13 +0700 Subject: [PATCH 008/113] fix(drive-abci)!: cap a contest at 1,000 contenders and tally every one (PV14) (#5029) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/contested-documents.md | 19 +- packages/rs-dpp/src/errors/consensus/codes.rs | 1 + ...ontest_maximum_contenders_reached_error.rs | 62 +++ .../errors/consensus/state/document/mod.rs | 1 + .../src/errors/consensus/state/state_error.rs | 19 +- .../check_for_ended_vote_polls/v1/mod.rs | 5 +- .../mod.rs | 2 + .../tests.rs | 514 ++++++++++++++++++ .../mod.rs | 40 ++ .../state_v2/mod.rs | 35 ++ .../batch/tests/document/creation.rs | 145 ++++- .../state_transition/state_transitions/mod.rs | 2 +- packages/rs-drive/grovedb-structure.json | 8 +- .../mod.rs | 15 +- .../v1/mod.rs | 443 +++++++++++++++ .../drive/document/insert_contested/mod.rs | 2 +- .../mod.rs | 63 +++ .../v0/mod.rs | 275 ++++++++++ .../rs-drive/src/drive/votes/fetch/mod.rs | 1 + .../rs-drive/src/drive/votes/structure.rs | 10 +- .../rs-drive/src/util/test_helpers/mod.rs | 110 ++++ .../v0/mod.rs | 132 +++++ .../drive_abci_validation_versions/v10.rs | 13 +- .../drive_document_method_versions/v4.rs | 10 +- .../drive_vote_method_versions/mod.rs | 2 + .../drive_vote_method_versions/v1.rs | 1 + .../drive_vote_method_versions/v2.rs | 1 + .../drive_vote_method_versions/v3.rs | 5 +- .../src/version/mocks/v2_test.rs | 1 + .../src/version/system_limits/mod.rs | 6 + .../src/version/system_limits/v1.rs | 1 + .../src/version/system_limits/v2.rs | 1 + .../src/version/system_limits/v3.rs | 1 + .../src/version/system_limits/v4.rs | 6 + .../rs-platform-version/src/version/v14.rs | 22 +- .../src/errors/consensus/consensus_error.rs | 4 + 36 files changed, 1957 insertions(+), 21 deletions(-) create mode 100644 packages/rs-dpp/src/errors/consensus/state/document/document_contest_maximum_contenders_reached_error.rs create mode 100644 packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/tests.rs create mode 100644 packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/votes/fetch/fetch_contested_document_vote_poll_contender_count/mod.rs create mode 100644 packages/rs-drive/src/drive/votes/fetch/fetch_contested_document_vote_poll_contender_count/v0/mod.rs diff --git a/book/src/data-model/contested-documents.md b/book/src/data-model/contested-documents.md index 7ab62dfd3be..2f4b7537ab2 100644 --- a/book/src/data-model/contested-documents.md +++ b/book/src/data-model/contested-documents.md @@ -16,6 +16,15 @@ amount from that balance. Contenders may join for the **join window** (one week the first document; the contest runs for the **poll duration** (two weeks on mainnet). The first document's owner may not be joined by the same identity twice. +From protocol version 14, a contest accepts at most 1,000 contenders +(`max_contenders_per_contest`): a document that would add one more is refused, paid, with +`DocumentContestMaximumContendersReachedError` (40141). The bound is what lets one block end a +contest: its end tallies up to `maximum_contenders_to_consider` contenders (10,000 from version 14) +and removes the entries of those it tallied, so a contest within the cap is tallied and cleaned up +whole. Before 14 a contest accepted any number of contenders and its end tallied at most 100; a +contest that grew past 10,000 contenders before 14 is tallied and cleaned up for its first 10,000 +only. + The index's `contested.resolution` says how the contest is decided. From protocol version 14, an identifier property among the index values is written as an @@ -68,14 +77,18 @@ other contest, DPNS included, keeps the generic windows and fund. ## Ties From protocol version 14, a tie among the top contenders goes to the **earliest** contender: -creation time, then block height, then core block height, then document id. This holds for both -resolutions; contests ending before version 14 awarded the latest contender. +creation time, then block height, then core block height, then document id, among every tied +contender. This holds for both resolutions; contests ending before version 14 awarded the latest +contender. ## Storage A contest's state lives under `votes / contested_resource / active_polls`, laid out like the contested index it decides: the contenders' documents, one votes sum tree per contender, and the -abstain and lock tallies. The masternodes' vote references live under +abstain and lock tallies. From protocol version 14 the tree below a contest's last index value, +which holds its contenders, stored result and tallies, is a count tree, so a join reads how many +contenders there are in one fetch; a contest started before 14 keeps its plain tree, and a join +counts its contenders by reading their keys. The masternodes' vote references live under `votes / contested_resource / identity_votes`, and the end dates under `votes / end_date_queries`, one tree per end date holding an entry for each contest ending then. Once the contest ends, the winning document is awarded, the losers are removed, and the stored diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index dd9287a8dc4..af4c8d69a95 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -372,6 +372,7 @@ impl ErrorWithCode for StateError { Self::ReferencedDocumentListInvalidError(_) => 40138, Self::DocumentActionFeeModeratorsShareMismatchError(_) => 40139, Self::DocumentExpiredError(_) => 40140, + Self::DocumentContestMaximumContendersReachedError(_) => 40141, // Identity Errors: 40200-40299 Self::IdentityAlreadyExistsError(_) => 40200, diff --git a/packages/rs-dpp/src/errors/consensus/state/document/document_contest_maximum_contenders_reached_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/document_contest_maximum_contenders_reached_error.rs new file mode 100644 index 00000000000..d5a591b0ae3 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/document_contest_maximum_contenders_reached_error.rs @@ -0,0 +1,62 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A document would add a contender to a contest that already holds the most contenders a +/// contest accepts (protocol version 14). +#[derive( + Error, + Debug, + Clone, + PartialEq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "The vote poll {vote_poll} already has {max_contenders} contenders, the most a contest accepts" +)] +#[platform_serialize(unversioned)] +pub struct DocumentContestMaximumContendersReachedError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + vote_poll: ContestedDocumentResourceVotePoll, + max_contenders: u16, +} + +impl DocumentContestMaximumContendersReachedError { + pub fn new(vote_poll: ContestedDocumentResourceVotePoll, max_contenders: u16) -> Self { + Self { + vote_poll, + max_contenders, + } + } + + pub fn vote_poll(&self) -> &ContestedDocumentResourceVotePoll { + &self.vote_poll + } + + pub fn max_contenders(&self) -> u16 { + self.max_contenders + } +} + +impl From for ConsensusError { + fn from(err: DocumentContestMaximumContendersReachedError) -> Self { + Self::StateError(StateError::DocumentContestMaximumContendersReachedError( + err, + )) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs index e3d57d5c93b..f302c21b963 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs @@ -7,6 +7,7 @@ pub mod document_contest_currently_locked_error; pub mod document_contest_document_with_same_id_already_present_error; pub mod document_contest_identity_already_contestant; pub mod document_contest_index_mismatch_error; +pub mod document_contest_maximum_contenders_reached_error; pub mod document_contest_not_joinable_error; pub mod document_contest_not_paid_for_error; pub mod document_contest_not_required_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index 26687e70fac..ee70e3877e1 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -64,6 +64,7 @@ use crate::consensus::state::data_contract::document_type_update_error::Document use crate::consensus::state::document::document_contest_currently_locked_error::DocumentContestCurrentlyLockedError; use crate::consensus::state::document::document_contest_document_with_same_id_already_present_error::DocumentContestDocumentWithSameIdAlreadyPresentError; use crate::consensus::state::document::document_contest_identity_already_contestant::DocumentContestIdentityAlreadyContestantError; +use crate::consensus::state::document::document_contest_maximum_contenders_reached_error::DocumentContestMaximumContendersReachedError; use crate::consensus::state::document::document_contest_index_mismatch_error::DocumentContestIndexMismatchError; use crate::consensus::state::document::document_contest_not_joinable_error::DocumentContestNotJoinableError; use crate::consensus::state::document::document_contest_not_paid_for_error::DocumentContestNotPaidForError; @@ -627,6 +628,11 @@ pub enum StateError { // (protocol version 14). #[error(transparent)] DocumentExpiredError(DocumentExpiredError), + + // A contest holding the most contenders a contest accepts refuses another (protocol version + // 14). + #[error(transparent)] + DocumentContestMaximumContendersReachedError(DocumentContestMaximumContendersReachedError), } impl From for ConsensusError { @@ -1304,7 +1310,7 @@ mod tests { 150 ); // A document changed or restored after its time to live passed (protocol version - // 14): the tail of the enum. + // 14). assert_eq!( discriminant_of(StateError::DocumentExpiredError(DocumentExpiredError::new( group_id, @@ -1315,5 +1321,16 @@ mod tests { ))), 151 ); + // A contest holding the most contenders a contest accepts refuses another (protocol + // version 14): the tail of the enum. + assert_eq!( + discriminant_of(StateError::DocumentContestMaximumContendersReachedError( + DocumentContestMaximumContendersReachedError::new( + ContestedDocumentResourceVotePoll::default(), + 1_000, + ) + )), + 152 + ); } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs index a1a3b10563f..7401c3243b4 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs @@ -114,11 +114,12 @@ where .first() .map(|max_voted_contender| max_voted_contender.final_vote_tally) .unwrap_or_default(); - // These are all the people who got top votes + // These are all the people who got top votes, every one of them + // considered (up to `maximum_contenders_to_consider`); version 0 + // compared at most 100 let top_contenders: Vec = sorted_contenders .into_iter() .filter(|c| c.final_vote_tally == highest_vote_tally) - .take(100) // Limit to the first 100 before the expensive operation .map(|contender| { FinalizedContender::try_from_contender_with_serialized_document( contender, diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/mod.rs index 32e3245fc95..ad59a7b84ce 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/mod.rs @@ -11,6 +11,8 @@ use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_ use drive::grovedb::TransactionArg; use std::collections::BTreeMap; +#[cfg(test)] +mod tests; mod v0; mod v1; diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/tests.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/tests.rs new file mode 100644 index 00000000000..a09f67e66e6 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/tests.rs @@ -0,0 +1,514 @@ +//! The end of a contested vote poll holding many contenders: the tally reaches every one, and +//! the cleanup built from it leaves none of their entries behind. + +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, +}; +use dpp::data_contract::DataContract; +use dpp::document::DocumentV0Setters; +use dpp::identifier::Identifier; +use dpp::platform_value::{Bytes32, Value}; +use dpp::prelude::TimestampMillis; +use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::ContestedDocumentVotePollStoredInfo; +use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::{ + ContestedDocumentVotePollStatus, ContestedDocumentVotePollStoredInfoV0Getters, +}; +use drive::drive::votes::paths::{ + vote_contested_resource_identity_votes_tree_path_vec, VotePollPaths, + RESOURCE_STORED_INFO_KEY_U8_32, +}; +use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +use drive::grovedb::query_result_type::QueryResultType; +use drive::grovedb::{PathQuery, Query, SizedQuery, Transaction}; +use drive::util::object_size_info::DocumentInfo::DocumentRefInfo; +use drive::util::object_size_info::{DataContractOwnedResolvedInfo, OwnedDocumentInfo}; +use drive::util::storage_flags::StorageFlags; +use drive::util::test_helpers::vote_poll_end_dates; +use rand::rngs::StdRng; +use rand::SeedableRng; + +/// The identity id of contender `n`: `n + 1` big endian in its first 8 bytes, so contenders +/// sort by `n`. +fn contender_id(n: u64) -> Identifier { + let mut id = [0u8; 32]; + id[..8].copy_from_slice(&(n + 1).to_be_bytes()); + Identifier::from(id) +} + +/// The pro tx hash of voter `n` +fn voter(n: u64) -> [u8; 32] { + let mut pro_tx_hash = [0xFFu8; 32]; + pro_tx_hash[..8].copy_from_slice(&n.to_be_bytes()); + pro_tx_hash +} + +/// A DPNS name contest on `label` with `contenders` contenders (see [`contender_id`]), written +/// straight to Drive at block time 0. Every document is created at time 1, but the last +/// contender's at time 0. `voted` masternodes vote, one each, for the contenders in turn. Returns the +/// poll and its end time. +fn start_contest( + platform: &TempPlatform, + label: &str, + contenders: u64, + voted: u64, + platform_version: &PlatformVersion, +) -> ( + ContestedDocumentResourceVotePollWithContractInfo, + TimestampMillis, +) { + let dpns_contract: DataContract = + load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("expected the DPNS contract"); + let document_type = dpns_contract + .document_type_for_name("domain") + .expect("expected the domain document type"); + let vote_poll = ContestedDocumentResourceVotePollWithContractInfo { + contract: DataContractOwnedResolvedInfo::OwnedDataContract(dpns_contract.clone()), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + Value::Text("dash".to_string()), + Value::Text(label.to_string()), + ], + }; + let block_info = BlockInfo::default(); + let mut rng = StdRng::seed_from_u64(contenders); + for n in 0..contenders { + let owner_id = contender_id(n); + let mut document = document_type + .random_document_with_params( + owner_id, + Bytes32::random_with_rng(&mut rng), + Some(if n + 1 == contenders { 0 } else { 1 }), + Some(1), + Some(1), + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::MinDocumentFillSize, + &mut rng, + platform_version, + ) + .expect("expected a random domain"); + document.set("parentDomainName", "dash".into()); + document.set("normalizedParentDomainName", "dash".into()); + document.set("label", label.into()); + document.set("normalizedLabel", label.into()); + document.set("records.identity", owner_id.into()); + document.set("subdomainRules.allowSubdomains", false.into()); + let stored_info = (n == 0).then(|| { + ContestedDocumentVotePollStoredInfo::new(block_info, platform_version) + .expect("expected the poll's stored info") + }); + platform + .drive + .add_contested_document( + OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(owner_id.to_buffer()), + }, + vote_poll.clone(), + false, + stored_info, + &block_info, + true, + None, + platform_version, + ) + .expect("expected to add the contender"); + } + for n in 0..voted { + platform + .drive + .register_contested_resource_identity_vote( + voter(n), + 1, + vote_poll.clone(), + ResourceVoteChoice::TowardsIdentity(contender_id(n % contenders)), + None, + &block_info, + None, + platform_version, + ) + .expect("expected to register the vote"); + } + let end_dates = vote_poll_end_dates(&platform.drive, platform_version); + let [end_time]: [TimestampMillis; 1] = end_dates + .keys() + .copied() + .collect::>() + .try_into() + .expect("expected the contest to end at one time"); + (vote_poll, end_time) +} + +/// The block that ends the polls due at `time_ms` +fn ending_block(time_ms: TimestampMillis) -> BlockInfo { + BlockInfo { + time_ms, + height: 2, + core_height: 42, + epoch: Default::default(), + } +} + +/// Every key directly under `path` +fn keys_under( + platform: &TempPlatform, + path: Vec>, + transaction: &Transaction, + platform_version: &PlatformVersion, +) -> Vec> { + let mut query = Query::new(); + query.insert_all(); + match platform.drive.grove_get_raw_path_query( + &PathQuery::new(path, SizedQuery::new(query, None, None)), + Some(transaction), + QueryResultType::QueryKeyElementPairResultType, + &mut vec![], + &platform_version.drive, + ) { + Ok((elements, _)) => elements.to_keys(), + Err(drive::error::Error::GroveDB(error)) + if matches!( + *error, + drive::grovedb::Error::PathNotFound(_) + | drive::grovedb::Error::PathParentLayerNotFound(_) + | drive::grovedb::Error::PathKeyNotFound(_) + ) => + { + vec![] + } + Err(error) => panic!("expected to read the keys: {error:?}"), + } +} + +/// The poll's stored info +fn stored_info( + platform: &TempPlatform, + vote_poll: &ContestedDocumentResourceVotePollWithContractInfo, + transaction: &Transaction, + platform_version: &PlatformVersion, +) -> ContestedDocumentVotePollStoredInfo { + platform + .drive + .fetch_contested_document_vote_poll_stored_info( + vote_poll, + None, + Some(transaction), + platform_version, + ) + .expect("expected to read the stored info") + .1 + .expect("expected the poll to keep its stored info") +} + +/// What the end of a poll leaves: the keys under its choices, its contested documents and the +/// voters' vote records +fn left_behind( + platform: &TempPlatform, + vote_poll: &ContestedDocumentResourceVotePollWithContractInfo, + transaction: &Transaction, + platform_version: &PlatformVersion, +) -> (Vec>, Vec>, Vec>) { + let choices = keys_under( + platform, + vote_poll + .contenders_path(platform_version) + .expect("expected the choices path"), + transaction, + platform_version, + ); + let documents = keys_under( + platform, + vote_poll.documents_storage_path_vec(), + transaction, + platform_version, + ); + let voters = keys_under( + platform, + vote_contested_resource_identity_votes_tree_path_vec(), + transaction, + platform_version, + ) + .into_iter() + .filter(|voter_tree| { + !keys_under( + platform, + vote_contested_resource_identity_votes_tree_path_vec() + .into_iter() + .chain([voter_tree.clone()]) + .collect(), + transaction, + platform_version, + ) + .is_empty() + }) + .collect(); + (choices, documents, voters) +} + +#[test] +fn should_end_a_poll_of_more_contenders_than_protocol_13_tallied_leaving_nothing_behind() { + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (vote_poll, end_time) = start_contest(&platform, "quantum", 150, 0, platform_version); + + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + platform + .check_for_ended_vote_polls( + &platform_state, + &platform_state, + &ending_block(end_time), + Some(&transaction), + platform_version, + ) + .expect("expected the block to end the poll"); + + let (choices, contested_documents, _) = + left_behind(&platform, &vote_poll, &transaction, platform_version); + assert_eq!(choices, vec![RESOURCE_STORED_INFO_KEY_U8_32.to_vec()]); + assert!(contested_documents.is_empty()); + + // Nobody voted, so all 150 tie, and the earliest document wins: the last contender's + let stored_info = stored_info(&platform, &vote_poll, &transaction, platform_version); + assert_eq!( + stored_info.vote_poll_status(), + ContestedDocumentVotePollStatus::Awarded(contender_id(149)) + ); + assert_eq!( + stored_info + .contender_votes_in_vec_of_contender_with_serialized_document() + .expect("expected the contenders of the finished poll") + .len(), + 150 + ); +} + +/// Protocol version 13 tallies at most 100 contenders, and its cleanup, built from the tally, +/// removes the entries of the contenders it tallied; kept for replay. +#[test] +fn should_clean_up_the_tallied_contenders_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + let (vote_poll, end_time) = start_contest(&platform, "quantum", 150, 0, platform_version); + + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + platform + .check_for_ended_vote_polls( + &platform_state, + &platform_state, + &ending_block(end_time), + Some(&transaction), + platform_version, + ) + .expect("expected the block to end the poll"); + + let (choices, contested_documents, _) = + left_behind(&platform, &vote_poll, &transaction, platform_version); + let mut expected = vec![RESOURCE_STORED_INFO_KEY_U8_32.to_vec()]; + expected.extend((100..150).map(|n| contender_id(n).to_vec())); + assert_eq!(choices, expected); + // The documents removal reads every document, tallied or not + assert!(contested_documents.is_empty()); +} + +/// Measures the end of a poll holding the most contenders a contest accepts. Run in release +/// on drive-abci's 8 MiB runtime stack: +/// +/// `CONTENDERS` defaults to `max_contenders_per_contest`; `VOTED` masternodes (2,000 by default) +/// vote, one each, for the contenders in turn. +/// +/// ```text +/// CONTENDERS=1000 VOTED=3000 cargo test --release -p drive-abci --lib \ +/// should_end_a_poll_of_the_most_contenders_a_contest_accepts -- --ignored --nocapture +/// ``` +#[test] +#[ignore] +fn should_end_a_poll_of_the_most_contenders_a_contest_accepts() { + std::thread::Builder::new() + .stack_size(8 * 1024 * 1024) + .spawn(end_a_full_poll) + .expect("expected to spawn the measuring thread") + .join() + .expect("expected the measurement to succeed"); +} + +fn end_a_full_poll() { + use std::time::Instant; + + let platform_version = PlatformVersion::latest(); + let contenders: u64 = std::env::var("CONTENDERS") + .ok() + .map(|contenders| contenders.parse().expect("expected a number of contenders")) + .unwrap_or(platform_version.system_limits.max_contenders_per_contest as u64); + let voted: u64 = std::env::var("VOTED") + .ok() + .map(|voted| voted.parse().expect("expected a number of votes")) + .unwrap_or(2_000); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let started = Instant::now(); + let (vote_poll, end_time) = + start_contest(&platform, "quantum", contenders, voted, platform_version); + println!( + "setup: {contenders} contenders, {voted} votes in {:?}", + started.elapsed() + ); + + let started = Instant::now(); + let (join_fee, counted) = platform + .drive + .fetch_contested_document_vote_poll_contender_count( + &vote_poll, + platform_version.system_limits.max_contenders_per_contest, + &Default::default(), + None, + platform_version, + ) + .expect("expected the contender count"); + println!( + "join count read: {counted} contenders in {:?}, {} processing credits", + started.elapsed(), + join_fee.processing_fee + ); + // A join counts at most the limit + assert_eq!( + counted as u64, + contenders.min(platform_version.system_limits.max_contenders_per_contest as u64) + ); + + let platform_state = platform.state.load(); + let block_info = ending_block(end_time); + + // The whole end of the poll, rolled back after + { + let transaction = platform.drive.grove.start_transaction(); + let started = Instant::now(); + platform + .check_for_ended_vote_polls( + &platform_state, + &platform_state, + &block_info, + Some(&transaction), + platform_version, + ) + .expect("expected the block to end the poll"); + println!("end of the poll: {:?}", started.elapsed()); + let (choices, contested_documents, voters) = + left_behind(&platform, &vote_poll, &transaction, platform_version); + assert_eq!(choices, vec![RESOURCE_STORED_INFO_KEY_U8_32.to_vec()]); + assert!(contested_documents.is_empty()); + assert!(voters.is_empty()); + let stored_info_bytes = platform + .drive + .grove_get_raw( + vote_poll + .contenders_path(platform_version) + .expect("expected the choices path") + .as_slice() + .into(), + &RESOURCE_STORED_INFO_KEY_U8_32, + drive::util::grove_operations::DirectQueryType::StatefulDirectQuery, + Some(&transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("expected the stored info") + .expect("expected the stored info") + .into_item_bytes() + .expect("expected an item"); + println!("finished poll record: {} bytes", stored_info_bytes.len()); + } + + // Its steps + let transaction = platform.drive.grove.start_transaction(); + let started = Instant::now(); + let tally = platform + .tally_votes_for_contested_document_resource_vote_poll( + (&vote_poll).into(), + Some(&transaction), + platform_version, + ) + .expect("expected the tally"); + println!( + "tally: {} contenders in {:?}", + tally.contenders.len(), + started.elapsed() + ); + assert_eq!(tally.contenders.len() as u64, contenders); + + let started = Instant::now(); + let (with_votes, without_votes): (Vec<_>, Vec<_>) = tally + .contenders + .iter() + .partition(|contender| contender.final_vote_tally > 0); + let mut votes = platform + .drive + .fetch_identities_voting_for_contenders( + &vote_poll, + with_votes + .iter() + .map(|contender| contender.identity_id) + .collect(), + true, + Some(&transaction), + platform_version, + ) + .expect("expected the voters"); + votes.extend(without_votes.iter().map(|contender| { + ( + ResourceVoteChoice::TowardsIdentity(contender.identity_id), + vec![], + ) + })); + println!("voters: {:?}", started.elapsed()); + + let started = Instant::now(); + let finished = [(&vote_poll, &end_time, &votes)]; + let operations = platform + .clean_up_after_contested_resources_vote_polls_end_operations_v0( + &finished, + false, + Some(&transaction), + platform_version, + ) + .expect("expected the cleanup"); + println!( + "cleanup build: {} operations in {:?}", + operations.len(), + started.elapsed() + ); + + let started = Instant::now(); + platform + .drive + .apply_batch_low_level_drive_operations( + None, + Some(&transaction), + operations, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to apply the cleanup"); + println!("cleanup apply: {:?}", started.elapsed()); +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_contested_document_resource_vote_poll/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_contested_document_resource_vote_poll/mod.rs index 3a70992c5b2..dae47db1455 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_contested_document_resource_vote_poll/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_contested_document_resource_vote_poll/mod.rs @@ -39,3 +39,43 @@ where } } } + +#[cfg(test)] +mod tests { + use dpp::version::PLATFORM_VERSIONS; + + /// Wherever a join is checked against `max_contenders_per_contest` (document create state + /// validation 2 on), the tally reaches that many contenders, so the cleanup built from it + /// leaves none behind, and its query, two results a contender plus three, fits the u16 + /// query limit without saturating + #[test] + fn should_tally_every_contender_a_contest_accepts() { + for platform_version in PLATFORM_VERSIONS { + if platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .document_create_transition_state_validation + < 2 + { + continue; + } + let tallied = platform_version + .drive_abci + .validation_and_processing + .event_constants + .maximum_contenders_to_consider; + assert!( + tallied >= platform_version.system_limits.max_contenders_per_contest, + "protocol version {}", + platform_version.protocol_version + ); + assert!( + tallied as u32 * 2 + 3 <= u16::MAX as u32, + "protocol version {}", + platform_version.protocol_version + ); + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs index 2898dec7ea3..4e939f0970e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs @@ -1,5 +1,6 @@ use dpp::block::block_info::BlockInfo; use dpp::consensus::state::document::document_contest_document_with_same_id_already_present_error::DocumentContestDocumentWithSameIdAlreadyPresentError; +use dpp::consensus::state::document::document_contest_maximum_contenders_reached_error::DocumentContestMaximumContendersReachedError; use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; @@ -57,6 +58,40 @@ impl DocumentCreateTransitionActionStateValidationV2 for DocumentCreateTransitio return Ok(validation_result); } + // A contest accepts at most `max_contenders_per_contest` contenders, so the end of the + // poll can tally and clean up every one in a block. v1 has let the document join the + // contest when it exists; a new contest has no contenders to count. + if let Some((contested_document_resource_vote_poll, _)) = self.prefunded_voting_balance() { + if self.current_store_contest_info().is_some() { + let max_contenders = platform_version.system_limits.max_contenders_per_contest; + let (fee_result, contenders) = platform + .drive + .fetch_contested_document_vote_poll_contender_count( + contested_document_resource_vote_poll, + max_contenders, + &block_info.epoch, + transaction, + platform_version, + )?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + if contenders >= max_contenders { + return Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError( + StateError::DocumentContestMaximumContendersReachedError( + DocumentContestMaximumContendersReachedError::new( + contested_document_resource_vote_poll.into(), + max_contenders, + ), + ), + ), + )); + } + } + } + // The creator of a document being created is its writer let reference_result = self.base().validate_document_references( self.data(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index 20d66c1999f..86483a438df 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -32,7 +32,11 @@ mod creation_tests { use drive::util::test_helpers::setup_contract; use crate::test::helpers::setup::TempPlatform; use crate::rpc::core::MockCoreRPCLike; - use crate::execution::validation::state_transition::state_transitions::tests::{add_contender_to_dpns_name_contest, create_dpns_identity_name_contest, create_dpns_name_contest_give_key_info, perform_votes_multi}; + use crate::execution::validation::state_transition::state_transitions::tests::{add_contender_to_dpns_name_contest, create_dpns_identity_name_contest, create_dpns_name_contest_give_key_info, dpns_name_vote_poll, perform_votes_multi}; + use drive::drive::votes::paths::VotePollPaths; + use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::resolve::ContestedDocumentResourceVotePollResolver; + use drive::fees::op::LowLevelDriveOperation; + use drive::grovedb::Element; use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::PaidConsensusError; use crate::test::helpers::fast_forward_to_block::fast_forward_to_block; @@ -3570,6 +3574,145 @@ mod creation_tests { assert_eq!(consensus_error.to_string(), "An Identity with the id BjNejy4r9QAvLHpQ9Yq6yRMgNymeGZ46d48fJxJbMrfW is already a contestant for the vote_poll ContestedDocumentResourceVotePoll { contract_id: GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec, document_type_name: domain, index_name: parentNameAndLabel, index_values: [string dash, string quantum] }"); } + /// Fills the contest on `name` up to `contenders` contenders with bare contender entries, + /// written straight to GroveDB in one batch: a join reads how many contenders a contest + /// holds, never what they hold, so this stands in for thousands of contested documents. + fn fill_contest_with_bare_contenders( + platform: &TempPlatform, + dpns_contract: &DataContract, + name: &str, + contenders: u64, + platform_version: &PlatformVersion, + ) { + let choices_path = dpns_name_vote_poll(dpns_contract, name) + .resolve(&platform.drive, None, platform_version) + .expect("expected to resolve the vote poll") + .contenders_path(platform_version) + .expect("expected the choices path"); + let (_, held) = platform + .drive + .fetch_contested_document_vote_poll_contender_count( + &dpns_name_vote_poll(dpns_contract, name) + .resolve(&platform.drive, None, platform_version) + .expect("expected to resolve the vote poll"), + u16::MAX, + &Default::default(), + None, + PlatformVersion::latest(), + ) + .expect("expected the contender count"); + let operations = (held as u64..contenders) + .map(|n| { + let mut key = [0xEEu8; 32]; + key[24..].copy_from_slice(&n.to_be_bytes()); + LowLevelDriveOperation::insert_for_known_path_key_element( + choices_path.clone(), + key.to_vec(), + Element::empty_tree(), + ) + }) + .collect(); + platform + .drive + .apply_batch_low_level_drive_operations( + None, + None, + operations, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to write the bare contenders"); + } + + /// A contest accepts at most `max_contenders_per_contest` contenders (1,000): the one that + /// would be the 1,001st is refused, paid + #[tokio::test] + async fn should_refuse_a_contender_past_the_most_a_contest_accepts() { + let platform_version = PlatformVersion::latest(); + let max_contenders = platform_version.system_limits.max_contenders_per_contest as u64; + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_state = platform.state.load(); + let (_, _, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + "quantum", + platform_version, + ) + .await; + + fill_contest_with_bare_contenders( + &platform, + &dpns_contract, + "quantum", + max_contenders - 1, + platform_version, + ); + add_contender_to_dpns_name_contest( + &mut platform, + &platform_state, + 4, + "quantum", + None, + platform_version, + ) + .await; + + add_contender_to_dpns_name_contest( + &mut platform, + &platform_state, + 9, + "quantum", + Some("The vote poll ContestedDocumentResourceVotePoll { contract_id: GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec, document_type_name: domain, index_name: parentNameAndLabel, index_values: [string dash, string quantum] } already has 1000 contenders, the most a contest accepts"), + platform_version, + ) + .await; + } + + /// PROTOCOL_VERSION_13: a contest accepts any number of contenders + #[tokio::test] + async fn should_accept_a_contender_past_the_most_a_contest_accepts_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + let max_contenders = PlatformVersion::latest() + .system_limits + .max_contenders_per_contest as u64; + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_state = platform.state.load(); + let (_, _, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + "quantum", + platform_version, + ) + .await; + + fill_contest_with_bare_contenders( + &platform, + &dpns_contract, + "quantum", + max_contenders, + platform_version, + ); + add_contender_to_dpns_name_contest( + &mut platform, + &platform_state, + 4, + "quantum", + None, + platform_version, + ) + .await; + } + #[tokio::test] async fn test_that_a_contested_document_can_not_be_added_if_we_are_locked() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index c3ae91ed840..25724080ee1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -2000,7 +2000,7 @@ pub(in crate::execution) mod tests { .. } = result else { - panic!("expected a paid consensus error"); + panic!("expected a paid consensus error, got {result:?}"); }; assert_eq!(consensus_error.to_string(), expected_err); } else { diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 329491ef4f8..9e5b70f7b0d 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -4269,8 +4269,10 @@ "description": "The value of the next index property; empty for null" }, "kinds": [ - "Tree" + "Tree", + "CountTree" ], + "kinds_note": "A count tree for the last value of the index when the poll started at protocol version 14 or later, counting its contenders plus its stored info, abstain and lock entries, so a join reads how many contenders the poll has in one fetch. A tree otherwise.", "flags": [ "EpochOwned", "None" @@ -4542,8 +4544,10 @@ "description": "The value of the next index property; empty for null" }, "kinds": [ - "Tree" + "Tree", + "CountTree" ], + "kinds_note": "A count tree for the last value of the index when the poll started at protocol version 14 or later, counting its contenders plus its stored info, abstain and lock entries, so a join reads how many contenders the poll has in one fetch. A tree otherwise.", "flags": [ "EpochOwned", "None" diff --git a/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/mod.rs index 370100c7871..823e9a675cf 100644 --- a/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/mod.rs @@ -9,9 +9,12 @@ use platform_version::version::PlatformVersion; use std::collections::HashMap; mod v0; +mod v1; impl Drive { - /// Adds indices for an index level and recurses. + /// Adds the contested index entries of a document: a tree per index value, and below the + /// last value the document's contender entry and the poll's abstain and lock trees. + /// Version 1 writes the last value as a count tree. /// Will return true if the contest already existed pub(crate) fn add_contested_indices_for_contract_operations( &self, @@ -39,9 +42,17 @@ impl Drive { batch_operations, platform_version, ), + 1 => self.add_contested_indices_for_contract_operations_v1( + document_and_contract_info, + previous_batch_operations, + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_contested_indices_for_contract_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/v1/mod.rs new file mode 100644 index 00000000000..5db80572fe4 --- /dev/null +++ b/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/v1/mod.rs @@ -0,0 +1,443 @@ +use crate::util::grove_operations::BatchInsertTreeApplyType; + +use crate::drive::Drive; +use crate::util::object_size_info::{ + DocumentAndContractInfo, DocumentInfoV0Methods, DriveKeyInfo, PathInfo, +}; + +use crate::error::fee::FeeError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + +use crate::drive::votes::paths::{ + vote_contested_resource_contract_documents_indexes_path_vec, + RESOURCE_ABSTAIN_VOTE_TREE_KEY_U8_32, RESOURCE_LOCK_VOTE_TREE_KEY_U8_32, +}; +use crate::error::drive::DriveError; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; +use dpp::data_contract::document_type::IndexProperty; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; +use grovedb::EstimatedLayerSizes::AllSubtrees; +use grovedb::EstimatedSumTrees::{AllCountTrees, NoSumTrees}; +use grovedb::{EstimatedLayerInformation, TransactionArg, TreeType}; +use std::collections::HashMap; + +impl Drive { + /// Adds contested indices for the contract operations + /// Will return true if the contest already existed + /// + /// Version 1 (protocol version 14) writes the last index value, the tree holding the + /// poll's contenders with its stored info, abstain and lock entries, as a count tree, so + /// the number of contenders a join is checked against is one element read. A poll whose + /// last index value already exists keeps the tree it has: a poll started before protocol + /// version 14 goes on in its plain tree. + #[inline(always)] + pub(super) fn add_contested_indices_for_contract_operations_v1( + &self, + document_and_contract_info: &DocumentAndContractInfo, + previous_batch_operations: &mut Option<&mut Vec>, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + batch_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + let drive_version = &platform_version.drive; + let owner_id = document_and_contract_info + .owned_document_info + .owner_id + .ok_or(Error::Drive(DriveError::ContestedDocumentMissingOwnerId( + "expecting an owner id", + )))?; + let contested_index = document_and_contract_info + .document_type + .find_contested_index() + .ok_or(Error::Drive(DriveError::ContestedIndexNotFound( + "a contested index is expected", + )))?; + let document_type = document_and_contract_info.document_type; + let storage_flags = document_and_contract_info + .owned_document_info + .document_info + .get_storage_flags_ref(); + + // we need to construct the path for documents on the contract + // the path is + // * Document and DataContract root tree + // * DataContract ID recovered from document + // * 0 to signify Documents and notDataContract + let contract_document_type_path = + vote_contested_resource_contract_documents_indexes_path_vec( + document_and_contract_info.contract.id_ref().as_bytes(), + document_and_contract_info.document_type.name(), + ); + + // Every index value tree sits in a plain tree and is plain, except the last one, a + // count tree; the poll's choices sit in that count tree and are plain + let estimating = estimated_costs_only_with_layer_info.is_some(); + let flags_len = storage_flags + .map(|s| s.serialized_size()) + .unwrap_or_default(); + let apply_type = |in_tree_type: TreeType, tree_type: TreeType| { + if estimating { + BatchInsertTreeApplyType::StatelessBatchInsertTree { + in_tree_type, + tree_type, + flags_len, + } + } else { + BatchInsertTreeApplyType::StatefulBatchInsertTree + } + }; + let choice_apply_type = apply_type(TreeType::CountTree, TreeType::NormalTree); + + // at this point the contract path is to the contract documents + // for each index the top index component will already have been added + // when the contract itself was created + let index_path: Vec> = contract_document_type_path.clone(); + + let mut index_path_info = if document_and_contract_info + .owned_document_info + .document_info + .is_document_size() + { + // This is a stateless operation + PathInfo::PathWithSizes(KeyInfoPath::from_known_owned_path(index_path)) + } else { + PathInfo::PathAsVec::<0>(index_path) + }; + + let mut contest_already_existed = true; + + let last_property_position = contested_index.properties.len().saturating_sub(1); + + // next we need to store a reference to the document for each index + for (position, IndexProperty { name, .. }) in contested_index.properties.iter().enumerate() + { + let value_tree_type = if position == last_property_position { + TreeType::CountTree + } else { + TreeType::NormalTree + }; + + // We on purpose do not want to put index names + // This is different from document secondary indexes + // The reason is that there is only one index so we already know the structure + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info + { + let document_top_field_estimated_size = document_and_contract_info + .owned_document_info + .document_info + .get_estimated_size_for_document_type(name, document_type, platform_version)?; + + if document_top_field_estimated_size > u8::MAX as u16 { + return Err(Error::Fee(FeeError::Overflow( + "document field is too big for being an index on delete", + ))); + } + + // On this level we will have all the user defined values for the paths + estimated_costs_only_with_layer_info.insert( + index_path_info.clone().convert_to_key_info_path(), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: PotentiallyAtMaxElements, + estimated_layer_sizes: AllSubtrees( + document_top_field_estimated_size as u8, + if value_tree_type == TreeType::CountTree { + AllCountTrees + } else { + NoSumTrees + }, + storage_flags.map(|s| s.serialized_size()), + ), + }, + ); + } + + // with the example of the dashpay contract's first index + // the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId + let document_top_field = document_and_contract_info + .owned_document_info + .document_info + .get_raw_for_document_type( + name, + document_type, + document_and_contract_info.owned_document_info.owner_id, + None, //we should never need this in contested documents + platform_version, + )? + .unwrap_or_default(); + + // here we are inserting an empty tree that will have a subtree of all other index properties + let inserted = self.batch_insert_empty_tree_if_not_exists( + document_top_field + .clone() + .add_path_info(index_path_info.clone()), + value_tree_type, + storage_flags, + apply_type(TreeType::NormalTree, value_tree_type), + transaction, + previous_batch_operations, + batch_operations, + drive_version, + )?; + + // if we insert anything, that means that the contest didn't already exist + if contest_already_existed { + contest_already_existed &= !inserted; + } + + index_path_info.push(document_top_field)?; + } + + // Under each tree we have all identifiers of identities that want the contested resource + // Contrary to normal secondary indexes there are no property names and there is no termination key "0" + // We get something like + // Inter-wizard championship (event type) + // | + // Goblet of Fire (event name) <---- We just inserted this + // / \ + // Sam's ID Ivan's ID <---- We now need to insert at this level + // / \ / \ + // 0 (ref) 1 (sum tree) 0 (ref) 1 (sum tree) + // + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + // On this level we will have all the identities + estimated_costs_only_with_layer_info.insert( + index_path_info.clone().convert_to_key_info_path(), + EstimatedLayerInformation { + tree_type: TreeType::CountTree, + estimated_layer_count: ApproximateElements(16), // very seldom would more than 16 people want the resource + estimated_layer_sizes: AllSubtrees( + DEFAULT_HASH_SIZE_U8, + NoSumTrees, + storage_flags.map(|s| s.serialized_size()), + ), + }, + ); + } + + self.batch_insert_empty_tree_if_not_exists( + DriveKeyInfo::Key(owner_id.to_vec()).add_path_info(index_path_info.clone()), + TreeType::NormalTree, + storage_flags, + choice_apply_type, + transaction, + previous_batch_operations, + batch_operations, + drive_version, + )?; + + let inserted_abstain = self.batch_insert_empty_tree_if_not_exists( + DriveKeyInfo::Key(RESOURCE_ABSTAIN_VOTE_TREE_KEY_U8_32.to_vec()) + .add_path_info(index_path_info.clone()), + TreeType::NormalTree, + storage_flags, + choice_apply_type, + transaction, + previous_batch_operations, + batch_operations, + drive_version, + )?; + + let inserted_lock = self.batch_insert_empty_tree_if_not_exists( + DriveKeyInfo::Key(RESOURCE_LOCK_VOTE_TREE_KEY_U8_32.to_vec()) + .add_path_info(index_path_info.clone()), + TreeType::NormalTree, + storage_flags, + choice_apply_type, + transaction, + previous_batch_operations, + batch_operations, + drive_version, + )?; + + let mut towards_identity_index_path_info = index_path_info.clone(); + towards_identity_index_path_info.push(DriveKeyInfo::Key(owner_id.to_vec()))?; + + // Inter-wizard championship (event type) + // | + // Goblet of Fire (event name) + // / \ + // Sam's ID Ivan's ID <---- We just inserted this + // / \ / \ + // 0 (ref) 1 (sum tree) 0 (ref) 1 (sum tree) <---- We now need to insert at this level + // + + self.add_contested_reference_and_vote_subtree_to_document_operations( + document_and_contract_info, + towards_identity_index_path_info, + storage_flags, + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + drive_version, + )?; + + if inserted_abstain { + let mut towards_abstain_index_path_info = index_path_info.clone(); + towards_abstain_index_path_info.push(DriveKeyInfo::Key( + RESOURCE_ABSTAIN_VOTE_TREE_KEY_U8_32.to_vec(), + ))?; + + self.add_contested_vote_subtree_for_non_identities_operations( + towards_abstain_index_path_info, + storage_flags, + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + drive_version, + )?; + } + + if inserted_lock { + let mut towards_lock_index_path_info = index_path_info; + towards_lock_index_path_info.push(DriveKeyInfo::Key( + RESOURCE_LOCK_VOTE_TREE_KEY_U8_32.to_vec(), + ))?; + + self.add_contested_vote_subtree_for_non_identities_operations( + towards_lock_index_path_info, + storage_flags, + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + drive_version, + )?; + } + + Ok(contest_already_existed) + } +} + +#[cfg(test)] +mod tests { + use crate::drive::votes::paths::VotePollPaths; + use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; + use crate::drive::Drive; + use crate::util::grove_operations::DirectQueryType; + use crate::util::storage_flags::StorageFlags; + use crate::util::test_helpers::add_dpns_name_contenders; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::data_contract::DataContract; + use dpp::identifier::Identifier; + use dpp::tests::fixtures::get_dpns_data_contract_fixture; + use dpp::version::PlatformVersion; + use grovedb::Element; + + /// A drive holding the DPNS contract + fn drive_with_dpns() -> (Drive, DataContract) { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let dpns_contract = get_dpns_data_contract_fixture( + Some(Identifier::from([7; 32])), + 0, + platform_version.protocol_version, + ) + .data_contract_owned(); + drive + .apply_contract( + &dpns_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the DPNS contract"); + (drive, dpns_contract) + } + + /// The elements of the poll's index values, the first one to the last one + fn index_value_elements( + drive: &Drive, + vote_poll: &ContestedDocumentResourceVotePollWithContractInfo, + ) -> Vec { + let platform_version = PlatformVersion::latest(); + let mut path = vote_poll + .contenders_path(platform_version) + .expect("expected the choices path"); + let mut elements = vec![]; + for _ in &vote_poll.index_values { + let key = path.pop().expect("expected an index value"); + elements.insert( + 0, + drive + .grove_get_raw( + path.as_slice().into(), + &key, + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to read the index value") + .expect("expected the index value"), + ); + } + elements + } + + /// The last index value counts the contenders plus the poll's stored info, abstain and + /// lock entries; the values before it stay plain trees + #[test] + fn should_write_the_last_index_value_of_a_new_poll_as_a_count_tree() { + let platform_version = PlatformVersion::latest(); + let (drive, dpns_contract) = drive_with_dpns(); + let vote_poll = add_dpns_name_contenders( + &drive, + &dpns_contract, + "quantum", + 0..4, + |_| 1, + &BlockInfo::default(), + platform_version, + ); + + let [parent, label]: [Element; 2] = index_value_elements(&drive, &vote_poll) + .try_into() + .expect("expected two index values"); + assert!(matches!(parent, Element::Tree(..)), "{parent:?}"); + assert!(matches!(label, Element::CountTree(_, 7, _)), "{label:?}"); + } + + /// A poll started before protocol version 14 goes on in its plain tree + #[test] + fn should_keep_the_plain_tree_of_a_poll_started_before_protocol_version_14() { + let platform_version = PlatformVersion::latest(); + let (drive, dpns_contract) = drive_with_dpns(); + add_dpns_name_contenders( + &drive, + &dpns_contract, + "quantum", + 0..2, + |_| 1, + &BlockInfo::default(), + PlatformVersion::get(13).expect("expected version 13"), + ); + let vote_poll = add_dpns_name_contenders( + &drive, + &dpns_contract, + "quantum", + 2..4, + |_| 1, + &BlockInfo::default(), + platform_version, + ); + + let [parent, label]: [Element; 2] = index_value_elements(&drive, &vote_poll) + .try_into() + .expect("expected two index values"); + assert!(matches!(parent, Element::Tree(..)), "{parent:?}"); + assert!(matches!(label, Element::Tree(..)), "{label:?}"); + } +} diff --git a/packages/rs-drive/src/drive/document/insert_contested/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/mod.rs index 0354148c72e..425c37887b4 100644 --- a/packages/rs-drive/src/drive/document/insert_contested/mod.rs +++ b/packages/rs-drive/src/drive/document/insert_contested/mod.rs @@ -572,7 +572,7 @@ mod tests { /// `add_contested_document_for_contract` targeting a document type /// that has no contested index (`preorder` in DPNS) must surface /// `DriveError::ContestedIndexNotFound` from - /// `add_contested_indices_for_contract_operations_v0`. + /// `add_contested_indices_for_contract_operations`. #[test] fn add_contested_document_for_contract_errors_on_missing_contested_index() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_contested_document_vote_poll_contender_count/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_contested_document_vote_poll_contender_count/mod.rs new file mode 100644 index 00000000000..b25c5469d7f --- /dev/null +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_contested_document_vote_poll_contender_count/mod.rs @@ -0,0 +1,63 @@ +mod v0; + +use crate::drive::Drive; + +use crate::error::drive::DriveError; +use crate::error::Error; + +use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +use dpp::block::epoch::Epoch; +use dpp::fee::fee_result::FeeResult; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Fetches how many contenders a contested document resource vote poll holds, counting at + /// most `count_limit` of them. + /// + /// A poll started from protocol version 14 holds its choices in a count tree, and its + /// contenders are counted from that tree's element in one read. A poll started before + /// holds them in a plain tree, and its contenders are counted by a query of at most + /// `count_limit` of their keys. + /// + /// # Parameters + /// - `vote_poll`: The contested document resource vote poll. + /// - `count_limit`: The most contenders counted; a poll holding more is reported as + /// holding `count_limit`. + /// - `epoch`: The epoch the reads are billed in. + /// - `transaction`: The transaction to read in. + /// - `platform_version`: The platform version. + /// + /// # Returns + /// The fee of the reads and the number of contenders, at most `count_limit`; 0 for a poll + /// that has none, or does not exist. + pub fn fetch_contested_document_vote_poll_contender_count( + &self, + vote_poll: &ContestedDocumentResourceVotePollWithContractInfo, + count_limit: u16, + epoch: &Epoch, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(FeeResult, u16), Error> { + match platform_version + .drive + .methods + .vote + .fetch + .fetch_contested_document_vote_poll_contender_count + { + 0 => self.fetch_contested_document_vote_poll_contender_count_v0( + vote_poll, + count_limit, + epoch, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_contested_document_vote_poll_contender_count".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_contested_document_vote_poll_contender_count/v0/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_contested_document_vote_poll_contender_count/v0/mod.rs new file mode 100644 index 00000000000..da8a7430d29 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_contested_document_vote_poll_contender_count/v0/mod.rs @@ -0,0 +1,275 @@ +use crate::drive::votes::paths::{VotePollPaths, RESOURCE_LOCK_VOTE_TREE_KEY_U8_32}; +use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::query::GroveError; +use crate::util::grove_operations::DirectQueryType; +use dpp::block::epoch::Epoch; +use dpp::fee::fee_result::FeeResult; +use grovedb::query_result_type::QueryResultType; +use grovedb::{Element, PathQuery, Query, SizedQuery, TransactionArg}; +use platform_version::version::PlatformVersion; + +/// The entries beside the contenders in the tree holding a poll's choices: the poll's stored +/// info, written with its first contender and kept after it ends, and its abstain and lock vote +/// trees, written with every contender when missing and removed when the poll ends. A poll with +/// a contender holds all three; one cleaned up after it ended holds its stored info alone. +const NON_CONTENDER_CHOICE_ENTRIES: u64 = 3; + +impl Drive { + /// Fetches how many contenders a contested document resource vote poll holds, counting at + /// most `count_limit` of them + #[inline(always)] + pub(super) fn fetch_contested_document_vote_poll_contender_count_v0( + &self, + vote_poll: &ContestedDocumentResourceVotePollWithContractInfo, + count_limit: u16, + epoch: &Epoch, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(FeeResult, u16), Error> { + let (parent_path, Some(choices_key)) = vote_poll.last_index_path(platform_version)? else { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a contested index has at least one property", + ))); + }; + let mut cost_operations = vec![]; + let choices = match self.grove_get_raw_optional( + parent_path.as_slice().into(), + choices_key.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + &mut cost_operations, + &platform_version.drive, + ) { + Ok(choices) => choices, + Err(Error::GroveDB(error)) + if matches!( + *error, + GroveError::PathNotFound(_) + | GroveError::PathParentLayerNotFound(_) + | GroveError::PathKeyNotFound(_) + ) => + { + None + } + Err(error) => return Err(error), + }; + + let contenders = match choices { + None => 0, + // A poll started from protocol version 14 + Some(Element::CountTree(_, count, _)) => count + .saturating_sub(NON_CONTENDER_CHOICE_ENTRIES) + .min(count_limit as u64) + as u16, + // A poll started before protocol version 14: its contenders are the keys after the + // lock tree's, the last of the reserved keys + Some(Element::Tree(..)) => { + let mut query = Query::new(); + query.insert_range_after(RESOURCE_LOCK_VOTE_TREE_KEY_U8_32.to_vec()..); + let path_query = PathQuery::new( + vote_poll.contenders_path(platform_version)?, + SizedQuery::new(query, Some(count_limit), None), + ); + let (keys, _) = self.grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryKeyElementPairResultType, + &mut cost_operations, + &platform_version.drive, + )?; + u16::try_from(keys.len()).map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a query limited to a u16 returns at most a u16 of results", + )) + })? + } + Some(_) => { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "the choices of a contested vote poll must be held in a tree".to_string(), + ))) + } + }; + + let fee_result = Drive::calculate_fee( + None, + Some(cost_operations), + epoch, + self.config.epochs_per_era, + platform_version, + None, + )?; + Ok((fee_result, contenders)) + } +} + +#[cfg(test)] +mod tests { + use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; + use crate::drive::Drive; + use crate::util::object_size_info::DataContractOwnedResolvedInfo; + use crate::util::storage_flags::StorageFlags; + use crate::util::test_helpers::add_dpns_name_contenders; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::data_contract::DataContract; + use dpp::identifier::Identifier; + use dpp::tests::fixtures::get_dpns_data_contract_fixture; + use platform_version::version::PlatformVersion; + + /// A drive holding the DPNS contract + fn drive_with_dpns() -> (Drive, DataContract) { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let dpns_contract = get_dpns_data_contract_fixture( + Some(Identifier::from([7; 32])), + 0, + platform_version.protocol_version, + ) + .data_contract_owned(); + drive + .apply_contract( + &dpns_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the DPNS contract"); + (drive, dpns_contract) + } + + #[test] + fn should_count_the_contenders_of_a_poll_up_to_the_limit() { + let platform_version = PlatformVersion::latest(); + let (drive, dpns_contract) = drive_with_dpns(); + let vote_poll = add_dpns_name_contenders( + &drive, + &dpns_contract, + "quantum", + 0..5, + |_| 1, + &BlockInfo::default(), + platform_version, + ); + + for (count_limit, expected) in [(10, 5), (5, 5), (3, 3)] { + let (_, contenders) = drive + .fetch_contested_document_vote_poll_contender_count( + &vote_poll, + count_limit, + &Default::default(), + None, + platform_version, + ) + .expect("expected the contender count"); + assert_eq!(contenders, expected, "counting at most {count_limit}"); + } + } + + /// The count is one element read, billed the same whatever the number of contenders + #[test] + fn should_read_the_count_of_a_poll_in_one_fetch_whatever_its_contenders() { + let platform_version = PlatformVersion::latest(); + let [few, many] = [2, 40].map(|contenders| { + let (drive, dpns_contract) = drive_with_dpns(); + let vote_poll = add_dpns_name_contenders( + &drive, + &dpns_contract, + "quantum", + 0..contenders, + |_| 1, + &BlockInfo::default(), + platform_version, + ); + drive + .fetch_contested_document_vote_poll_contender_count( + &vote_poll, + 100, + &Default::default(), + None, + platform_version, + ) + .expect("expected the contender count") + }); + assert_eq!((few.1, many.1), (2, 40)); + assert_eq!(few.0, many.0); + } + + #[test] + fn should_count_no_contenders_for_a_poll_that_does_not_exist() { + let platform_version = PlatformVersion::latest(); + let (drive, dpns_contract) = drive_with_dpns(); + add_dpns_name_contenders( + &drive, + &dpns_contract, + "quantum", + 0..2, + |_| 1, + &BlockInfo::default(), + platform_version, + ); + // Another label under the same parent, and a parent nothing is under + for (parent, label) in [("dash", "coolio"), ("nothing", "coolio")] { + let vote_poll = ContestedDocumentResourceVotePollWithContractInfo { + contract: DataContractOwnedResolvedInfo::OwnedDataContract(dpns_contract.clone()), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![parent.into(), label.into()], + }; + let (_, contenders) = drive + .fetch_contested_document_vote_poll_contender_count( + &vote_poll, + 10, + &Default::default(), + None, + platform_version, + ) + .expect("expected the contender count"); + assert_eq!(contenders, 0, "{parent}/{label}"); + } + } + + /// A poll started before protocol version 14 keeps a plain tree, and has its contenders + /// counted by their keys, the ones joining at 14 included + #[test] + fn should_count_the_contenders_of_a_poll_started_before_protocol_version_14() { + let platform_version = PlatformVersion::latest(); + let protocol_version_13 = PlatformVersion::get(13).expect("expected version 13"); + let (drive, dpns_contract) = drive_with_dpns(); + add_dpns_name_contenders( + &drive, + &dpns_contract, + "quantum", + 0..3, + |_| 1, + &BlockInfo::default(), + protocol_version_13, + ); + let vote_poll = add_dpns_name_contenders( + &drive, + &dpns_contract, + "quantum", + 3..5, + |_| 1, + &BlockInfo::default(), + platform_version, + ); + + for (count_limit, expected) in [(10, 5), (4, 4)] { + let (_, contenders) = drive + .fetch_contested_document_vote_poll_contender_count( + &vote_poll, + count_limit, + &Default::default(), + None, + platform_version, + ) + .expect("expected the contender count"); + assert_eq!(contenders, expected, "counting at most {count_limit}"); + } + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/mod.rs b/packages/rs-drive/src/drive/votes/fetch/mod.rs index 74748b04f0c..d4393ec6b95 100644 --- a/packages/rs-drive/src/drive/votes/fetch/mod.rs +++ b/packages/rs-drive/src/drive/votes/fetch/mod.rs @@ -1,3 +1,4 @@ +mod fetch_contested_document_vote_poll_contender_count; mod fetch_contested_document_vote_poll_stored_info; mod fetch_identities_voting_for_contenders; mod fetch_identity_contested_resource_vote; diff --git a/packages/rs-drive/src/drive/votes/structure.rs b/packages/rs-drive/src/drive/votes/structure.rs index 42ef6631b51..9163214b1bb 100644 --- a/packages/rs-drive/src/drive/votes/structure.rs +++ b/packages/rs-drive/src/drive/votes/structure.rs @@ -18,6 +18,12 @@ const CONTESTED_DOCUMENT: &str = "votes.contested_resource.active_polls.contract.document_type.storage.document"; const INDEX_VALUE: &str = "votes.contested_resource.active_polls.contract.document_type.indexes.value"; +const INDEX_VALUE_TREES: [ElementKind; 2] = [ElementKind::Tree, ElementKind::CountTree]; +const INDEX_VALUE_NOTE: &str = + "A count tree for the last value of the index when the poll started at \ + protocol version 14 or later, counting its contenders plus its stored \ + info, abstain and lock entries, so a join reads how many contenders the \ + poll has in one fetch. A tree otherwise."; fn voting_storage() -> StructureNode { StructureNode::fixed( @@ -249,7 +255,7 @@ fn index_value() -> StructureNode { "The value of the next index property; empty for \ null", ) - .kind(ElementKind::Tree) + .kinds(&INDEX_VALUE_TREES, INDEX_VALUE_NOTE) .flags(&OWNED, CONTENDER_FLAGS) .describe( "One value of the contested index. Below the last \ @@ -313,7 +319,7 @@ fn index_value() -> StructureNode { "The value of the next index property; empty for \ null", ) - .kind(ElementKind::Tree) + .kinds(&INDEX_VALUE_TREES, INDEX_VALUE_NOTE) .flags(&OWNED, CONTENDER_FLAGS) .recurse(INDEX_VALUE) .describe( diff --git a/packages/rs-drive/src/util/test_helpers/mod.rs b/packages/rs-drive/src/util/test_helpers/mod.rs index 956cfb4d123..e76cf8a44ef 100644 --- a/packages/rs-drive/src/util/test_helpers/mod.rs +++ b/packages/rs-drive/src/util/test_helpers/mod.rs @@ -42,6 +42,33 @@ use std::collections::{BTreeMap, BTreeSet}; #[cfg(test)] use ciborium::value::Value; +#[cfg(test)] +use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +#[cfg(test)] +use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; +#[cfg(test)] +use crate::util::object_size_info::{DataContractOwnedResolvedInfo, OwnedDocumentInfo}; +#[cfg(test)] +use crate::util::storage_flags::StorageFlags; +#[cfg(test)] +use dpp::data_contract::accessors::v0::DataContractV0Getters; +#[cfg(test)] +use dpp::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, +}; +#[cfg(test)] +use dpp::document::DocumentV0Setters; +#[cfg(test)] +use dpp::platform_value; +#[cfg(test)] +use dpp::platform_value::Bytes32; +#[cfg(test)] +use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::ContestedDocumentVotePollStoredInfo; +#[cfg(test)] +use rand::rngs::StdRng; +#[cfg(test)] +use rand::SeedableRng; + #[cfg(any(test, feature = "server"))] pub mod setup; #[cfg(any(test, feature = "fixtures-and-mocks"))] @@ -127,6 +154,89 @@ pub fn vote_poll_end_dates( .collect() } +#[cfg(test)] +/// The identity id of DPNS name contender `n` of [`add_dpns_name_contenders`]: `n + 1` big +/// endian in its first 8 bytes, so contenders sort by `n`. +pub(crate) fn dpns_name_contender_id(n: u64) -> Identifier { + let mut id = [0u8; 32]; + id[..8].copy_from_slice(&(n + 1).to_be_bytes()); + Identifier::from(id) +} + +#[cfg(test)] +/// Adds contenders `contenders` (see [`dpns_name_contender_id`]) to the contest on the DPNS name +/// `label` under `dash`, written straight to Drive at `block_info` with no validation. Contender +/// `n`'s document is created at `created_at(n)`. Contender 0 starts the contest, writing its +/// stored info. Returns the poll. +pub(crate) fn add_dpns_name_contenders( + drive: &Drive, + dpns_contract: &DataContract, + label: &str, + contenders: std::ops::Range, + created_at: impl Fn(u64) -> TimestampMillis, + block_info: &BlockInfo, + platform_version: &PlatformVersion, +) -> ContestedDocumentResourceVotePollWithContractInfo { + let document_type = dpns_contract + .document_type_for_name("domain") + .expect("expected the domain document type"); + let vote_poll = ContestedDocumentResourceVotePollWithContractInfo { + contract: DataContractOwnedResolvedInfo::OwnedDataContract(dpns_contract.clone()), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + platform_value::Value::Text("dash".to_string()), + platform_value::Value::Text(label.to_string()), + ], + }; + let mut rng = StdRng::seed_from_u64(contenders.start); + for n in contenders { + let owner_id = dpns_name_contender_id(n); + let mut document = document_type + .random_document_with_params( + owner_id, + Bytes32::random_with_rng(&mut rng), + Some(created_at(n)), + Some(block_info.height), + Some(block_info.core_height), + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::MinDocumentFillSize, + &mut rng, + platform_version, + ) + .expect("expected a random domain"); + document.set("parentDomainName", "dash".into()); + document.set("normalizedParentDomainName", "dash".into()); + document.set("label", label.into()); + document.set("normalizedLabel", label.into()); + document.set("records.identity", owner_id.into()); + document.set("subdomainRules.allowSubdomains", false.into()); + let stored_info = (n == 0).then(|| { + ContestedDocumentVotePollStoredInfo::new(*block_info, platform_version) + .expect("expected the poll's stored info") + }); + drive + .add_contested_document( + OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(owner_id.to_buffer()), + }, + vote_poll.clone(), + false, + stored_info, + block_info, + true, + None, + platform_version, + ) + .expect("expected to add the contender"); + } + vote_poll +} + #[cfg(test)] /// Serializes a hex string to CBOR. pub fn cbor_from_hex(hex_string: String) -> Vec { diff --git a/packages/rs-drive/src/verify/voting/verify_vote_poll_vote_state_proof/v0/mod.rs b/packages/rs-drive/src/verify/voting/verify_vote_poll_vote_state_proof/v0/mod.rs index 7202a3dc16b..2d869927fc9 100644 --- a/packages/rs-drive/src/verify/voting/verify_vote_poll_vote_state_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/voting/verify_vote_poll_vote_state_proof/v0/mod.rs @@ -353,9 +353,13 @@ mod tests { use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfoAllowBorrowed; use crate::query::vote_poll_vote_state_query::ContestedDocumentVotePollDriveQueryResultType; use crate::util::object_size_info::DataContractResolvedInfo; + use crate::util::storage_flags::StorageFlags; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use crate::util::test_helpers::{add_dpns_name_contenders, dpns_name_contender_id}; use dpp::block::block_info::BlockInfo; + use dpp::tests::fixtures::get_dpns_data_contract_fixture; use dpp::tests::json_document::json_document_to_contract; + use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use std::sync::Arc; #[test] @@ -417,4 +421,132 @@ mod tests { assert_eq!(result.abstaining_vote_tally, None); assert_eq!(result.winner, None); } + + /// A poll holding the most contenders a contest accepts, its choices in a count tree, is + /// read page by page as clients read it: each page proved, verified against the state and + /// equal to the unproved read, the pages together every contender once, in identity order + #[test] + fn should_page_proved_vote_states_through_the_most_contenders_a_contest_accepts() { + let platform_version = PlatformVersion::latest(); + let contenders = platform_version.system_limits.max_contenders_per_contest as u64; + let page_size = 100; + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let dpns_contract = get_dpns_data_contract_fixture( + Some(Identifier::from([7; 32])), + 0, + platform_version.protocol_version, + ) + .data_contract_owned(); + drive + .apply_contract( + &dpns_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the DPNS contract"); + let vote_poll = add_dpns_name_contenders( + &drive, + &dpns_contract, + "quantum", + 0..contenders, + |_| 1, + &BlockInfo::default(), + platform_version, + ); + // Every seventh contender gets a vote, and one vote locks + let votes = (0..contenders) + .step_by(7) + .map(|n| ResourceVoteChoice::TowardsIdentity(dpns_name_contender_id(n))) + .chain([ResourceVoteChoice::Lock]); + for (voter, choice) in votes.enumerate() { + let mut pro_tx_hash = [0xFFu8; 32]; + pro_tx_hash[..8].copy_from_slice(&(voter as u64).to_be_bytes()); + drive + .register_contested_resource_identity_vote( + pro_tx_hash, + 1, + vote_poll.clone(), + choice, + None, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected to register the vote"); + } + let root_hash = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("expected the root hash"); + + let mut read = vec![]; + let mut start_at = None; + let mut pages = 0; + loop { + let query = ResolvedContestedDocumentVotePollDriveQuery { + vote_poll: (&vote_poll).into(), + result_type: ContestedDocumentVotePollDriveQueryResultType::DocumentsAndVoteTally, + offset: None, + limit: Some(page_size), + start_at, + allow_include_locked_and_abstaining_vote_tally: start_at.is_none(), + }; + let proof = drive + .grove_get_proved_path_query( + &query + .construct_path_query(platform_version) + .expect("expected the path query"), + None, + &mut vec![], + &platform_version.drive, + ) + .expect("expected the proof"); + let (proved_root_hash, proved) = query + .verify_vote_poll_vote_state_proof(proof.as_slice(), platform_version) + .expect("expected the proof to verify"); + assert_eq!(proved_root_hash, root_hash, "page {pages}"); + let unproved = query + .execute(&drive, None, &mut vec![], platform_version) + .expect("expected the unproved read"); + assert_eq!(proved, unproved, "page {pages}"); + if pages == 0 { + assert_eq!(proved.locked_vote_tally, Some(1)); + assert_eq!(proved.abstaining_vote_tally, Some(0)); + } + pages += 1; + + let last = proved + .contenders + .last() + .map(|contender| contender.identity_id()); + let full_page = proved.contenders.len() == page_size as usize; + read.extend(proved.contenders); + match last { + Some(last) if full_page => start_at = Some((last.to_buffer(), false)), + _ => break, + } + } + + assert_eq!(pages, contenders as usize / page_size as usize + 1); + assert_eq!( + read.iter() + .map(|contender| contender.identity_id()) + .collect::>(), + (0..contenders) + .map(dpns_name_contender_id) + .collect::>() + ); + for (n, contender) in read.iter().enumerate() { + assert!(contender.serialized_document().is_some(), "contender {n}"); + assert_eq!( + contender.vote_tally(), + Some(u32::from(n % 7 == 0)), + "contender {n}" + ); + } + } } diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 4ba0915044f..2bfdf272eb1 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -31,6 +31,12 @@ use crate::version::drive_abci_versions::drive_abci_validation_versions::{ // (DocumentPropertyConstraintViolatedError, 10422): the check runs inside dpp's // `DataContract::validate_document_properties` 0, which both call, and is inert // before this version through its own dpp gate. +// Document create state validation 2 also refuses a document that would add a +// contender to a contest holding `max_contenders_per_contest` already +// (DocumentContestMaximumContendersReachedError, 40141), and +// `maximum_contenders_to_consider` rises from 100 to 10,000 so the end of a poll +// tallies and cleans up every contender of a poll within the 1,000 a contest +// accepts, and up to 10,000 of one that grew past it before this version. // v9 remains unchanged for PROTOCOL_VERSION_13 chain replay. pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = DriveAbciValidationVersions { @@ -402,7 +408,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = }, event_constants: DriveAbciValidationConstants { maximum_vote_polls_to_process: 2, - maximum_contenders_to_consider: 100, + // Raised for protocol 14 above the most contenders a contest accepts + // (`max_contenders_per_contest`, 1,000), so the tally and the cleanup at the end of a + // poll reach every contender of a poll within it, and up to 10,000 of one that grew + // past it before 14. The tally reads only the contenders there are; 10,000 x 2 + 3 + // results still fit the u16 query limit + maximum_contenders_to_consider: 10_000, minimum_pool_notes_for_outgoing: 250, shielded_anchor_retention_blocks: 1000, shielded_anchor_pruning_interval: 100, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs index 47bcd4b486b..d96dc74c78e 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs @@ -74,6 +74,14 @@ use crate::version::drive_versions::drive_document_method_versions::{ /// continuation demotion decides the *value* tree type, and the two /// levels never contend. See /// `packages/rs-drive/src/drive/document/index_level_tree_types.rs`. +/// +/// ## 3. Contenders counted +/// +/// `insert_contested.add_contested_indices_for_contract_operations: 0 → 1`: a +/// contested vote poll started from v14 holds its choices under its last index +/// value in a count tree, so the contender count a join is checked against +/// (`max_contenders_per_contest`) is one element read. A poll started before +/// keeps its plain tree for the rest of its life. pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = DriveDocumentMethodVersions { query: DriveDocumentQueryMethodVersions { @@ -135,7 +143,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = add_contested_document_for_contract_apply_and_add_to_operations: 0, add_contested_document_for_contract_operations: 1, // changed in v14: no-locking contests end at the join window until a second contender joins add_contested_document_to_primary_storage: 0, - add_contested_indices_for_contract_operations: 0, + add_contested_indices_for_contract_operations: 1, // changed in v14: a poll's last index level is a count tree, counting its contenders add_contested_reference_and_vote_subtree_to_document_operations: 0, add_contested_vote_subtree_for_non_identities_operations: 1, // changed in v4: recreates the abstain or lock vote tree over the storage an earlier poll's cleanup left orphaned when a resource is contested again fetch_charter_election_windows: Some(0), // new in v14: a moderation election runs on the join and vote windows its target contract declares 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 6e715a719c0..3b268472ea2 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 @@ -19,6 +19,8 @@ pub struct DriveVoteFetchMethodVersions { pub fetch_identities_voting_for_contenders: FeatureVersion, pub fetch_contested_document_vote_poll_stored_info: FeatureVersion, pub fetch_identity_contested_resource_vote: FeatureVersion, + /// Read by the contested document create state validation v2 (protocol version 14) only. + pub fetch_contested_document_vote_poll_contender_count: FeatureVersion, } #[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 1e7a59dd95e..17c779d2b9d 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 @@ -34,5 +34,6 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V1: DriveVoteMethodVersions = DriveVoteMeth fetch_identities_voting_for_contenders: 0, fetch_contested_document_vote_poll_stored_info: 0, fetch_identity_contested_resource_vote: 0, + fetch_contested_document_vote_poll_contender_count: 0, }, }; 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 ef99612f58d..4a7c24f091a 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 @@ -34,5 +34,6 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V2: DriveVoteMethodVersions = DriveVoteMeth fetch_identities_voting_for_contenders: 0, fetch_contested_document_vote_poll_stored_info: 0, fetch_identity_contested_resource_vote: 0, + fetch_contested_document_vote_poll_contender_count: 0, }, }; 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 1aa78b301d7..f25e7a74859 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 @@ -9,7 +9,9 @@ use crate::version::drive_versions::drive_vote_method_versions::{ /// Identical to [`super::v2::DRIVE_VOTE_METHOD_VERSIONS_V2`] except /// `cleanup.remove_contested_resource_vote_poll_end_date_query_operations` is /// bumped to `2`: the end-date cleanup of ended contested vote polls removes an -/// end date only once none of its polls remain. +/// end date only once none of its polls remain. It also carries +/// `fetch.fetch_contested_document_vote_poll_contender_count` (0 in every table), +/// which only protocol v14's contested document create state validation reads. pub const DRIVE_VOTE_METHOD_VERSIONS_V3: DriveVoteMethodVersions = DriveVoteMethodVersions { insert: DriveVoteInsertMethodVersions { register_identity_vote: 0, @@ -40,5 +42,6 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V3: DriveVoteMethodVersions = DriveVoteMeth fetch_identities_voting_for_contenders: 0, fetch_contested_document_vote_poll_stored_info: 0, fetch_identity_contested_resource_vote: 0, + fetch_contested_document_vote_poll_contender_count: 0, }, }; 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 80ab0ade50b..66ab28d078a 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -602,6 +602,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_contract_moderation_challenge_cool_down_seconds: 94_608_000, contract_document_restore_window_ms: 604_800_000, max_contract_moderation_added_moderators: 15, + max_contenders_per_contest: 1_000, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, max_shielded_transition_actions: 16, 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 eac1ea7bd2c..a2a3766c207 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -197,6 +197,12 @@ pub struct SystemLimits { /// after the election (`maxAddedModerators`). Read by the declaration's validation /// (protocol version 14) and never reached before. pub max_contract_moderation_added_moderators: u16, + /// Most contenders one contested document resource vote poll accepts: a document that + /// would add one more is refused. The end of a poll tallies, and cleans up, every + /// contender in one block, so this bounds that work; `maximum_contenders_to_consider` + /// must stay at least this where it is read. Read by the contested document create + /// state validation v2 (protocol version 14) and never reached before. + pub max_contenders_per_contest: u16, // This the max redemption cycles we can process if we don't use a constant distribution // For a constant perpetual distribution this is very cheap since it's just a multiplication // For other distributions we much calculate at each cycle the rewards, so we don't want to 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 fe5a2b5afbc..08eac9bf4ac 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -63,6 +63,7 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days max_contract_moderation_added_moderators: 15, + max_contenders_per_contest: 1_000, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on 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 435d51100aa..5777773344d 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -44,6 +44,7 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days max_contract_moderation_added_moderators: 15, + max_contenders_per_contest: 1_000, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on 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 f72a37e82a4..7ca7b9686ec 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -46,6 +46,7 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days max_contract_moderation_added_moderators: 15, + max_contenders_per_contest: 1_000, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on 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 ff7df4a21fb..0cdee2788b5 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -79,6 +79,11 @@ use crate::version::system_limits::SystemLimits; /// seated team's leader add at most 15 members (`max_contract_moderation_added_moderators`), /// which joined this table in place while protocol version 14 was unreleased. A charter's /// description cap is the charter schema's own `maxBytes`, not a limit here. +/// * Contested documents (protocol version 14): a contest accepts at most 1,000 contenders +/// (`max_contenders_per_contest`, backfilled into the earlier tables, whose validation never +/// reads it). The end of a poll within the cap tallies and cleans up every contender in one +/// block; the end of one that grew past 10,000 before version 14, its first 10,000 +/// (`maximum_contenders_to_consider`). pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5120, //5 KiB @@ -119,6 +124,7 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days max_contract_moderation_added_moderators: 15, + max_contenders_per_contest: 1_000, max_token_redemption_cycles: 128, max_evonode_reward_claim_epochs: 100, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 7a8072f4f6e..06507996803 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1252,6 +1252,22 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// owner may still delete it where `canBeDeleted` allows. See /// `book/src/data-model/document-ttl.md`. /// +/// 50. **A contest accepts at most 1,000 contenders, and its end reaches every +/// one**: document create state validation 2 (`DRIVE_ABCI_VALIDATION_VERSIONS_V10`) +/// refuses, paid, a document that would add a contender to a contest holding +/// `max_contenders_per_contest` (`SYSTEM_LIMITS_V4`, 1,000) already +/// (`DocumentContestMaximumContendersReachedError`, 40141). +/// `add_contested_indices_for_contract_operations` 1 +/// (`DRIVE_DOCUMENT_METHOD_VERSIONS_V4`) writes the last index value of a +/// poll started from this version as a count tree, so the join reads the +/// count in one element fetch; a poll started before keeps its plain tree +/// and has its contenders counted by a keys query of at most 1,000. +/// `maximum_contenders_to_consider` rises from 100 to 10,000, so the tally +/// of an ended poll, and the cleanup built from it, cover every contender +/// of a poll within the cap, and up to 10,000 of one that grew past it +/// before this version. `check_for_ended_vote_polls` 1 compares every tied +/// contender; version 0 compared at most 100. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's @@ -1316,11 +1332,11 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// its gates on; Drive identity methods v2 rewrite the key and raise the remaining budget). pub const PLATFORM_V14: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_14, - drive: DRIVE_VERSION_V9, // changed: drive document method versions v4 — v2 index walkers (shared-prefix aggregate indexes become insertable) + the detect_ranked_mode slot; contract method versions v4: the moderation list trees, the document removal record trees and the moderation method table; apply_drive_operations 1 (a moderator's document deletion refunds nobody; every write of one identity balance, fee pot or prefunded specialized balance in a batch merged into one; a batch writing one token balance or supply twice refused; repaid identity debt credited to the processing fee pool); index uniqueness gains validate_restored_document_uniqueness (a moderator's document restore); vote method versions v3: the end-date cleanup of ended contested vote polls removes an end date only once none of its polls remain; token method versions v2: evonode_participation_rewards 1 (an evonode's token claim covers only the epochs it read) + drive: DRIVE_VERSION_V9, // changed: drive document method versions v4 — v2 index walkers (shared-prefix aggregate indexes become insertable) + the detect_ranked_mode slot; contract method versions v4: the moderation list trees, the document removal record trees and the moderation method table; apply_drive_operations 1 (a moderator's document deletion refunds nobody; every write of one identity balance, fee pot or prefunded specialized balance in a batch merged into one; a batch writing one token balance or supply twice refused; repaid identity debt credited to the processing fee pool); index uniqueness gains validate_restored_document_uniqueness (a moderator's document restore); vote method versions v3: the end-date cleanup of ended contested vote polls removes an end date only once none of its polls remain; token method versions v2: evonode_participation_rewards 1 (an evonode's token claim covers only the epochs it read); add_contested_indices_for_contract_operations 1: a poll's last index value is a count tree drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, // changed: saved platform state structure 1 keeps masternodes and validator sets as one aux entry each methods: DRIVE_ABCI_METHOD_VERSIONS_V10, // changed: records the per-block total credits history for the daily withdrawal limit - validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation; the ContractUserModeration gates and the batch transformer's contract_moderation_gate + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation; the ContractUserModeration gates and the batch transformer's contract_moderation_gate; a contest accepts at most max_contenders_per_contest contenders and maximum_contenders_to_consider rises to 10,000 withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, // changed: prune bound for the total credits history query: DRIVE_ABCI_QUERY_VERSIONS_V3, // changed: ranked + boolean-HAVING routing gate; the v1 handler also resolves IN_TIME_RANGE from committed block time checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, @@ -1346,7 +1362,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { // the shared storage table; it is dead below v14 (the `ttl` grammar // does not parse), so no table fork is needed. fee_version: FEE_VERSION3, // changed: contested document contribution reduced to 0.1 DASH; masternode vote cost reduced to 0.00002 DASH; moderation election fund of 0.5 DASH; registration surcharge for once-per-identity token distributions - system_limits: SYSTEM_LIMITS_V4, // changed: daily withdrawal limit becomes 15% of the total credits a day ago + time-range overlap-factor cap (24) + time-range TTL cap (1 week) and per-write drop cap (32) + GroveDB proof envelope floor (V1); max_contract_moderators, max_contract_suspension_until, max_contract_moderation_reason_length, max_contract_warnings_per_identity, max_contract_moderation_reason_documents and contract_document_restore_window_ms (a week) + system_limits: SYSTEM_LIMITS_V4, // changed: daily withdrawal limit becomes 15% of the total credits a day ago + time-range overlap-factor cap (24) + time-range TTL cap (1 week) and per-write drop cap (32) + GroveDB proof envelope floor (V1); max_contract_moderators, max_contract_suspension_until, max_contract_moderation_reason_length, max_contract_warnings_per_identity, max_contract_moderation_reason_documents and contract_document_restore_window_ms (a week); max_contenders_per_contest (1,000) consensus: ConsensusVersions { tenderdash_consensus_version: 1, }, diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 4df48790552..e3fa679fd31 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -78,6 +78,7 @@ use dpp::consensus::state::data_contract::document_type_update_error::DocumentTy use dpp::consensus::state::document::document_contest_currently_locked_error::DocumentContestCurrentlyLockedError; use dpp::consensus::state::document::document_contest_document_with_same_id_already_present_error::DocumentContestDocumentWithSameIdAlreadyPresentError; use dpp::consensus::state::document::document_contest_identity_already_contestant::DocumentContestIdentityAlreadyContestantError; +use dpp::consensus::state::document::document_contest_maximum_contenders_reached_error::DocumentContestMaximumContendersReachedError; use dpp::consensus::state::document::document_contest_index_mismatch_error::DocumentContestIndexMismatchError; use dpp::consensus::state::document::document_contest_not_joinable_error::DocumentContestNotJoinableError; use dpp::consensus::state::document::document_contest_not_paid_for_error::DocumentContestNotPaidForError; @@ -721,6 +722,9 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::DocumentExpiredError(e) => { generic_consensus_error!(DocumentExpiredError, e).into() } + StateError::DocumentContestMaximumContendersReachedError(e) => { + generic_consensus_error!(DocumentContestMaximumContendersReachedError, e).into() + } } } From dc42371623e5119eed419e884ae560556404e0de Mon Sep 17 00:00:00 2001 From: PastaPastaPasta <6443210+PastaPastaPasta@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:27:42 -0500 Subject: [PATCH 009/113] ci: build release SDKs and NPM packages on self-hosted runners (#4562) Co-authored-by: Quantum Explorer Co-authored-by: Claude Fable 5.1 --- .../release-cargo-target-cache/action.yaml | 52 ++ .github/workflows/release-kotlin-sdk.yml | 240 +++++--- .github/workflows/release-swift-sdk.yml | 226 +++++--- .github/workflows/release.yml | 517 ++++++++++++++---- packages/swift-sdk/build_ios.sh | 4 +- scripts/pack_dashmate.sh | 5 + 6 files changed, 771 insertions(+), 273 deletions(-) create mode 100644 .github/actions/release-cargo-target-cache/action.yaml diff --git a/.github/actions/release-cargo-target-cache/action.yaml b/.github/actions/release-cargo-target-cache/action.yaml new file mode 100644 index 00000000000..c135fdcc1d5 --- /dev/null +++ b/.github/actions/release-cargo-target-cache/action.yaml @@ -0,0 +1,52 @@ +--- +name: "Release Cargo target cache" +description: >- + Point CARGO_TARGET_DIR at a per-workflow release cache outside the workspace + of a persistent self-hosted runner. Release jobs wipe the workspace before + checkout (PR jobs share it), so a cache inside it would never survive. The + cache is reset when it outgrows max-gib or the volume drops below + min-free-gib, so it cannot starve the PR jobs on the same runner of disk. +inputs: + name: + description: Cache directory name, unique per release workflow + required: true + max-gib: + description: Reset the cache when it grows past this many GiB + required: false + default: "60" + min-free-gib: + description: Reset the cache when the volume has less than this many GiB free + required: false + default: "40" +runs: + using: composite + steps: + - name: Prepare release Cargo target cache + # Quoted: a self-hosted runner's temp directory can contain spaces. + shell: bash --noprofile --norc -e -o pipefail "{0}" + env: + CACHE_NAME: ${{ inputs.name }} + MAX_GIB: ${{ inputs.max-gib }} + MIN_FREE_GIB: ${{ inputs.min-free-gib }} + run: | + set -euo pipefail + case "$CACHE_NAME" in + ''|*/*|.*) + echo "::error::Invalid release target cache name '$CACHE_NAME'." + exit 1 + ;; + esac + RELEASE_TARGET_DIR="$HOME/.cache/dash-platform/$CACHE_NAME" + MAX_CACHE_KIB=$((MAX_GIB * 1024 * 1024)) + MIN_FREE_KIB=$((MIN_FREE_GIB * 1024 * 1024)) + mkdir -p "$RELEASE_TARGET_DIR" + USED_KIB=$(du -sk "$RELEASE_TARGET_DIR" | cut -f1) + FREE_KIB=$(df -Pk "$RELEASE_TARGET_DIR" | awk 'NR == 2 { print $4 }') + if [ "$USED_KIB" -gt "$MAX_CACHE_KIB" ] || [ "$FREE_KIB" -lt "$MIN_FREE_KIB" ]; then + echo "::notice::Resetting the release target cache (${USED_KIB} KiB used, ${FREE_KIB} KiB free on the volume)." + rm -rf "${RELEASE_TARGET_DIR:?}" + mkdir -p "$RELEASE_TARGET_DIR" + fi + du -sh "$RELEASE_TARGET_DIR" + df -h "$RELEASE_TARGET_DIR" + echo "CARGO_TARGET_DIR=$RELEASE_TARGET_DIR" >> "$GITHUB_ENV" diff --git a/.github/workflows/release-kotlin-sdk.yml b/.github/workflows/release-kotlin-sdk.yml index 8286d0b371e..b14f6e40b0f 100644 --- a/.github/workflows/release-kotlin-sdk.yml +++ b/.github/workflows/release-kotlin-sdk.yml @@ -37,10 +37,17 @@ on: jobs: build-and-release: name: Build release AAR (arm64-v8a + x86_64) - runs-on: ubuntu-24.04 + # Same persistent runner as kotlin-sdk-build.yml, so the multi-hour + # cargo/NDK build reuses its warm ~/.cargo and target/ caches instead of + # building cold on a hosted runner. No fork PR guard is needed here: the + # workflow only triggers on release/workflow_dispatch (via release.yml), + # never on pull_request. The maven-central-deploy job below deliberately + # stays on a hosted runner so the environment-scoped publishing secrets + # never touch the persistent machine. + runs-on: [self-hosted, kotlin-ci] timeout-minutes: 180 permissions: - contents: write # attach the AAR to the platform release + contents: read # release attachment runs on an ephemeral hosted job # Serialize same-tag runs (e.g. an emergency dispatch racing the # release-triggered run) so the asset-exists guard cannot be bypassed by # two concurrent builds. Never cancel a release build in progress. @@ -58,6 +65,29 @@ jobs: sha: ${{ steps.resolve-sha.outputs.sha }} steps: + # Same idempotent host check as kotlin-sdk-build.yml, plus gh (used by + # the tag validation below and preinstalled only on hosted images). Runs + # before checkout so the validation step can rely on gh. + - name: Ensure runner dependencies + run: | + set -euo pipefail + + MISSING=() + for pkg in build-essential cmake curl gh jq libgmp-dev libpulse0 libssl-dev libx11-xcb1 pkg-config python3 unzip zip; do + dpkg -s "$pkg" >/dev/null 2>&1 || MISSING+=("$pkg") + done + if [ ${#MISSING[@]} -gt 0 ]; then + echo "Installing: ${MISSING[*]}" + sudo apt-get update -qq + sudo apt-get install -qq --yes "${MISSING[@]}" + fi + + if [ ! -x "$HOME/.cargo/bin/rustup" ]; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --no-modify-path --default-toolchain none + fi + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + # A workflow_dispatch `tag` input is free-form and actions/checkout would # happily resolve it to a BRANCH (or any ref). Normalize and validate it # here — reject anything that is not an existing platform release tag @@ -117,6 +147,17 @@ jobs: } >> "$GITHUB_OUTPUT" echo "Releasing Kotlin SDK ${VERSION} for platform release ${TAG}" + # PR jobs run in this same workspace on this persistent runner. Git + # state they leave behind (.git/hooks, .git/config, .git/info/attributes) + # would run inside actions/checkout's own `git checkout`, before any + # later cleanup could remove it, and stale jniLibs or gradle outputs + # must never leak into a release AAR. Start from an empty directory and + # a fresh clone; the release build cache lives outside the workspace. + - name: Empty the workspace left by earlier jobs + run: | + find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + \ + || sudo -n find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + - name: Checkout repository uses: actions/checkout@v4 with: @@ -124,21 +165,20 @@ jobs: # raw dispatch input — so the released AAR is built from the tag's # commit and a manual run can never build from a branch. ref: ${{ steps.release-ref.outputs.checkout_ref }} + persist-credentials: false - name: Resolve built commit SHA id: resolve-sha run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - name: Free disk space + - name: Verify JDK 17 run: | - sudo rm -rf /usr/share/dotnet /usr/local/lib/android/sdk/ndk /opt/ghc - df -h / - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' + JAVA_HOME_RESOLVED=$(dirname "$(dirname "$(readlink -f "$(command -v java)")")") + JAVA_VERSION_OUTPUT=$("$JAVA_HOME_RESOLVED/bin/java" -version 2>&1) + printf '%s\n' "$JAVA_VERSION_OUTPUT" + printf '%s\n' "$JAVA_VERSION_OUTPUT" | grep -Eq 'version "17([.]|\")' + echo "JAVA_HOME=$JAVA_HOME_RESOLVED" >> "$GITHUB_ENV" + echo "$JAVA_HOME_RESOLVED/bin" >> "$GITHUB_PATH" - name: Set up Android SDK uses: android-actions/setup-android@v3 @@ -153,26 +193,40 @@ jobs: with: targets: aarch64-linux-android,x86_64-linux-android - - name: Restore cargo cache - uses: actions/cache@v4 + # No actions/cache here: the persistent runner keeps ~/.cargo between + # runs, and the release target cache below lives outside the workspace. + - name: Prepare release Cargo target cache + uses: ./.github/actions/release-cargo-target-cache with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: kotlin-sdk-release-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - kotlin-sdk-release-cargo- - kotlin-sdk-cargo- - - - name: Install cargo-ndk - run: cargo install cargo-ndk --locked - - - name: Install protoc v32.0 (repo-standard; apt's 3.21 breaks tenderdash-proto) + name: release-kotlin-sdk-target + + # Always reinstalled, never trusted from an earlier job: a binary left in + # ~/.cargo/bin can print the pinned version and still be something else. + # Cargo looks up `cargo ndk` in $CARGO_HOME/bin before PATH, so the + # install has to overwrite that copy rather than land elsewhere. + - name: Install cargo-ndk v4.1.2 run: | - curl -fsSL -o /tmp/protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-x86_64.zip - sudo unzip -o /tmp/protoc.zip -d /usr/local 'bin/protoc' 'include/*' - protoc --version + set -euo pipefail + cargo install cargo-ndk --version 4.1.2 --locked --force + cargo ndk --version | grep -qx 'cargo-ndk 4.1.2' + + # Fresh, checksum-verified copy in this job's temp directory rather than + # whatever an earlier job left in /usr/local (repo-standard v32.0; apt's + # 3.21 breaks tenderdash-proto). prost-build reads PROTOC first. + - name: Install protoc v32.0 + env: + PROTOC_SHA256: 7ca037bfe5e5cabd4255ccd21dd265f79eb82d3c010117994f5dc81d2140ee88 + run: | + set -euo pipefail + PROTOC_DIR="$RUNNER_TEMP/protoc-32.0" + curl -fsSL -o "$RUNNER_TEMP/protoc.zip" \ + https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-x86_64.zip + echo "$PROTOC_SHA256 $RUNNER_TEMP/protoc.zip" | sha256sum -c - + rm -rf "$PROTOC_DIR" + unzip -q "$RUNNER_TEMP/protoc.zip" -d "$PROTOC_DIR" + echo "PROTOC=$PROTOC_DIR/bin/protoc" >> "$GITHUB_ENV" + echo "$PROTOC_DIR/bin" >> "$GITHUB_PATH" + "$PROTOC_DIR/bin/protoc" --version - name: Build native library (both ABIs, release profile) working-directory: packages/kotlin-sdk @@ -187,91 +241,95 @@ jobs: working-directory: packages/kotlin-sdk run: ./gradlew :sdk:assembleRelease --stacktrace - # Defenses against a force-moved tag splitting the GitHub-release AAR - # from the (immutable) Maven Central artifact of the same version: - # 1. Hard-fail if the tag no longer resolves to the exact commit this - # run built. - # 2. The AAR is attached together with a .commit.txt recording - # the commit it was built from. When both already exist, this run - # may proceed to the Maven deploy ONLY if that recorded commit - # equals the commit this run built — otherwise the existing GitHub - # AAR and the Maven artifact this run would publish came from - # different commits, and the run hard-fails instead of letting the - # two channels drift. An attached AAR is never overwritten; a - # deliberate re-release must delete both assets first. - - name: Verify tag still resolves to the built commit - id: tag-guard + - name: Prepare versioned assets (AAR + build provenance) env: - TAG: ${{ steps.release-ref.outputs.tag_name }} - AAR_ASSET: dash-sdk-android-${{ steps.release-ref.outputs.version }}.aar - COMMIT_ASSET: dash-sdk-android-${{ steps.release-ref.outputs.version }}.commit.txt + VERSION: ${{ steps.release-ref.outputs.version }} BUILT_SHA: ${{ steps.resolve-sha.outputs.sha }} + run: | + cd packages/kotlin-sdk/sdk/build/outputs/aar + cp sdk-release.aar "dash-sdk-android-${VERSION}.aar" + printf '%s\n' "$BUILT_SHA" > "dash-sdk-android-${VERSION}.commit.txt" + + # Release attachment runs on a fresh hosted runner so release-write + # credentials never reach the persistent build machine. + - name: Upload release assets + uses: actions/upload-artifact@v4 + with: + name: kotlin-sdk-release-assets + path: | + packages/kotlin-sdk/sdk/build/outputs/aar/dash-sdk-android-${{ steps.release-ref.outputs.version }}.aar + packages/kotlin-sdk/sdk/build/outputs/aar/dash-sdk-android-${{ steps.release-ref.outputs.version }}.commit.txt + if-no-files-found: error + retention-days: 7 + + # Hand the freshly built native libraries to the deploy job so it + # re-stages the SAME .so (same commit, same tag) without repeating the + # multi-hour cargo/NDK build. + - name: Upload native libraries for the deploy job + uses: actions/upload-artifact@v4 + with: + name: kotlin-sdk-jnilibs + path: packages/kotlin-sdk/sdk/src/main/jniLibs + if-no-files-found: error + retention-days: 7 + + attach-release: + name: Attach AAR to platform release + needs: build-and-release + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + concurrency: + group: release-kotlin-sdk-attach-${{ needs.build-and-release.outputs.tag_name }} + cancel-in-progress: false + steps: + - name: Download release assets + uses: actions/download-artifact@v4 + with: + name: kotlin-sdk-release-assets + path: release-assets + + - name: Verify tag and existing assets + id: verify + env: + TAG: ${{ needs.build-and-release.outputs.tag_name }} + AAR_ASSET: dash-sdk-android-${{ needs.build-and-release.outputs.version }}.aar + COMMIT_ASSET: dash-sdk-android-${{ needs.build-and-release.outputs.version }}.commit.txt + BUILT_SHA: ${{ needs.build-and-release.outputs.sha }} REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} run: | - # Resolve the tag's current commit, dereferencing an annotated tag - # to the commit it points at. + set -euo pipefail OBJ_TYPE=$(gh api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.type') OBJ_SHA=$(gh api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.sha') if [ "$OBJ_TYPE" = "tag" ]; then OBJ_SHA=$(gh api "repos/${REPO}/git/tags/${OBJ_SHA}" --jq '.object.sha') fi - if [ "$OBJ_SHA" != "$BUILT_SHA" ]; then - echo "::error::Tag ${TAG} now points at ${OBJ_SHA} but this run built ${BUILT_SHA} — the tag was force-moved. Refusing to attach/deploy." - exit 1 - fi + [ "$OBJ_SHA" = "$BUILT_SHA" ] || { echo "::error::Tag ${TAG} moved from built commit ${BUILT_SHA} to ${OBJ_SHA}."; exit 1; } ASSETS=$(gh api "repos/${REPO}/releases/tags/${TAG}" --jq '.assets[].name') HAVE_AAR=$(echo "$ASSETS" | grep -Fxc "$AAR_ASSET" || true) HAVE_COMMIT=$(echo "$ASSETS" | grep -Fxc "$COMMIT_ASSET" || true) - if [ "$HAVE_AAR" = "1" ] && [ "$HAVE_COMMIT" = "1" ]; then + if [ "$HAVE_AAR" = 1 ] && [ "$HAVE_COMMIT" = 1 ]; then RECORDED_SHA=$(gh release download "$TAG" --repo "$REPO" --pattern "$COMMIT_ASSET" --output - | tr -d '[:space:]') - if [ "$RECORDED_SHA" != "$BUILT_SHA" ]; then - echo "::error::Release ${TAG} carries ${AAR_ASSET} built from ${RECORDED_SHA}, but this run built ${BUILT_SHA} — deploying would publish a different commit to Maven Central than the attached AAR. Delete both assets deliberately to re-release." - exit 1 - fi + [ "$RECORDED_SHA" = "$BUILT_SHA" ] || { echo "::error::Existing AAR provenance does not match this build."; exit 1; } echo "asset_exists=true" >> "$GITHUB_OUTPUT" - echo "::notice::Release ${TAG} already has ${AAR_ASSET} built from this same commit — leaving it untouched; the Maven deploy may proceed." - elif [ "$HAVE_AAR" = "1" ] || [ "$HAVE_COMMIT" = "1" ]; then - echo "::error::Release ${TAG} has only one of ${AAR_ASSET} / ${COMMIT_ASSET} — an earlier attach was interrupted, so the existing asset's provenance cannot be verified. Delete the surviving asset and re-run." + elif [ "$HAVE_AAR" = 1 ] || [ "$HAVE_COMMIT" = 1 ]; then + echo "::error::Release has only one provenance asset; delete it before retrying." exit 1 else echo "asset_exists=false" >> "$GITHUB_OUTPUT" fi - - name: Prepare versioned assets (AAR + build provenance) - env: - VERSION: ${{ steps.release-ref.outputs.version }} - BUILT_SHA: ${{ steps.resolve-sha.outputs.sha }} - run: | - cd packages/kotlin-sdk/sdk/build/outputs/aar - cp sdk-release.aar "dash-sdk-android-${VERSION}.aar" - printf '%s\n' "$BUILT_SHA" > "dash-sdk-android-${VERSION}.commit.txt" - - # Attach to the PLATFORM release: only tag_name + files. Passing name/ - # body/prerelease/generate_release_notes here would overwrite the - # platform release's own notes, title or prerelease flag. - # overwrite_files: false makes a same-name upload race fail loudly - # instead of silently replacing an asset the guard above vouched for. - name: Attach AAR to the platform release - if: steps.tag-guard.outputs.asset_exists != 'true' + if: steps.verify.outputs.asset_exists != 'true' uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: - tag_name: ${{ steps.release-ref.outputs.tag_name }} + tag_name: ${{ needs.build-and-release.outputs.tag_name }} overwrite_files: false files: | - packages/kotlin-sdk/sdk/build/outputs/aar/dash-sdk-android-${{ steps.release-ref.outputs.version }}.aar - packages/kotlin-sdk/sdk/build/outputs/aar/dash-sdk-android-${{ steps.release-ref.outputs.version }}.commit.txt - - # Hand the freshly built native libraries to the deploy job so it - # re-stages the SAME .so (same commit, same tag) without repeating the - # multi-hour cargo/NDK build. - - name: Upload native libraries for the deploy job - uses: actions/upload-artifact@v4 - with: - name: kotlin-sdk-jnilibs - path: packages/kotlin-sdk/sdk/src/main/jniLibs - if-no-files-found: error - retention-days: 7 + release-assets/dash-sdk-android-${{ needs.build-and-release.outputs.version }}.aar + release-assets/dash-sdk-android-${{ needs.build-and-release.outputs.version }}.commit.txt # --------------------------------------------------------------------------- # Maven Central deploy. Declares `environment: maven-central`, so the five @@ -284,7 +342,7 @@ jobs: # --------------------------------------------------------------------------- maven-central-deploy: name: Deploy to Maven Central (version from tag) - needs: build-and-release + needs: [build-and-release, attach-release] # Runs only when the RUN's ref is exactly the target tag — not merely # some tag: binding the ref to inputs.tag stops a dispatch started at tag # A from publishing input tag B under the environment authorization A's @@ -294,7 +352,7 @@ jobs: # selected ref) skips this job cleanly instead of failing at the # environment policy; Maven for such tags goes through the manual # runbook in packages/kotlin-sdk/PUBLISHING.md. - if: ${{ github.ref == format('refs/tags/{0}', inputs.tag) }} + if: ${{ needs.build-and-release.result == 'success' && needs.attach-release.result == 'success' && github.ref == format('refs/tags/{0}', inputs.tag) }} runs-on: ubuntu-24.04 timeout-minutes: 60 permissions: diff --git a/.github/workflows/release-swift-sdk.yml b/.github/workflows/release-swift-sdk.yml index 4d6669c244e..dcc62c29872 100644 --- a/.github/workflows/release-swift-sdk.yml +++ b/.github/workflows/release-swift-sdk.yml @@ -30,19 +30,44 @@ on: jobs: build-and-release: name: Build and release DashSDKFFI - runs-on: macos-15 - # Two cold release-profile builds (device + simulator) of ~25 min each. + outputs: + tag_name: ${{ steps.release-ref.outputs.tag_name }} + version: ${{ steps.release-ref.outputs.version }} + sha: ${{ steps.resolve-sha.outputs.sha }} + checksum: ${{ steps.zip.outputs.checksum }} + # Same persistent runner as swift-sdk-build.yml. Release builds keep their + # own Cargo target cache on it (see "Prepare release Cargo target cache"), + # so later releases only rebuild what changed since the previous one. No + # fork PR guard is needed here: the workflow only triggers on release/ + # workflow_dispatch (via release.yml), never on pull_request. + runs-on: [self-hosted, macOS, ARM64] + # Three cold fat-LTO slices took over 45 minutes on a hosted macos-15 + # runner. A cold run here (first release, or after the cache is reset) + # needs the same headroom; warm runs finish well inside it. timeout-minutes: 90 permissions: - contents: write # attach the xcframework to the platform release + contents: read # release attachment runs on an ephemeral hosted job # Serialize same-tag runs (e.g. an emergency dispatch racing the # release-triggered run) so the asset-exists guard cannot be bypassed by # two concurrent builds. Never cancel a release build in progress. concurrency: group: release-swift-sdk-${{ inputs.tag }} cancel-in-progress: false + defaults: + run: + # The runner's work/temp directory can be on a volume with spaces. + shell: bash --noprofile --norc -e -o pipefail "{0}" steps: + # The tag validation below needs gh before checkout; hosted images ship + # it, the persistent runner may not. + - name: Ensure gh is installed + run: | + if ! command -v gh >/dev/null 2>&1; then + brew install gh + fi + gh --version + # Same guard as release-kotlin-sdk.yml: normalize/validate the tag, # refuse anything that is not an existing platform release tag with a # published GitHub release, and hand checkout an explicit refs/tags/ @@ -90,50 +115,107 @@ jobs: } >> "$GITHUB_OUTPUT" echo "Releasing DashSDKFFI ${TAG#v} for platform release ${TAG}" + # PR jobs run in this same workspace on this persistent runner. Git + # state they leave behind (.git/hooks, .git/config, .git/info/attributes) + # would run inside actions/checkout's own `git checkout`, before any + # later cleanup could remove it. Start from an empty directory and a + # fresh clone; the release build cache lives outside the workspace. + - name: Empty the workspace left by earlier jobs + run: | + find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + \ + || sudo -n find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + - name: Checkout repository uses: actions/checkout@v4 with: ref: ${{ steps.release-ref.outputs.checkout_ref }} + persist-credentials: false - name: Resolve built commit SHA id: resolve-sha run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - name: Select Xcode 16 - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: '16.*' - + # The runner's selected Xcode is the one every other Swift job on this + # machine builds with (setup-xcode only knows hosted image layouts). - name: Show Xcode and Swift versions run: | xcodebuild -version swift --version - - name: Set up Rust toolchain (stable) - uses: dtolnay/rust-toolchain@stable + # Composite actions choose their own shell and do not inherit the quoted + # script path above, so rustup is set up inline as in + # swift-sdk-build.yml. The build uses the rust-toolchain.toml channel. + - name: Set up Rust toolchain + env: + RUSTUP_PERMIT_COPY_RENAME: "1" + run: | + export CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" + export PATH="$CARGO_HOME/bin:$PATH" + if ! command -v rustup >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused \ + --location --silent --show-error --fail https://sh.rustup.rs \ + | sh -s -- --default-toolchain none --no-modify-path -y + fi + { + echo "CARGO_HOME=$CARGO_HOME" + echo "CARGO_INCREMENTAL=${CARGO_INCREMENTAL-0}" + echo "CARGO_TERM_COLOR=${CARGO_TERM_COLOR-always}" + } >> "$GITHUB_ENV" + echo "$CARGO_HOME/bin" >> "$GITHUB_PATH" - - name: Cache cargo registry - uses: actions/cache@v5 + # Restore-only, matching swift-sdk-build.yml: the persistent runner + # keeps ~/.cargo between runs, so saving it back would just re-upload + # gigabytes on every lockfile change. + - name: Restore cargo registry cache + uses: actions/cache/restore@v5 with: path: | ~/.cargo/registry ~/.cargo/git key: cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + cargo-registry- - name: Add iOS Rust targets + env: + RUSTUP_PERMIT_COPY_RENAME: "1" run: | rustup target add aarch64-apple-ios aarch64-apple-ios-sim + rustc --version --verbose - - name: Install protoc (Protocol Buffers compiler) - uses: arduino/setup-protoc@v3 + # Fresh, checksum-verified copy in this job's temp directory. The runner's + # tool cache persists between jobs, so a protoc left there by an earlier + # job is not trusted for a release build. + - name: Install protoc v32.0 (Protocol Buffers compiler) + env: + PROTOC_SHA256: 09a2c729cc821215cc0d4c564b761760961fe338c52f24b302fd7e18e7b675d1 + run: | + set -euo pipefail + PROTOC_DIR="$RUNNER_TEMP/protoc-32.0" + curl -fsSL -o "$RUNNER_TEMP/protoc.zip" \ + https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-osx-aarch_64.zip + echo "$PROTOC_SHA256 $RUNNER_TEMP/protoc.zip" | shasum -a 256 -c - + rm -rf "$PROTOC_DIR" + unzip -q "$RUNNER_TEMP/protoc.zip" -d "$PROTOC_DIR" + echo "PROTOC=$PROTOC_DIR/bin/protoc" >> "$GITHUB_ENV" + echo "$PROTOC_DIR/bin" >> "$GITHUB_PATH" + "$PROTOC_DIR/bin/protoc" --version + + # PR builds on this runner (swift-sdk-build.yml) set PRUNE_CARGO_TARGETS, + # which deletes every Apple target directory under the workspace + # target/ before and after each slice, and this job empties the + # workspace anyway. Releases keep their own release-ios cache outside + # it instead. + - name: Prepare release Cargo target cache + uses: ./.github/actions/release-cargo-target-cache with: - version: '32.x' - # Without a token the action resolves the protoc release - # unauthenticated, and the shared macOS-runner egress IP burns - # through the 60 req/h anonymous limit. - repo-token: ${{ secrets.GITHUB_TOKEN }} + name: release-swift-sdk-target - name: Build DashSDKFFI.xcframework and install into Swift package + env: + # Keep all three slices' intermediates in the release cache above; + # pruning them would make every release a cold build. + PRUNE_CARGO_TARGETS: "0" run: | bash packages/swift-sdk/build_ios.sh --target all --profile release @@ -149,88 +231,86 @@ jobs: printf '%s\n' "$BUILT_SHA" > "DashSDKFFI-${VERSION}.commit.txt" echo "checksum=$(cat "DashSDKFFI-${VERSION}.checksum.txt")" >> "$GITHUB_OUTPUT" - # Same defenses as the Kotlin job: hard-fail if the tag was force-moved - # away from the commit this run built, and never overwrite an - # already-attached versioned zip — SwiftPM consumers pin the zip by - # checksum, so replacing attached bytes would break them. The zip - # attaches together with checksum + commit provenance; existing assets - # are reused only when their recorded commit equals the commit this run - # built, so a force-moved tag cannot leave a green run whose assets - # came from a different commit. A deliberate re-release must delete all - # three assets first. - - name: Verify tag still resolves to the built commit - id: tag-guard + # Release attachment runs on a fresh hosted runner so release-write + # credentials never reach the persistent build machine. + - name: Upload release assets + uses: actions/upload-artifact@v4 + with: + name: swift-sdk-release-assets + path: | + packages/swift-sdk/DashSDKFFI-${{ steps.release-ref.outputs.version }}.xcframework.zip + packages/swift-sdk/DashSDKFFI-${{ steps.release-ref.outputs.version }}.checksum.txt + packages/swift-sdk/DashSDKFFI-${{ steps.release-ref.outputs.version }}.commit.txt + if-no-files-found: error + retention-days: 7 + + attach-release: + name: Attach XCFramework to platform release + needs: build-and-release + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + concurrency: + group: release-swift-sdk-attach-${{ needs.build-and-release.outputs.tag_name }} + cancel-in-progress: false + steps: + - name: Download release assets + uses: actions/download-artifact@v4 + with: + name: swift-sdk-release-assets + path: release-assets + + - name: Verify tag and existing assets + id: verify env: - TAG: ${{ steps.release-ref.outputs.tag_name }} - ZIP_ASSET: DashSDKFFI-${{ steps.release-ref.outputs.version }}.xcframework.zip - SUM_ASSET: DashSDKFFI-${{ steps.release-ref.outputs.version }}.checksum.txt - COMMIT_ASSET: DashSDKFFI-${{ steps.release-ref.outputs.version }}.commit.txt - BUILT_SHA: ${{ steps.resolve-sha.outputs.sha }} + TAG: ${{ needs.build-and-release.outputs.tag_name }} + ZIP_ASSET: DashSDKFFI-${{ needs.build-and-release.outputs.version }}.xcframework.zip + SUM_ASSET: DashSDKFFI-${{ needs.build-and-release.outputs.version }}.checksum.txt + COMMIT_ASSET: DashSDKFFI-${{ needs.build-and-release.outputs.version }}.commit.txt + BUILT_SHA: ${{ needs.build-and-release.outputs.sha }} REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} run: | - # Resolve the tag's current commit, dereferencing an annotated tag - # to the commit it points at. + set -euo pipefail OBJ_TYPE=$(gh api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.type') OBJ_SHA=$(gh api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.sha') if [ "$OBJ_TYPE" = "tag" ]; then OBJ_SHA=$(gh api "repos/${REPO}/git/tags/${OBJ_SHA}" --jq '.object.sha') fi - if [ "$OBJ_SHA" != "$BUILT_SHA" ]; then - echo "::error::Tag ${TAG} now points at ${OBJ_SHA} but this run built ${BUILT_SHA} — the tag was force-moved. Refusing to attach." - exit 1 - fi - # The three assets are attached as one unit: reuse them only when - # ALL are present AND the recorded provenance commit matches this - # run's built commit. A partial set means an earlier run died - # mid-attach (provenance unverifiable); a mismatched commit means - # the tag was force-moved after the original attach. Both hard-fail - # and require deliberate cleanup instead of a silently-green run. + [ "$OBJ_SHA" = "$BUILT_SHA" ] || { echo "::error::Tag ${TAG} moved from built commit ${BUILT_SHA} to ${OBJ_SHA}."; exit 1; } ASSETS=$(gh api "repos/${REPO}/releases/tags/${TAG}" --jq '.assets[].name') HAVE_ZIP=$(echo "$ASSETS" | grep -Fxc "$ZIP_ASSET" || true) HAVE_SUM=$(echo "$ASSETS" | grep -Fxc "$SUM_ASSET" || true) HAVE_COMMIT=$(echo "$ASSETS" | grep -Fxc "$COMMIT_ASSET" || true) - if [ "$HAVE_ZIP" = "1" ] && [ "$HAVE_SUM" = "1" ] && [ "$HAVE_COMMIT" = "1" ]; then + if [ "$HAVE_ZIP" = 1 ] && [ "$HAVE_SUM" = 1 ] && [ "$HAVE_COMMIT" = 1 ]; then RECORDED_SHA=$(gh release download "$TAG" --repo "$REPO" --pattern "$COMMIT_ASSET" --output - | tr -d '[:space:]') - if [ "$RECORDED_SHA" != "$BUILT_SHA" ]; then - echo "::error::Release ${TAG} carries ${ZIP_ASSET} built from ${RECORDED_SHA}, but this run built ${BUILT_SHA} — the existing assets came from a different commit. Delete all three assets deliberately to re-release." - exit 1 - fi + [ "$RECORDED_SHA" = "$BUILT_SHA" ] || { echo "::error::Existing XCFramework provenance does not match this build."; exit 1; } echo "asset_exists=true" >> "$GITHUB_OUTPUT" - echo "::notice::Release ${TAG} already has ${ZIP_ASSET} (+ checksum/commit) built from this same commit — leaving them untouched." - elif [ "$HAVE_ZIP" = "1" ] || [ "$HAVE_SUM" = "1" ] || [ "$HAVE_COMMIT" = "1" ]; then - echo "::error::Release ${TAG} has only some of ${ZIP_ASSET} / ${SUM_ASSET} / ${COMMIT_ASSET} — an earlier attach was interrupted, so the existing assets' provenance cannot be verified. Delete the surviving assets and re-run." + elif [ "$HAVE_ZIP" = 1 ] || [ "$HAVE_SUM" = 1 ] || [ "$HAVE_COMMIT" = 1 ]; then + echo "::error::Release has only some XCFramework provenance assets; delete them before retrying." exit 1 else echo "asset_exists=false" >> "$GITHUB_OUTPUT" fi - # Attach to the PLATFORM release: only tag_name + files. Passing name/ - # body/prerelease/generate_release_notes here would overwrite the - # platform release's own notes, title or prerelease flag. - # overwrite_files: false makes a same-name upload race fail loudly - # instead of silently replacing an asset the guard above vouched for. - name: Attach XCFramework to the platform release - if: steps.tag-guard.outputs.asset_exists != 'true' + if: steps.verify.outputs.asset_exists != 'true' uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: - tag_name: ${{ steps.release-ref.outputs.tag_name }} + tag_name: ${{ needs.build-and-release.outputs.tag_name }} overwrite_files: false files: | - packages/swift-sdk/DashSDKFFI-${{ steps.release-ref.outputs.version }}.xcframework.zip - packages/swift-sdk/DashSDKFFI-${{ steps.release-ref.outputs.version }}.checksum.txt - packages/swift-sdk/DashSDKFFI-${{ steps.release-ref.outputs.version }}.commit.txt + release-assets/DashSDKFFI-${{ needs.build-and-release.outputs.version }}.xcframework.zip + release-assets/DashSDKFFI-${{ needs.build-and-release.outputs.version }}.checksum.txt + release-assets/DashSDKFFI-${{ needs.build-and-release.outputs.version }}.commit.txt - # The durable home for the consumer checksum is the checksum.txt release - # asset; this summary is a convenience copy. Skipped when the attach was - # skipped: this run's freshly computed checksum would describe THIS - # build, not the (not byte-reproducible) zip actually attached earlier. - name: Write SwiftPM usage to the job summary - if: steps.tag-guard.outputs.asset_exists != 'true' + if: steps.verify.outputs.asset_exists != 'true' env: - TAG: ${{ steps.release-ref.outputs.tag_name }} - VERSION: ${{ steps.release-ref.outputs.version }} - CHECKSUM: ${{ steps.zip.outputs.checksum }} + TAG: ${{ needs.build-and-release.outputs.tag_name }} + VERSION: ${{ needs.build-and-release.outputs.version }} + CHECKSUM: ${{ needs.build-and-release.outputs.checksum }} REPO: ${{ github.repository }} run: | cat >> "$GITHUB_STEP_SUMMARY" </dev/null 2>&1 || MISSING+=("$pkg") + done + if [ ${#MISSING[@]} -gt 0 ]; then + echo "Installing: ${MISSING[*]}" + sudo apt-get update -qq + sudo apt-get install -qq --yes "${MISSING[@]}" + fi + + if [ ! -x "$HOME/.cargo/bin/rustup" ]; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --no-modify-path --default-toolchain none + fi + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + docker info >/dev/null + # Always pull: a local image under this tag may have been replaced + # by an earlier job, and a pull resets the tag to the registry's. + docker pull rvolosatovs/protoc:4.0.0 - name: Setup Rust uses: ./.github/actions/rust with: target: wasm32-unknown-unknown + # The runner persists the Cargo registry between runs. + cache: 'false' if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - name: Setup sccache - uses: ./.github/actions/sccache - with: - bucket: ${{ vars.CACHE_S3_BUCKET }} - region: ${{ vars.AWS_REGION }} - endpoint: ${{ vars.CACHE_S3_ENDPOINT }} - access_key_id: ${{ secrets.CACHE_KEY_ID }} - secret_access_key: ${{ secrets.CACHE_SECRET_KEY }} - + # The Rust action above reuses a protoc left in ~/.local by earlier jobs. + # Override it with a fresh, checksum-verified copy for the release build; + # prost-build reads PROTOC first. + - name: Install protoc v32.0 + if: ${{ steps.check-artifact.outputs.exists != 'true' }} + env: + PROTOC_SHA256: 7ca037bfe5e5cabd4255ccd21dd265f79eb82d3c010117994f5dc81d2140ee88 + run: | + set -euo pipefail + PROTOC_DIR="$RUNNER_TEMP/protoc-32.0" + curl -fsSL -o "$RUNNER_TEMP/protoc.zip" \ + https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-x86_64.zip + echo "$PROTOC_SHA256 $RUNNER_TEMP/protoc.zip" | sha256sum -c - + rm -rf "$PROTOC_DIR" + unzip -q "$RUNNER_TEMP/protoc.zip" -d "$PROTOC_DIR" + echo "PROTOC=$PROTOC_DIR/bin/protoc" >> "$GITHUB_ENV" + echo "$PROTOC_DIR/bin" >> "$GITHUB_PATH" + "$PROTOC_DIR/bin/protoc" --version + + - name: Prepare release Cargo target cache if: ${{ steps.check-artifact.outputs.exists != 'true' }} + uses: ./.github/actions/release-cargo-target-cache + with: + name: release-npm-target - # Composite action defaults to Node 24+ which ships npm 11.5.1+; - # required for trusted-publishers OIDC at the publish step below. - # See https://docs.npmjs.com/trusted-publishers. - name: Setup Node.JS uses: ./.github/actions/nodejs + if: ${{ steps.check-artifact.outputs.exists != 'true' }} - name: Install Cargo binstall uses: cargo-bins/cargo-binstall@v1.3.1 if: ${{ steps.check-artifact.outputs.exists != 'true' }} + # Always reinstalled, never trusted from an earlier job: a binary left on + # this persistent runner can print the pinned version and still be + # something else. - name: Install wasm-bindgen-cli - run: cargo binstall wasm-bindgen-cli@0.2.108 + run: | + set -euo pipefail + cargo binstall wasm-bindgen-cli@0.2.108 --no-confirm --force + wasm-bindgen --version | grep -Fxq 'wasm-bindgen 0.2.108' if: ${{ steps.check-artifact.outputs.exists != 'true' }} - name: Install wasm-pack - run: cargo binstall wasm-pack + run: | + set -euo pipefail + cargo binstall wasm-pack@0.15.0 --no-confirm --force + wasm-pack --version | grep -Fxq 'wasm-pack 0.15.0' if: ${{ steps.check-artifact.outputs.exists != 'true' }} + # Fresh, checksum-verified copy in this job's temp directory rather than + # one left in ~/.local by an earlier job. - name: Install Binaryen + env: + BINARYEN_SHA256: c90e0e295e8f8484ba5b47da92f26e5d1d18db6cd2fcc0c5cc265a5a73609f17 run: | - wget https://github.com/WebAssembly/binaryen/releases/download/version_121/binaryen-version_121-x86_64-linux.tar.gz -P /tmp - tar -xzf /tmp/binaryen-version_121-x86_64-linux.tar.gz -C /tmp - sudo cp -r /tmp/binaryen-version_121/* /usr/local/ + set -euo pipefail + ARCHIVE="$RUNNER_TEMP/binaryen-version_121-x86_64-linux.tar.gz" + curl -fsSL -o "$ARCHIVE" \ + https://github.com/WebAssembly/binaryen/releases/download/version_121/binaryen-version_121-x86_64-linux.tar.gz + echo "$BINARYEN_SHA256 $ARCHIVE" | sha256sum -c - + rm -rf "$RUNNER_TEMP/binaryen-version_121" + tar -xzf "$ARCHIVE" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/binaryen-version_121/bin" >> "$GITHUB_PATH" if: ${{ steps.check-artifact.outputs.exists != 'true' }} - name: Build packages @@ -100,13 +164,163 @@ jobs: CARGO_BUILD_PROFILE: release if: ${{ steps.check-artifact.outputs.exists != 'true' }} + - name: Ignore only already cached artifacts + run: | + find . -name '.gitignore' -exec rm -f {} + + { + echo ".yarn" + echo "target" + echo "node_modules" + echo ".nyc_output" + echo ".idea" + echo ".ultra.cache.json" + echo "db/*" + echo "npm-packages" + } >> .gitignore + if: ${{ steps.check-artifact.outputs.exists != 'true' }} + + # Pack on the unprivileged builder. The hosted publisher receives only + # these tarballs and publishes them without running package lifecycle + # scripts, so persistent-runner output cannot execute with OIDC access. + - name: Pack NPM packages on the unprivileged builder + run: | + set -euo pipefail + mkdir -p npm-packages + yarn workspaces foreach --all --no-private --parallel pack \ + --out "$GITHUB_WORKSPACE/npm-packages/%s-%v.tgz" + test -n "$(find npm-packages -maxdepth 1 -type f -name '*.tgz' -print -quit)" + if: ${{ steps.check-artifact.outputs.exists != 'true' }} + + - name: Get modified files + id: diff + run: | + { + echo "files<> "$GITHUB_OUTPUT" + if: ${{ steps.check-artifact.outputs.exists != 'true' }} + + - name: Upload the archive of built files + uses: actions/upload-artifact@v4 + with: + name: js-build-${{ github.sha }} + path: | + ${{ steps.diff.outputs.files }} + npm-packages/*.tgz + # Keep the handoff alive long enough to re-run only a failed publish. + retention-days: 7 + if-no-files-found: error + include-hidden-files: true + if: ${{ steps.check-artifact.outputs.exists != 'true' }} + + release-npm: + name: Publish NPM packages + needs: build-npm + # npm trusted publishing currently accepts GitHub-hosted runners only. + runs-on: ubuntu-24.04 + timeout-minutes: 60 + if: github.event_name == 'release' || startsWith(inputs.tag, 'npm-test:') + permissions: + id-token: write + contents: read + steps: + - name: Check out repo + uses: actions/checkout@v4 + + - name: Check package version matches tag + uses: geritol/match-tag-to-package-version@0.2.0 + if: github.event_name == 'release' + env: + TAG_PREFIX: v + + - name: Check package version matches requested test tag + id: test-tag + if: github.event_name == 'workflow_dispatch' + env: + INPUT_TAG: ${{ inputs.tag }} + run: | + TAG="${INPUT_TAG#npm-test:}" + PACKAGE_VERSION=$(jq -r '.version' package.json) + if [ "$TAG" != "v${PACKAGE_VERSION}" ]; then + echo "::error::Requested tag $TAG does not match package version v${PACKAGE_VERSION}" + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + # Only npm itself is needed here. Do not install dependencies or restore + # executable caches shared with the persistent builder in this OIDC job. + # No registry-url: with it, setup-node@v4 writes an _authToken line to + # .npmrc and exports a placeholder NODE_AUTH_TOKEN, so `npm publish` can + # attempt token auth instead of the trusted-publishing OIDC exchange. + # npm's default registry is already registry.npmjs.org. + - name: Setup Node.JS + uses: actions/setup-node@v4 + with: + node-version: '24.14.1' + + - name: Download built package artifacts + uses: actions/download-artifact@v4 + with: + name: js-build-${{ github.sha }} + path: release-artifacts + + # Verify every tarball against the trusted checkout metadata before the + # credentialed publish. Tarball contents are treated as data; no package + # script from the persistent builder is executed on this runner. + - name: Validate packed package identities + env: + ARTIFACT_DIR: release-artifacts/npm-packages + run: | + set -euo pipefail + NPM_CLI_PATH="$(command -v npm)" node <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + // Match npm publish's own manifest parser, including archive path aliases. + const npmBin = path.dirname(fs.realpathSync(process.env.NPM_CLI_PATH)); + const pacote = require(path.join(npmBin, '../node_modules/pacote')); + (async () => { + const trusted = new Map(); + for (const workspace of JSON.parse(fs.readFileSync('package.json')).workspaces) { + const manifest = JSON.parse(fs.readFileSync(path.join(workspace, 'package.json'))); + if (!manifest.private) trusted.set(manifest.name, manifest.version); + } + const tarballs = fs.readdirSync(process.env.ARTIFACT_DIR).filter(file => file.endsWith('.tgz')); + if (!tarballs.length) throw new Error('No NPM tarballs were uploaded.'); + const seen = new Set(); + for (const file of tarballs) { + const tarball = path.resolve(process.env.ARTIFACT_DIR, file); + if (!fs.lstatSync(tarball).isFile()) throw new Error(`Not a regular tarball: ${file}`); + const manifest = await pacote.manifest(tarball, { + fullmetadata: true, + fullReadJson: true, + ignoreScripts: true, + cache: path.join(process.env.RUNNER_TEMP, 'npm-metadata-cache'), + }); + if (!trusted.has(manifest.name) || trusted.get(manifest.name) !== manifest.version) { + throw new Error(`Untrusted package identity in ${file}`); + } + if (seen.has(manifest.name)) throw new Error(`Duplicate tarball for ${manifest.name}`); + seen.add(manifest.name); + // No workspace requires publishConfig. It can override npm's proxy, + // TLS and registry options even with --ignore-scripts, exposing OIDC. + if (manifest.publishConfig !== undefined && JSON.stringify(manifest.publishConfig) !== '{}') { + throw new Error(`Builder-supplied publishConfig is not allowed in ${file}`); + } + } + // A partial artifact would otherwise publish an incomplete release. + const missing = [...trusted.keys()].filter(name => !seen.has(name)); + if (missing.length) throw new Error(`Missing NPM tarballs: ${missing.join(', ')}`); + })().catch(error => { console.error(error.message); process.exitCode = 1; }); + NODE + - name: Set suffix - uses: actions/github-script@v6 + uses: actions/github-script@v8 id: suffix with: result-encoding: string script: | - const fullTag = "${{ inputs.tag }}" || context.payload.release.tag_name; + const fullTag = "${{ steps.test-tag.outputs.tag }}" || context.payload.release.tag_name; if (fullTag.includes('-')) { const [, fullSuffix] = fullTag.split('-'); const [suffix] = fullSuffix.split('.'); @@ -116,12 +330,12 @@ jobs: } - name: Set NPM release tag - uses: actions/github-script@v6 + uses: actions/github-script@v8 id: tag with: result-encoding: string script: | - const tag = "${{ inputs.tag }}" || context.payload.release.tag_name; + const tag = "${{ steps.test-tag.outputs.tag }}" || context.payload.release.tag_name; const [, major, minor] = tag.match(/^v([0-9]+)\.([0-9]+)/); return (tag.includes('-') ? `${major}.${minor}-${{steps.suffix.outputs.result}}` : 'latest'); @@ -130,41 +344,65 @@ jobs: echo "NPM suffix: ${{ steps.suffix.outputs.result }}" echo "NPM release tag: ${{ steps.tag.outputs.result }}" + # --provenance=false keeps what `yarn npm publish` did before. After a + # trusted-publishing token exchange from a public repository, npm turns + # provenance on unless it is set explicitly, and the registry then + # rejects every package whose repository.url does not name + # github.com/dashpay/platform (all but wasm-drive-verify today). - name: Publish NPM packages - run: yarn workspaces foreach --all --no-private --parallel npm publish --tolerate-republish --access public --tag ${{ steps.tag.outputs.result }} - - - name: Ignore only already cached artifacts + if: github.event_name == 'release' + env: + NPM_TAG: ${{ steps.tag.outputs.result }} run: | - find . -name '.gitignore' -exec rm -f {} + - echo ".yarn" >> .gitignore - echo "target" >> .gitignore - echo "node_modules" >> .gitignore - echo ".nyc_output" >> .gitignore - echo ".idea" >> .gitignore - echo ".ultra.cache.json" >> .gitignore - echo "db/*" >> .gitignore - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - - name: Get modified files - id: diff + set -euo pipefail + REGISTRY="$(npm config get registry)" + REGISTRY="${REGISTRY%/}" + for tarball in release-artifacts/npm-packages/*.tgz; do + metadata=$(tar -xOf "$tarball" package/package.json) + name=$(jq -r '.name' <<<"$metadata") + version=$(jq -r '.version' <<<"$metadata") + # npm addresses scoped packages as @scope%2fname: keep the leading @, + # percent-encode only the scope separator. + encoded_name=${name//\//%2f} + status=$(curl --silent --show-error --location --output "$RUNNER_TEMP/npm-metadata.json" \ + --write-out '%{http_code}' --connect-timeout 10 --max-time 30 \ + "$REGISTRY/$encoded_name") || { + echo "::error::Could not query $REGISTRY for $name@$version." + exit 1 + } + case "$status" in + 404) + ;; + 200) + jq -e --arg version "$version" '.versions[$version] != null' "$RUNNER_TEMP/npm-metadata.json" >/dev/null || { + jq -e . "$RUNNER_TEMP/npm-metadata.json" >/dev/null || { echo "::error::Invalid registry response for $name."; exit 1; } + npm publish "$tarball" --ignore-scripts --provenance=false --access public --tag "$NPM_TAG" + continue + } + echo "::notice::$name@$version already exists on $REGISTRY; skipping." + continue + ;; + *) + echo "::error::Registry query for $name@$version returned HTTP $status; refusing to publish blindly." + exit 1 + ;; + esac + npm publish "$tarball" --ignore-scripts --provenance=false --access public --tag "$NPM_TAG" + done + + - name: Dry-run NPM packages + if: github.event_name == 'workflow_dispatch' + env: + NPM_TAG: ${{ steps.tag.outputs.result }} run: | - echo "files<> $GITHUB_OUTPUT - git ls-files --others --exclude-standard >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - - name: Upload the archive of built files - uses: actions/upload-artifact@v4 - with: - name: js-build-${{ github.sha }} - path: ${{ steps.diff.outputs.files }} - retention-days: 1 - if-no-files-found: error - include-hidden-files: true - if: ${{ steps.check-artifact.outputs.exists != 'true' }} + set -euo pipefail + for tarball in release-artifacts/npm-packages/*.tgz; do + npm publish "$tarball" --ignore-scripts --provenance=false --dry-run --access public --tag "$NPM_TAG" + done release-drive-image: name: Release Drive image + if: ${{ !startsWith(inputs.tag, 'npm-test:') }} secrets: inherit uses: ./.github/workflows/release-docker-image.yml with: @@ -176,6 +414,7 @@ jobs: release-drive-image-debug: name: Release Drive debug image + if: ${{ !startsWith(inputs.tag, 'npm-test:') }} secrets: inherit uses: ./.github/workflows/release-docker-image.yml with: @@ -189,7 +428,7 @@ jobs: release-rs-dapi-image: name: Release RS-DAPI image - if: ${{ !inputs.only_drive }} + if: ${{ !inputs.only_drive && !startsWith(inputs.tag, 'npm-test:') }} secrets: inherit uses: ./.github/workflows/release-docker-image.yml with: @@ -201,7 +440,7 @@ jobs: release-test-suite-image: name: Release Test Suite image - if: ${{ !inputs.only_drive }} + if: ${{ !inputs.only_drive && !startsWith(inputs.tag, 'npm-test:') }} secrets: inherit uses: ./.github/workflows/release-docker-image.yml with: @@ -214,7 +453,7 @@ jobs: release-dashmate-helper-image: name: Release Dashmate Helper image secrets: inherit - if: ${{ !inputs.only_drive }} + if: ${{ !inputs.only_drive && !startsWith(inputs.tag, 'npm-test:') }} uses: ./.github/workflows/release-docker-image.yml with: name: Dashmate Helper @@ -246,14 +485,13 @@ jobs: with: tag: ${{ github.event.release.tag_name }} - release-dashmate-packages: - name: Release Dashmate packages + build-dashmate-packages: + name: Build Dashmate packages runs-on: ${{ matrix.os }} - if: ${{ !inputs.only_drive }} + if: ${{ !inputs.only_drive && !startsWith(inputs.tag, 'npm-test:') }} needs: release-npm permissions: - id-token: write # s3 cache - contents: write # update release artifacts + contents: read strategy: fail-fast: false matrix: @@ -271,12 +509,15 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - name: Download JS build artifacts uses: actions/download-artifact@v4 with: name: js-build-${{ github.sha }} - path: packages + # The mixed JS/NPM artifact is rooted at the repository, since its + # paths contain both packages/ and npm-packages/. + path: . - name: Install macOS build deps if: runner.os == 'macOS' @@ -287,29 +528,6 @@ jobs: if: runner.os == 'macOS' uses: docker-practice/actions-setup-docker@master - - name: Install the Apple certificate - if: runner.os == 'macOS' - env: - BUILD_CERTIFICATE_BASE64: ${{ secrets.MACOS_BUILD_CERTIFICATE_BASE64 }} - P12_PASSWORD: ${{ secrets.MACOS_P12_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} - run: | - # create variables - CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 - KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db - - # import certificate and provisioning profile from secrets - echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH - - # create temporary keychain - security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - security set-keychain-settings -lut 21600 $KEYCHAIN_PATH - security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - - # import certificate to keychain - security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH - security list-keychain -d user -s $KEYCHAIN_PATH - - name: Install Linux build deps if: runner.os == 'Linux' run: sudo apt-get install -y nsis @@ -324,23 +542,106 @@ jobs: - name: Create package env: - OSX_KEYCHAIN: ${{ runner.temp }}/app-signing.keychain-db - run: "${GITHUB_WORKSPACE}/scripts/pack_dashmate.sh ${{ matrix.package_type }}" + # Packaging executes dependency and lifecycle scripts, including + # output from the persistent builder. Signing happens in a new job. + DASHMATE_UNSIGNED: 'true' + run: | + "${GITHUB_WORKSPACE}/scripts/pack_dashmate.sh" "${{ matrix.package_type }}" - - name: Upload artifacts to action summary + - name: Upload Dashmate packages uses: actions/upload-artifact@v4 - if: github.event_name != 'release' with: - name: dashmate + name: dashmate-${{ matrix.package_type }}-${{ github.sha }} path: packages/dashmate/dist/** + if-no-files-found: error + retention-days: 7 + + release-dashmate-packages: + name: Release Dashmate packages + needs: build-dashmate-packages + # `needs` on a matrix job waits for every leg and reports failure if any + # one leg failed, which by default would skip all four release legs. Each + # leg below needs only its own package type's artifact, so run whenever + # the build matrix ran at all: a leg whose build failed then fails at its + # own download step, and the other package types still ship. + if: >- + !cancelled() + && github.event_name == 'release' + && needs.build-dashmate-packages.result != 'skipped' + runs-on: ${{ matrix.os }} + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - package_type: tarballs + os: ubuntu-24.04 + - package_type: win + os: ubuntu-24.04 + - package_type: deb + os: ubuntu-24.04 + - package_type: macos + os: macos-14 + steps: + # Treat finished installers as data. This job never installs or runs + # package code and never restores caches from the build jobs. + - name: Download Dashmate packages + uses: actions/download-artifact@v4 + with: + name: dashmate-${{ matrix.package_type }}-${{ github.sha }} + path: release-artifacts + + - name: Install the Apple certificate + if: runner.os == 'macOS' + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.MACOS_BUILD_CERTIFICATE_BASE64 }} + P12_PASSWORD: ${{ secrets.MACOS_P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + CERTIFICATE_PATH="$RUNNER_TEMP/build_certificate.p12" + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + printf '%s' "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o "$CERTIFICATE_PATH" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" + + - name: Sign macOS installers + if: runner.os == 'macOS' + run: | + set -euo pipefail + found=false + while IFS= read -r -d '' pkg; do + found=true + productsign --sign 'Developer ID Installer: The Dash Foundation, Inc.' \ + --keychain "$RUNNER_TEMP/app-signing.keychain-db" \ + "$pkg" "$RUNNER_TEMP/signed.pkg" + mv "$RUNNER_TEMP/signed.pkg" "$pkg" + done < <(find release-artifacts -type f -name '*.pkg' -print0) + "$found" || { echo '::error::No macOS installers were uploaded.'; exit 1; } - name: Notarize MacOS Release Build if: runner.os == 'macOS' + env: + APPLE_ID: ${{ secrets.MACOS_APPLE_ID }} + TEAM_ID: ${{ secrets.MACOS_TEAM_ID }} + NOTARIZING_PASSWORD: ${{ secrets.MACOS_NOTARIZING_PASSWORD }} + run: | + while IFS= read -r -d '' pkg; do + xcrun notarytool submit "$pkg" --apple-id "$APPLE_ID" --team-id "$TEAM_ID" --password "$NOTARIZING_PASSWORD" --wait + done < <(find release-artifacts -type f -name '*.pkg' -print0) + + - name: Delete the Apple keychain + if: always() && runner.os == 'macOS' run: | - find packages/dashmate/dist/ -name '*.pkg' -exec sh -c 'xcrun notarytool submit "{}" --apple-id "${{ secrets.MACOS_APPLE_ID }}" --team-id "${{ secrets.MACOS_TEAM_ID }}" --password "${{ secrets.MACOS_NOTARIZING_PASSWORD }}" --wait;' \; + security delete-keychain "$RUNNER_TEMP/app-signing.keychain-db" + rm -f "$RUNNER_TEMP/build_certificate.p12" - name: Upload artifacts to release uses: softprops/action-gh-release@v0.1.15 if: github.event_name == 'release' with: - files: packages/dashmate/dist/** + files: release-artifacts/** diff --git a/packages/swift-sdk/build_ios.sh b/packages/swift-sdk/build_ios.sh index 54c125a72d7..23ce6c0a3a3 100755 --- a/packages/swift-sdk/build_ios.sh +++ b/packages/swift-sdk/build_ios.sh @@ -21,7 +21,9 @@ NC="\033[0m" # ------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$SCRIPT_DIR/../../" -TARGET_DIR="$ROOT_DIR/target" +# Honor CARGO_TARGET_DIR like cargo itself does, so a caller can keep its +# own target cache (the release workflow keeps one outside the workspace). +TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT_DIR/target}" PACKAGE="rs-unified-sdk-ffi" XCFRAMEWORK="$SCRIPT_DIR/DashSDKFFI.xcframework" PROFILE="release" diff --git a/scripts/pack_dashmate.sh b/scripts/pack_dashmate.sh index 063239b7b1d..78125d6d226 100755 --- a/scripts/pack_dashmate.sh +++ b/scripts/pack_dashmate.sh @@ -46,6 +46,11 @@ cd $ROOT_PATH/packages/dashmate/package || exit 1 cp $ROOT_PATH/yarn.lock ./yarn.lock mkdir .yarn echo "nodeLinker: node-modules" > .yarnrc.yml +# CI signs the finished installer in a separate job with access to the Apple +# keychain. Keep the normal local signing behavior unless explicitly disabled. +if [ "$COMMAND" = macos ] && [ "${DASHMATE_UNSIGNED:-false}" = true ]; then + node -e 'const fs = require("fs"); const p = JSON.parse(fs.readFileSync("package.json", "utf8")); delete p.oclif.macos.sign; fs.writeFileSync("package.json", JSON.stringify(p, null, 2) + "\n");' +fi yarn install --no-immutable yarn oclif manifest yarn oclif pack $COMMAND $FLAGS From 74d3352678d39c36a043e42adaefd3d6bae0945a Mon Sep 17 00:00:00 2001 From: Dash schema release automation <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:29:49 +0000 Subject: [PATCH 010/113] chore(swift-sdk): freeze App Store schema 3.0.0 --- ...shSchemaSnapshotV3+PersistentAccount.swift | 78 ++++ ...SchemaSnapshotV3+PersistentAssetLock.swift | 102 +++++ ...hemaSnapshotV3+PersistentCoreAddress.swift | 68 +++ ...hSchemaSnapshotV3+PersistentDPNSName.swift | 132 ++++++ ...otV3+PersistentDashpayContactProfile.swift | 97 +++++ ...otV3+PersistentDashpayContactRequest.swift | 118 +++++ ...hotV3+PersistentDashpayIgnoredSender.swift | 67 +++ ...aSnapshotV3+PersistentDashpayPayment.swift | 99 +++++ ...aSnapshotV3+PersistentDashpayProfile.swift | 70 +++ ...emaSnapshotV3+PersistentDataContract.swift | 286 +++++++++++++ ...hSchemaSnapshotV3+PersistentDocument.swift | 181 ++++++++ ...emaSnapshotV3+PersistentDocumentType.swift | 121 ++++++ ...hSchemaSnapshotV3+PersistentIdentity.swift | 274 ++++++++++++ ...V3+PersistentIdentityBalanceMetadata.swift | 32 ++ ...DashSchemaSnapshotV3+PersistentIndex.swift | 86 ++++ ...chemaSnapshotV3+PersistentInvitation.swift | 73 ++++ ...shSchemaSnapshotV3+PersistentKeyword.swift | 40 ++ ...chemaSnapshotV3+PersistentMasternode.swift | 185 ++++++++ ...emaSnapshotV3+PersistentPendingInput.swift | 46 ++ ...SnapshotV3+PersistentPlatformAddress.swift | 78 ++++ ...PersistentPlatformAddressesSyncState.swift | 41 ++ ...hSchemaSnapshotV3+PersistentProperty.swift | 55 +++ ...SchemaSnapshotV3+PersistentPublicKey.swift | 218 ++++++++++ ...napshotV3+PersistentShieldedActivity.swift | 89 ++++ ...emaSnapshotV3+PersistentShieldedNote.swift | 66 +++ ...hotV3+PersistentShieldedOutgoingNote.swift | 49 +++ ...apshotV3+PersistentShieldedSyncState.swift | 34 ++ ...pshotV3+PersistentShieldedViewingKey.swift | 34 ++ ...DashSchemaSnapshotV3+PersistentToken.swift | 403 ++++++++++++++++++ ...emaSnapshotV3+PersistentTokenBalance.swift | 198 +++++++++ ...apshotV3+PersistentTokenHistoryEvent.swift | 112 +++++ ...apshotV3+PersistentTrackedMasternode.swift | 42 ++ ...hemaSnapshotV3+PersistentTransaction.swift | 167 ++++++++ .../DashSchemaSnapshotV3+PersistentTxo.swift | 99 +++++ ...ashSchemaSnapshotV3+PersistentWallet.swift | 95 +++++ ...otV3+PersistentWalletManagerMetadata.swift | 34 ++ .../DashSchemaSnapshotV3+Schema.swift | 47 ++ .../DashSchemaSnapshotV3+TokenTypes.swift | 165 +++++++ ...DashReleasedSchemaRegistry.generated.swift | 2 +- ...161203b8f5295c744f9fba3acafd475d72a9.store | Bin 0 -> 671744 bytes packages/swift-sdk/schema-releases.json | 186 +++++++- 41 files changed, 4354 insertions(+), 15 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentAccount.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentAssetLock.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentCoreAddress.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDPNSName.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayContactProfile.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayContactRequest.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayIgnoredSender.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayPayment.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayProfile.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDataContract.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDocument.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDocumentType.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentIdentity.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentIdentityBalanceMetadata.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentIndex.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentInvitation.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentKeyword.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentMasternode.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPendingInput.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPlatformAddress.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPlatformAddressesSyncState.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentProperty.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPublicKey.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedActivity.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedNote.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedOutgoingNote.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedSyncState.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedViewingKey.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentToken.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTokenBalance.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTokenHistoryEvent.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTrackedMasternode.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTransaction.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTxo.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentWallet.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentWalletManagerMetadata.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+Schema.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+TokenTypes.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/releases/fb711a3d216f05936c5ad8f6dcee161203b8f5295c744f9fba3acafd475d72a9.store diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentAccount.swift new file mode 100644 index 00000000000..ebcd61bbff8 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentAccount.swift @@ -0,0 +1,78 @@ +import Foundation +import SwiftData + +// `PersistentAccount` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentAccount { + #Unique([ + \.wallet, + \.accountType, + \.accountIndex, + \.standardTag, + \.registrationIndex, + \.keyClass, + \.userIdentityId, + \.friendIdentityId, + ]) + + var accountType: UInt32 + var accountIndex: UInt32 + var accountTypeName: String + var balanceConfirmed: UInt64 + var balanceUnconfirmed: UInt64 + var externalHighestUsed: Int32 + var internalHighestUsed: Int32 + var standardTag: UInt8 + var registrationIndex: UInt32 + var keyClass: UInt32 + var userIdentityId: Data + var friendIdentityId: Data + @Attribute(.unique) var accountExtendedPubKeyBytes: Data? + var createdAt: Date + var lastUpdated: Date + + var wallet: PersistentWallet + + @Relationship(deleteRule: .cascade, inverse: \PersistentCoreAddress.account) + var coreAddresses: [PersistentCoreAddress] + + @Relationship(deleteRule: .cascade, inverse: \PersistentPlatformAddress.account) + var platformAddresses: [PersistentPlatformAddress] + + var involvedTransactions: [PersistentTransaction] = [] + + init( + wallet: PersistentWallet, + accountType: UInt32, + accountIndex: UInt32, + accountTypeName: String + ) { + self.wallet = wallet + self.accountType = accountType + self.accountIndex = accountIndex + self.accountTypeName = accountTypeName + self.balanceConfirmed = 0 + self.balanceUnconfirmed = 0 + self.externalHighestUsed = -1 + self.internalHighestUsed = -1 + self.standardTag = 0 + self.registrationIndex = 0 + self.keyClass = 0 + self.userIdentityId = Data() + self.friendIdentityId = Data() + self.accountExtendedPubKeyBytes = nil + self.createdAt = Date() + self.lastUpdated = Date() + self.coreAddresses = [] + self.platformAddresses = [] + self.involvedTransactions = [] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentAssetLock.swift new file mode 100644 index 00000000000..d4e5844aaff --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentAssetLock.swift @@ -0,0 +1,102 @@ +import Foundation +import SwiftData + +// `PersistentAssetLock` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentAssetLock { + #Index([\.walletId]) + + @Attribute(.unique) var outPointHex: String + + var walletId: Data + + var transactionBytes: Data + + var fundingTypeRaw: Int + + var identityIndexRaw: Int32 + + var accountIndexRaw: Int32 = 0 + + var amountDuffs: Int64 + + var statusRaw: Int + + var proofBytes: Data? + + var recipientPlatformAddressHash: Data? + + var recipientPlatformAddressType: UInt8? + + var recipientIsExternal: Bool? + + var createdAt: Date + var updatedAt: Date + + init( + outPointHex: String, + walletId: Data, + transactionBytes: Data, + fundingTypeRaw: Int, + identityIndexRaw: Int32, + accountIndexRaw: Int32 = 0, + amountDuffs: Int64, + statusRaw: Int, + proofBytes: Data? = nil + ) { + self.outPointHex = outPointHex + self.walletId = walletId + self.transactionBytes = transactionBytes + self.fundingTypeRaw = fundingTypeRaw + self.identityIndexRaw = identityIndexRaw + self.accountIndexRaw = accountIndexRaw + self.amountDuffs = amountDuffs + self.statusRaw = statusRaw + self.proofBytes = proofBytes + self.createdAt = Date() + self.updatedAt = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentAssetLock { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } + + static func predicate( + walletId: Data, + identityIndex: UInt32 + ) -> Predicate { + let identityIndexRaw = Int32(bitPattern: identityIndex) + return #Predicate { entry in + entry.walletId == walletId && entry.identityIndexRaw == identityIndexRaw + } + } +} + +extension DashSchemaSnapshotV3.PersistentAssetLock { + static func encodeOutPoint(rawBytes: Data) -> String { + precondition(rawBytes.count == 36, "outpoint must be 36 bytes") + let txid = rawBytes.prefix(32) + let voutBytes = rawBytes.suffix(4) + let vout = voutBytes.withUnsafeBytes { raw -> UInt32 in + var value: UInt32 = 0 + withUnsafeMutableBytes(of: &value) { dst in + dst.copyBytes(from: raw.prefix(MemoryLayout.size)) + } + return UInt32(littleEndian: value) + } + let txidHex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(txidHex):\(vout)" + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentCoreAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentCoreAddress.swift new file mode 100644 index 00000000000..ce584682102 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentCoreAddress.swift @@ -0,0 +1,68 @@ +import Foundation +import SwiftData + +// `PersistentCoreAddress` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentCoreAddress { + @Attribute(.unique) var address: String + var publicKey: Data + var keyType: UInt8 = 0 + var poolTypeTag: UInt8 + var addressIndex: UInt32 + var derivationPath: String + var isUsed: Bool + var firstSeenHeight: UInt32 + var lastSeenHeight: UInt32 + var balance: UInt64 + var createdAt: Date + var lastUpdated: Date + + var account: PersistentAccount? + + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.coreAddress) + var txos: [PersistentTxo] = [] + + init( + address: String, + publicKey: Data = Data(), + keyType: UInt8 = 0, + poolTypeTag: UInt8, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0 + ) { + self.address = address + self.publicKey = publicKey + self.keyType = keyType + self.poolTypeTag = poolTypeTag + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.balance = balance + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentCoreAddress { + var poolTypeName: String { + switch poolTypeTag { + case 0: return "External" + case 1: return "Internal" + case 2: return "Additional" + case 3: return "Additional (Hardened)" + default: return "Unknown(\(poolTypeTag))" + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDPNSName.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDPNSName.swift new file mode 100644 index 00000000000..fe7677978ae --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDPNSName.swift @@ -0,0 +1,132 @@ +import Foundation +import SwiftData + +// `PersistentDPNSName` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentDPNSName { + #Unique([\.networkRaw, \.normalizedParentDomainName, \.normalizedLabel]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var label: String + + var normalizedLabel: String + + var parentDomainName: String + + var normalizedParentDomainName: String + + var acquiredAt: UInt64 + + var isOwned: Bool = true + + var documentIdBase58: String? + + var priceCredits: Int64? + + var saleStatusRaw: Int16 = 0 + + var counterpartyIdBase58: String? + + var documentCreatedAtMs: UInt64? + + var documentUpdatedAtMs: UInt64? + + var documentTransferredAtMs: UInt64? + + var marketplaceUpdatedAt: UInt64 = 0 + + var identity: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + identity: PersistentIdentity, + label: String, + parentDomainName: String = "dash", + acquiredAt: UInt64 = 0, + isOwned: Bool = true + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.label = label + self.normalizedLabel = Self.normalize(label) + self.parentDomainName = parentDomainName + self.normalizedParentDomainName = Self.normalize(parentDomainName) + self.acquiredAt = acquiredAt + self.isOwned = isOwned + self.documentIdBase58 = nil + self.priceCredits = nil + self.saleStatusRaw = 0 + self.counterpartyIdBase58 = nil + self.documentCreatedAtMs = nil + self.documentUpdatedAtMs = nil + self.documentTransferredAtMs = nil + self.marketplaceUpdatedAt = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentDPNSName { + var saleStatus: DpnsNameSaleStatus? { + guard documentIdBase58 != nil else { return nil } + switch saleStatusRaw { + case 0: + return .owned + case 1: + guard let to = counterpartyId else { return nil } + return .sold(to: to) + case 2: + guard let to = counterpartyId else { return nil } + return .transferred(to: to) + default: + return nil + } + } + + var counterpartyId: Data? { + counterpartyIdBase58.flatMap { Data.identifier(fromBase58: $0) } + } + + var listedPriceCredits: UInt64? { + guard documentIdBase58 != nil, let priceCredits else { return nil } + return UInt64(bitPattern: priceCredits) + } +} + +extension DashSchemaSnapshotV3.PersistentDPNSName { + static func normalize(_ input: String) -> String { + String(input.map { c -> Character in + switch c { + case "o", "O": return "0" + case "i", "I": return "1" + case "l", "L": return "1" + default: return Character(c.lowercased()) + } + }) + } +} + +extension DashSchemaSnapshotV3.PersistentDPNSName { + static func predicate(identityId: Data) -> Predicate { + let target = identityId + return #Predicate { name in + name.identity.identityId == target && name.isOwned == true + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayContactProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayContactProfile.swift new file mode 100644 index 00000000000..8246f986fae --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayContactProfile.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftData + +// `PersistentDashpayContactProfile` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentDashpayContactProfile { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var contactIdentityId: Data + + var displayName: String? + + var publicMessage: String? + + var bio: String? + + var avatarUrl: String? + + var avatarHash: Data? + + var avatarFingerprint: Data? + + var checkedAtMs: UInt64 + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + owner: PersistentIdentity, + contactIdentityId: Data, + checkedAtMs: UInt64, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.checkedAtMs = checkedAtMs + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentDashpayContactProfile { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + contactIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + let contact = contactIdentityId + return #Predicate { row in + row.ownerIdentityId == target + && row.contactIdentityId == contact + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayContactRequest.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayContactRequest.swift new file mode 100644 index 00000000000..de058bd7c0d --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayContactRequest.swift @@ -0,0 +1,118 @@ +import Foundation +import SwiftData + +// `PersistentDashpayContactRequest` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentDashpayContactRequest { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId, \.isOutgoing + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var contactIdentityId: Data + + var isOutgoing: Bool + + var senderKeyIndex: UInt32 + + var recipientKeyIndex: UInt32 + + var accountReference: UInt32 + + var encryptedPublicKey: Data + + var encryptedAccountLabel: Data? + + var autoAcceptProof: Data? + + var coreHeightCreatedAt: UInt32 + + var createdAtMillis: UInt64 + + var paymentChannelBroken: Bool = false + + var contactAlias: String? + + var contactNote: String? + + var contactHidden: Bool = false + + var contactAccountLabel: String? + + var contactAcceptedAccounts: [UInt32] = [] + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + owner: PersistentIdentity, + contactIdentityId: Data, + isOutgoing: Bool, + senderKeyIndex: UInt32, + recipientKeyIndex: UInt32, + accountReference: UInt32, + encryptedPublicKey: Data, + encryptedAccountLabel: Data? = nil, + autoAcceptProof: Data? = nil, + coreHeightCreatedAt: UInt32, + createdAtMillis: UInt64, + paymentChannelBroken: Bool = false + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.isOutgoing = isOutgoing + self.senderKeyIndex = senderKeyIndex + self.recipientKeyIndex = recipientKeyIndex + self.accountReference = accountReference + self.encryptedPublicKey = encryptedPublicKey + self.encryptedAccountLabel = encryptedAccountLabel + self.autoAcceptProof = autoAcceptProof + self.coreHeightCreatedAt = coreHeightCreatedAt + self.createdAtMillis = createdAtMillis + self.paymentChannelBroken = paymentChannelBroken + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentDashpayContactRequest { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + isOutgoing: Bool + ) -> Predicate { + let target = ownerIdentityId + let direction = isOutgoing + return #Predicate { row in + row.ownerIdentityId == target && row.isOutgoing == direction + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayIgnoredSender.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayIgnoredSender.swift new file mode 100644 index 00000000000..d9dfd85e728 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayIgnoredSender.swift @@ -0,0 +1,67 @@ +import Foundation +import SwiftData + +// `PersistentDashpayIgnoredSender` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentDashpayIgnoredSender { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.ignoredSenderId + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var ignoredSenderId: Data + + var owner: PersistentIdentity + + var ignoredAt: Date + + init( + owner: PersistentIdentity, + ignoredSenderId: Data + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.ignoredSenderId = ignoredSenderId + self.ignoredAt = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentDashpayIgnoredSender { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + ignoredSenderId: Data + ) -> Predicate { + let target = ownerIdentityId + let sender = ignoredSenderId + return #Predicate { row in + row.ownerIdentityId == target + && row.ignoredSenderId == sender + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayPayment.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayPayment.swift new file mode 100644 index 00000000000..2686c953b10 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayPayment.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftData + +// `PersistentDashpayPayment` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentDashpayPayment { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.txid + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var counterpartyIdentityId: Data + + var amountDuffs: UInt64 + + var directionRaw: UInt8 + + var direction: DashPayPaymentDirection { + get { DashPayPaymentDirection(rawValue: directionRaw) ?? .sent } + set { directionRaw = newValue.rawValue } + } + + var statusRaw: UInt8 + + var status: DashPayPaymentStatus { + get { DashPayPaymentStatus(rawValue: statusRaw) ?? .pending } + set { statusRaw = newValue.rawValue } + } + + var txid: String + + var memo: String? + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + owner: PersistentIdentity, + counterpartyIdentityId: Data, + amountDuffs: UInt64, + direction: DashPayPaymentDirection, + status: DashPayPaymentStatus, + txid: String, + memo: String? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.counterpartyIdentityId = counterpartyIdentityId + self.amountDuffs = amountDuffs + self.directionRaw = direction.rawValue + self.statusRaw = status.rawValue + self.txid = txid + self.memo = memo + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentDashpayPayment { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + counterpartyIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + let counterparty = counterpartyIdentityId + return #Predicate { row in + row.ownerIdentityId == target + && row.counterpartyIdentityId == counterparty + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayProfile.swift new file mode 100644 index 00000000000..34e0c32f455 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDashpayProfile.swift @@ -0,0 +1,70 @@ +import Foundation +import SwiftData + +// `PersistentDashpayProfile` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentDashpayProfile { + #Unique([\.networkRaw, \.identity]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var displayName: String? + + var publicMessage: String? + + var bio: String? + + var avatarUrl: String? + + var avatarHash: Data? + + var avatarFingerprint: Data? + + var identity: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + identity: PersistentIdentity, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentDashpayProfile { + static func predicate(identityId: Data) -> Predicate { + let target = identityId + return #Predicate { profile in + profile.identity.identityId == target + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDataContract.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDataContract.swift new file mode 100644 index 00000000000..7b32e2892a3 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDataContract.swift @@ -0,0 +1,286 @@ +import Foundation +import SwiftData + +// `PersistentDataContract` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentDataContract { + #Index([\.networkRaw]) + + @Attribute(.unique) var id: Data + var name: String + var serializedContract: Data + var createdAt: Date + var lastAccessedAt: Date + + var binarySerialization: Data? + + var version: Int? + var ownerId: Data? + + @Relationship(deleteRule: .cascade, inverse: \PersistentKeyword.dataContract) + var keywordRelations: [PersistentKeyword] + var contractDescription: String? + + var schemaData: Data + var documentTypesData: Data + + var groupsData: Data? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var lastUpdated: Date + var lastSyncedAt: Date? + + var canBeDeleted: Bool + var readonly: Bool + var keepsHistory: Bool + var schemaDefs: Int? + + var documentsKeepHistoryContractDefault: Bool + var documentsMutableContractDefault: Bool + var documentsCanBeDeletedContractDefault: Bool + + @Relationship(deleteRule: .cascade, inverse: \PersistentToken.dataContract) + var tokens: [PersistentToken]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocumentType.dataContract) + var documentTypes: [PersistentDocumentType]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.dataContract) + var documents: [PersistentDocument] + + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.ownedDataContracts) + var ownerIdentity: PersistentIdentity? + + var hasTokens: Bool + var tokensData: Data? + + var idBase58: String { + id.toBase58String() + } + + var ownerIdBase58: String? { + ownerId?.toBase58String() + } + + var parsedContract: [String: Any]? { + try? JSONSerialization.jsonObject(with: serializedContract, options: []) as? [String: Any] + } + + var binarySerializationHex: String? { + binarySerialization?.toHexString() + } + + var keywords: [String] { + keywordRelations.map { $0.keyword } + } + + var schema: [String: Any] { + get { + guard let json = try? JSONSerialization.jsonObject(with: schemaData), + let dict = json as? [String: Any] else { + return [:] + } + return dict + } + set { + schemaData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var documentTypesList: [String] { + get { + guard let json = try? JSONSerialization.jsonObject(with: documentTypesData), + let array = json as? [String] else { + return [] + } + return array + } + set { + documentTypesData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var tokenConfigurations: [String: Any]? { + get { + guard let data = tokensData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + tokensData = try? JSONSerialization.data(withJSONObject: newValue) + hasTokens = true + } else { + tokensData = nil + hasTokens = false + } + lastUpdated = Date() + } + } + + var groups: [String: Any]? { + get { + guard let data = groupsData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + groupsData = try? JSONSerialization.data(withJSONObject: newValue) + } else { + groupsData = nil + } + lastUpdated = Date() + } + } + + init( + id: Data, + name: String, + serializedContract: Data, + version: Int? = 1, + ownerId: Data? = nil, + schema: [String: Any] = [:], + documentTypesList: [String] = [], + keywords: [String] = [], + description: String? = nil, + hasTokens: Bool = false, + network: Network + ) { + self.id = id + self.name = name + self.serializedContract = serializedContract + self.createdAt = Date() + self.lastAccessedAt = Date() + self.version = version + self.ownerId = ownerId + + self.schemaData = (try? JSONSerialization.data(withJSONObject: schema)) ?? Data() + self.documentTypesData = (try? JSONSerialization.data(withJSONObject: documentTypesList)) ?? Data() + + self.keywordRelations = keywords.map { PersistentKeyword(keyword: $0, contractId: id.toBase58String()) } + self.contractDescription = description + + self.hasTokens = hasTokens + self.tokensData = nil + + self.groupsData = nil + + self.documents = [] + + self.ownerIdentity = nil + + self.networkRaw = network.rawValue + self.lastUpdated = Date() + self.lastSyncedAt = nil + + self.canBeDeleted = false + self.readonly = false + self.keepsHistory = false + self.documentsKeepHistoryContractDefault = false + self.documentsMutableContractDefault = true + self.documentsCanBeDeletedContractDefault = true + } + + func updateLastAccessed() { + self.lastAccessedAt = Date() + } + + func updateVersion(_ newVersion: Int) { + self.version = newVersion + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func addDocument(_ document: PersistentDocument) { + documents.append(document) + lastUpdated = Date() + } + + func removeDocument(withId documentId: String) { + if let docIdData = Data.identifier(fromBase58: documentId) { + documents.removeAll { $0.id == docIdData } + } + lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentDataContract { + static func predicate(contractId: String) -> Predicate { + guard let idData = Data.identifier(fromBase58: contractId) else { + return #Predicate { _ in false } + } + return #Predicate { contract in + contract.id == idData + } + } + + static func predicate(ownerId: Data) -> Predicate { + #Predicate { contract in + contract.ownerId == ownerId + } + } + + static func predicate(name: String) -> Predicate { + #Predicate { contract in + contract.name.localizedStandardContains(name) + } + } + + static var contractsWithTokensPredicate: Predicate { + #Predicate { contract in + contract.hasTokens == true + } + } + + static func predicate(keyword: String) -> Predicate { + #Predicate { contract in + contract.keywordRelations.contains { $0.keyword == keyword } + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { contract in + contract.lastSyncedAt == nil || contract.lastSyncedAt! < date + } + } + + static func predicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { contract in + contract.networkRaw == target + } + } + + static func contractsWithTokensPredicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { contract in + contract.hasTokens == true && contract.networkRaw == target + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDocument.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDocument.swift new file mode 100644 index 00000000000..0701244a387 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDocument.swift @@ -0,0 +1,181 @@ +import Foundation +import SwiftData + +// `PersistentDocument` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentDocument { + #Index([\.networkRaw]) + + @Attribute(.unique) var documentId: String + + var documentType: String + var revision: Int32 + var data: Data + + var contractId: String + var ownerId: String + + var contractIdData: Data + var ownerIdData: Data + + var createdAt: Date + var updatedAt: Date + var transferredAt: Date? + + var createdAtBlockHeight: Int64? + var updatedAtBlockHeight: Int64? + var transferredAtBlockHeight: Int64? + + var createdAtCoreBlockHeight: Int64? + var updatedAtCoreBlockHeight: Int64? + var transferredAtCoreBlockHeight: Int64? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var isDeleted: Bool = false + + var localCreatedAt: Date + var localUpdatedAt: Date + + var documentType_relation: PersistentDocumentType? + var dataContract: PersistentDataContract? + + var ownerIdentity: PersistentIdentity? + + var id: Data { + Data.identifier(fromBase58: documentId) ?? Data() + } + + var idBase58: String { + documentId + } + + var ownerIdBase58: String { + ownerId + } + + var contractIdBase58: String { + contractId + } + + var properties: [String: Any]? { + try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var displayTitle: String { + guard let props = properties else { return "Document" } + + if let title = props["title"] as? String { return title } + if let name = props["name"] as? String { return name } + if let label = props["label"] as? String { return label } + if let normalizedLabel = props["normalizedLabel"] as? String { return normalizedLabel } + + return documentType + } + + var summary: String { + var parts: [String] = [] + + parts.append("Type: \(documentType)") + parts.append("Rev: \(revision)") + + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateStyle = .short + parts.append("Created: \(formatter.string(from: createdAt))") + + return parts.joined(separator: " • ") + } + + init( + documentId: String, + documentType: String, + revision: Int32, + data: Data, + contractId: String, + ownerId: String, + network: Network + ) { + self.documentId = documentId + self.documentType = documentType + self.revision = revision + self.data = data + self.contractId = contractId + self.ownerId = ownerId + self.contractIdData = Data.identifier(fromBase58: contractId) ?? Data() + self.ownerIdData = Data.identifier(fromBase58: ownerId) ?? Data() + self.networkRaw = network.rawValue + self.createdAt = Date() + self.updatedAt = Date() + self.localCreatedAt = Date() + self.localUpdatedAt = Date() + } + + func updateProperties(_ newData: Data) { + self.data = newData + self.updatedAt = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = Int32(newRevision) + self.updatedAt = Date() + } + + func markAsDeleted() { + self.isDeleted = true + self.updatedAt = Date() + } + + static func predicate(documentId: String) -> Predicate { + #Predicate { doc in + doc.documentId == documentId && doc.isDeleted == false + } + } + + static func predicate(contractId: String, network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { doc in + doc.contractId == contractId && doc.networkRaw == target && doc.isDeleted == false + } + } + + static func predicate(ownerId: Data) -> Predicate { + let ownerIdString = ownerId.toBase58String() + return #Predicate { doc in + doc.ownerId == ownerIdString && doc.isDeleted == false + } + } + + func linkToLocalIdentityIfNeeded(in modelContext: ModelContext) { + guard ownerIdentity == nil else { return } + + let ownerIdToMatch = self.ownerIdData + let identityPredicate = #Predicate { identity in + identity.identityId == ownerIdToMatch && identity.isLocal == true + } + + let descriptor = FetchDescriptor(predicate: identityPredicate) + + do { + if let localIdentity = try modelContext.fetch(descriptor).first { + self.ownerIdentity = localIdentity + self.localUpdatedAt = Date() + } + } catch { + print("Failed to link document to local identity: \(error)") + } + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDocumentType.swift new file mode 100644 index 00000000000..62fb89aaaaf --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentDocumentType.swift @@ -0,0 +1,121 @@ +import Foundation +import SwiftData + +// `PersistentDocumentType` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentDocumentType { + @Attribute(.unique) var id: Data + var contractId: Data + var name: String + + var schemaJSON: Data + var propertiesJSON: Data + + var documentsKeepHistory: Bool + var documentsMutable: Bool + var documentsCanBeDeleted: Bool + var documentsTransferable: Bool + + var indexOnly: Bool = false + + var requiredFieldsJSON: Data? + + var securityLevel: Int + + var tradeMode: Int + var creationRestrictionMode: Int + + var requiresIdentityEncryptionBoundedKey: Bool + var requiresIdentityDecryptionBoundedKey: Bool + + var createdAt: Date + var lastAccessedAt: Date + + var dataContract: PersistentDataContract? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.documentType_relation) + var documents: [PersistentDocument]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentIndex.documentType) + var indices: [PersistentIndex]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentProperty.documentType) + var propertiesList: [PersistentProperty]? + + init(contractId: Data, name: String, schemaJSON: Data, propertiesJSON: Data) { + var idData = contractId + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.name = name + self.schemaJSON = schemaJSON + self.propertiesJSON = propertiesJSON + self.documentsKeepHistory = false + self.documentsMutable = true + self.documentsCanBeDeleted = true + self.documentsTransferable = false + self.securityLevel = 0 + self.tradeMode = 0 + self.creationRestrictionMode = 0 + self.requiresIdentityEncryptionBoundedKey = false + self.requiresIdentityDecryptionBoundedKey = false + self.createdAt = Date() + self.lastAccessedAt = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentDocumentType { + var contractIdBase58: String { + contractId.toBase58String() + } + + var schema: [String: Any]? { + try? JSONSerialization.jsonObject(with: schemaJSON, options: []) as? [String: Any] + } + + var properties: [String: Any]? { + try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String: Any] + } + + var persistentProperties: [DashSchemaSnapshotV3.PersistentProperty]? { + return propertiesList + } + + var requiredFields: [String]? { + guard let data = requiredFieldsJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String] + } + + var immutability: DocumentTypeImmutability { + DocumentTypeImmutability(documentTypeSchema: schema) + } + + var immutableProperties: [String] { + immutability.immutableProperties + } + + var immutableAllowSetting: [String] { + immutability.immutableAllowSetting + } + + var typedArrays: [DocumentTypedArray] { + DocumentTypedArray.all(inDocumentTypeSchema: schema) + } + + func typedArray(named name: String) -> DocumentTypedArray? { + DocumentTypedArray.named(name, inDocumentTypeSchema: schema) + } + + var documentCount: Int { + documents?.count ?? 0 + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentIdentity.swift new file mode 100644 index 00000000000..c6a3070d86d --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentIdentity.swift @@ -0,0 +1,274 @@ +import Foundation +import SwiftData + +// `PersistentIdentity` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentIdentity { + #Index([\.networkRaw]) + + @Attribute(.unique) var identityId: Data + var balance: Int64 + var revision: Int64 + var isLocal: Bool + var alias: String? + var dpnsName: String? + var mainDpnsName: String? + var identityType: String + + var votingPrivateKeyIdentifier: String? + var ownerPrivateKeyIdentifier: String? + var payoutPrivateKeyIdentifier: String? + + @Relationship(deleteRule: .cascade) var publicKeys: [PersistentPublicKey] + + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var wallet: PersistentWallet? + var identityIndex: UInt32 = 0 + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.ownerIdentity) var documents: [PersistentDocument] + @Relationship(deleteRule: .nullify) var tokenBalances: [PersistentTokenBalance] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDPNSName.identity) + var dpnsNames: [PersistentDPNSName] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayProfile.identity) + var dashpayProfile: PersistentDashpayProfile? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactRequest.owner) + var contactRequests: [PersistentDashpayContactRequest] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayPayment.owner) + var dashpayPayments: [PersistentDashpayPayment] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayIgnoredSender.owner) + var dashpayIgnoredSenders: [PersistentDashpayIgnoredSender] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactProfile.owner) + var contactProfiles: [PersistentDashpayContactProfile] = [] + + var ownedDataContracts: [PersistentDataContract] + + init( + identityId: Data, + balance: Int64 = 0, + revision: Int64 = 0, + isLocal: Bool = true, + alias: String? = nil, + dpnsName: String? = nil, + mainDpnsName: String? = nil, + identityType: IdentityType = .user, + votingPrivateKeyIdentifier: String? = nil, + ownerPrivateKeyIdentifier: String? = nil, + payoutPrivateKeyIdentifier: String? = nil, + network: Network, + identityIndex: UInt32 = 0 + ) { + self.identityId = identityId + self.balance = balance + self.revision = revision + self.isLocal = isLocal + self.alias = alias + self.dpnsName = dpnsName + self.mainDpnsName = mainDpnsName + self.identityType = identityType.rawValue + self.votingPrivateKeyIdentifier = votingPrivateKeyIdentifier + self.ownerPrivateKeyIdentifier = ownerPrivateKeyIdentifier + self.payoutPrivateKeyIdentifier = payoutPrivateKeyIdentifier + self.networkRaw = network.rawValue + self.identityIndex = identityIndex + self.publicKeys = [] + self.documents = [] + self.tokenBalances = [] + self.dpnsNames = [] + self.dashpayProfile = nil + self.contactRequests = [] + self.dashpayPayments = [] + self.dashpayIgnoredSenders = [] + self.contactProfiles = [] + self.ownedDataContracts = [] + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + } + + var identityIdString: String { + identityId.toHexString() + } + + var identityIdBase58: String { + identityId.toBase58String() + } + + var formattedBalance: String { + let dashAmount = Double(balance) / 100_000_000_000 + return String(format: "%.8f DASH", dashAmount) + } + + var identityPublicKeys: [IdentityPublicKey] { + publicKeys.compactMap { $0.toIdentityPublicKey() } + } + + var displayName: String { + if let alias = alias, !alias.isEmpty { + return alias + } + if let mainDpnsName = mainDpnsName, !mainDpnsName.isEmpty { + return mainDpnsName + } + if let dpnsName = dpnsName, !dpnsName.isEmpty { + return dpnsName + } + return String(identityIdString.prefix(12)) + "..." + } + + var identityTypeEnum: IdentityType { + IdentityType(rawValue: identityType) ?? .user + } + + func updateBalance(_ newBalance: Int64) { + self.balance = newBalance + self.lastUpdated = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = newRevision + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func updateDPNSName(_ name: String?) { + self.dpnsName = name + self.lastUpdated = Date() + } + + func addPublicKey(_ key: PersistentPublicKey) { + publicKeys.append(key) + lastUpdated = Date() + } + + func removePublicKey(withId keyId: Int32) { + publicKeys.removeAll { $0.keyId == keyId } + lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentIdentity { + static func predicate(identityId: Data) -> Predicate { + #Predicate { identity in + identity.identityId == identityId + } + } + + static var walletOwnedIdentitiesPredicate: Predicate { + #Predicate { identity in + identity.wallet != nil + } + } + + static func predicate(type: IdentityType) -> Predicate { + let typeString = type.rawValue + return #Predicate { identity in + identity.identityType == typeString + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { identity in + identity.lastSyncedAt == nil || identity.lastSyncedAt! < date + } + } + + static func predicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { identity in + identity.networkRaw == target + } + } + + static func walletOwnedIdentitiesPredicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { identity in + identity.wallet != nil && identity.networkRaw == target + } + } + + static func fetch( + in context: ModelContext, + identityId: Data + ) -> DashSchemaSnapshotV3.PersistentIdentity? { + let target = identityId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.identityId == target } + ) + return try? context.fetch(descriptor).first + } +} + +extension DashSchemaSnapshotV3.PersistentIdentity { + @discardableResult + static func updateBalance( + in context: ModelContext, + identityId: Data, + balance: UInt64 + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.balance = Int64(bitPattern: balance) + row.lastUpdated = Date() + return true + } + + @discardableResult + static func updateDpnsName( + in context: ModelContext, + identityId: Data, + dpnsName: String? + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.dpnsName = dpnsName + row.lastUpdated = Date() + return true + } + + @discardableResult + static func updateMainDpnsName( + in context: ModelContext, + identityId: Data, + mainDpnsName: String? + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.mainDpnsName = mainDpnsName + row.lastUpdated = Date() + return true + } + + @discardableResult + static func remove( + in context: ModelContext, + identityId: Data + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + context.delete(row) + return true + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentIdentityBalanceMetadata.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentIdentityBalanceMetadata.swift new file mode 100644 index 00000000000..eaea895a397 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentIdentityBalanceMetadata.swift @@ -0,0 +1,32 @@ +import Foundation +import SwiftData + +// `PersistentIdentityBalanceMetadata` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentIdentityBalanceMetadata { + #Unique([\.networkRaw, \.walletId, \.identityId]) + var networkRaw: UInt32 + var walletId: Data + var identityId: Data + var platformHeight: Int64 + var coreHeight: UInt32 + var timestampMillis: Int64 + + init(networkRaw: UInt32, walletId: Data, identityId: Data, + platformHeight: UInt64, coreHeight: UInt32, timestampMillis: UInt64) { + self.networkRaw = networkRaw + self.walletId = walletId + self.identityId = identityId + self.platformHeight = Int64(bitPattern: platformHeight) + self.coreHeight = coreHeight + self.timestampMillis = Int64(bitPattern: timestampMillis) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentIndex.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentIndex.swift new file mode 100644 index 00000000000..3f09cadff93 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentIndex.swift @@ -0,0 +1,86 @@ +import Foundation +import SwiftData + +// `PersistentIndex` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentIndex { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + var unique: Bool + var nullSearchable: Bool + var contested: Bool + + var countable: String? + var rangeCountable: Bool = false + var summable: String? + var rangeSummable: Bool = false + var averageable: String? + var rangeAverageable: Bool = false + + var rankedCountable: Bool = false + var rankedSummable: Bool = false + var rankedAverageable: Bool = false + + var terminal: String? + + var preallocated: Bool = false + + var timeRangeJSON: Data? + + var propertiesJSON: Data + + var contestedDetailsJSON: Data? + + var createdAt: Date + + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, properties: [String]) { + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.unique = false + self.nullSearchable = false + self.contested = false + + if let jsonData = try? JSONSerialization.data(withJSONObject: properties, options: []) { + self.propertiesJSON = jsonData + } else { + self.propertiesJSON = Data() + } + + self.createdAt = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentIndex { + var properties: [String]? { + try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String] + } + + var contestedDetails: [String: Any]? { + guard let data = contestedDetailsJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var timeRange: [String: Any]? { + guard let data = timeRangeJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentInvitation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentInvitation.swift new file mode 100644 index 00000000000..b7f5fe45f9b --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentInvitation.swift @@ -0,0 +1,73 @@ +import Foundation +import SwiftData + +// `PersistentInvitation` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentInvitation { + #Index([\.walletId]) + + @Attribute(.unique) var outPointHex: String + + var rawOutPoint: Data + + var walletId: Data + + var fundingIndexRaw: Int + + var amountDuffs: Int64 + + var expiryUnix: Int + + var createdAtSecs: Int + + var hasInviter: Bool + + var statusRaw: Int + + var reclaimInFlight: Bool = false + + var createdAt: Date + var updatedAt: Date + + init( + outPointHex: String, + rawOutPoint: Data, + walletId: Data, + fundingIndexRaw: Int, + amountDuffs: Int64, + expiryUnix: Int, + createdAtSecs: Int, + hasInviter: Bool, + statusRaw: Int, + reclaimInFlight: Bool = false + ) { + self.outPointHex = outPointHex + self.rawOutPoint = rawOutPoint + self.walletId = walletId + self.fundingIndexRaw = fundingIndexRaw + self.amountDuffs = amountDuffs + self.expiryUnix = expiryUnix + self.createdAtSecs = createdAtSecs + self.hasInviter = hasInviter + self.statusRaw = statusRaw + self.reclaimInFlight = reclaimInFlight + self.createdAt = Date() + self.updatedAt = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentInvitation { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentKeyword.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentKeyword.swift new file mode 100644 index 00000000000..f19e626315f --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentKeyword.swift @@ -0,0 +1,40 @@ +import Foundation +import SwiftData + +// `PersistentKeyword` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentKeyword { + @Attribute(.unique) var id: String + var keyword: String + var contractId: String + + var dataContract: PersistentDataContract? + + init(keyword: String, contractId: String) { + self.id = "\(contractId)_\(keyword)" + self.keyword = keyword + self.contractId = contractId + } + } +} + +extension DashSchemaSnapshotV3.PersistentKeyword { + static func predicate(keyword: String) -> Predicate { + #Predicate { item in + item.keyword.localizedStandardContains(keyword) + } + } + + static func predicate(contractId: String) -> Predicate { + #Predicate { item in + item.contractId == contractId + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentMasternode.swift new file mode 100644 index 00000000000..78ed41f05a0 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentMasternode.swift @@ -0,0 +1,185 @@ +import Foundation +import SwiftData + +// `PersistentMasternode` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentMasternode { + #Unique([\.walletId, \.proTxHash]) + + var walletId: Data + var proTxHash: Data + var registrationTxid: Data + + var serviceAddress: String? + var isEvonode: Bool + + var ownerKeyHash: Data? + var votingKeyHash: Data? + var ownerAddress: String? + var votingAddress: String? + var operatorPublicKey: Data? + var platformNodeId: Data? + var payoutAddress: String? + var operatorPseudoAddress: String? + var platformNodeAddress: String? + + var ownerInWallet: Bool = false + var ownerAccountType: UInt8 = 0 + var ownerKeyIndex: UInt32 = 0 + var votingInWallet: Bool = false + var votingAccountType: UInt8 = 0 + var votingKeyIndex: UInt32 = 0 + var operatorInWallet: Bool = false + var operatorAccountType: UInt8 = 0 + var operatorKeyIndex: UInt32 = 0 + var platformInWallet: Bool = false + var platformAccountType: UInt8 = 0 + var platformKeyIndex: UInt32 = 0 + + var collateralTxid: Data? + var collateralVout: UInt32 + + var revoked: Bool + var revocationReason: UInt16 + var statusRaw: UInt8 = 3 + + var registrationHeight: UInt32 + var hasRegistration: Bool + var txCount: UInt32 + + var orderIndex: UInt32 + var typeIndex: UInt32 = 0 + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + proTxHash: Data, + registrationTxid: Data, + serviceAddress: String? = nil, + isEvonode: Bool = false, + ownerKeyHash: Data? = nil, + votingKeyHash: Data? = nil, + ownerAddress: String? = nil, + votingAddress: String? = nil, + operatorPublicKey: Data? = nil, + platformNodeId: Data? = nil, + payoutAddress: String? = nil, + collateralTxid: Data? = nil, + collateralVout: UInt32 = 0, + revoked: Bool = false, + revocationReason: UInt16 = 0, + statusRaw: UInt8 = 3, + registrationHeight: UInt32 = 0, + hasRegistration: Bool = false, + txCount: UInt32 = 0, + orderIndex: UInt32 = 0, + typeIndex: UInt32 = 0 + ) { + self.walletId = walletId + self.proTxHash = proTxHash + self.registrationTxid = registrationTxid + self.serviceAddress = serviceAddress + self.isEvonode = isEvonode + self.ownerKeyHash = ownerKeyHash + self.votingKeyHash = votingKeyHash + self.ownerAddress = ownerAddress + self.votingAddress = votingAddress + self.operatorPublicKey = operatorPublicKey + self.platformNodeId = platformNodeId + self.payoutAddress = payoutAddress + self.collateralTxid = collateralTxid + self.collateralVout = collateralVout + self.revoked = revoked + self.revocationReason = revocationReason + self.statusRaw = statusRaw + self.registrationHeight = registrationHeight + self.hasRegistration = hasRegistration + self.txCount = txCount + self.orderIndex = orderIndex + self.typeIndex = typeIndex + self.createdAt = Date() + self.lastUpdated = Date() + } + + var proTxHashHex: String { + proTxHash.reversed().map { String(format: "%02x", $0) }.joined() + } + + var proTxHashShort: String { + let hex = proTxHashHex + guard hex.count >= 12 else { return hex } + return "\(String(hex.prefix(6)))…\(String(hex.suffix(6)))" + } + + var ownerKeyHashHex: String? { + ownerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var votingKeyHashHex: String? { + votingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + static func providerAccountTypeName(_ tag: UInt8) -> String { + switch tag { + case 8: return "ProviderVotingKeys" + case 9: return "ProviderOwnerKeys" + case 10: return "ProviderOperatorKeys" + case 11: return "ProviderPlatformKeys" + default: return "Unknown(\(tag))" + } + } + + static func keyOwnershipLabel( + inWallet: Bool, + accountType: UInt8, + index: UInt32 + ) -> String { + inWallet + ? "\(providerAccountTypeName(accountType)) #\(index)" + : "not in this wallet" + } + + var operatorPublicKeyHex: String? { + operatorPublicKey.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var platformNodeIdHex: String? { + platformNodeId.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var collateralDisplay: String? { + guard let txid = collateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(collateralVout)" + } + + var displayNumber: Int { + Int(typeIndex) + } + + var typeName: String { + isEvonode ? "Evonode" : "Masternode" + } + + var displayTitle: String { + "\(typeName) \(displayNumber)" + } + + var status: MasternodeStatus { + MasternodeStatus(rawValue: statusRaw) ?? .unknown + } + + var statusName: String { + status.displayName + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPendingInput.swift new file mode 100644 index 00000000000..5956862b15c --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPendingInput.swift @@ -0,0 +1,46 @@ +import Foundation +import SwiftData + +// `PersistentPendingInput` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentPendingInput { + #Index([\.outpoint], [\.walletId], [\.walletId, \.isSweptTombstone]) + var outpoint: Data + + var inputIndex: UInt32 + + var spendingTxid: Data + + var spendingTransaction: PersistentTransaction? + + var walletId: Data + + var createdAt: Date + + var isSweptTombstone: Bool = false + + var winnerMinedHeight: UInt32? + + init( + outpoint: Data, + inputIndex: UInt32, + spendingTxid: Data, + spendingTransaction: PersistentTransaction?, + walletId: Data + ) { + self.outpoint = outpoint + self.inputIndex = inputIndex + self.spendingTxid = spendingTxid + self.spendingTransaction = spendingTransaction + self.walletId = walletId + self.createdAt = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPlatformAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPlatformAddress.swift new file mode 100644 index 00000000000..e597b020a63 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPlatformAddress.swift @@ -0,0 +1,78 @@ +import Foundation +import SwiftData + +// `PersistentPlatformAddress` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentPlatformAddress { + #Index([\.walletId]) + + @Attribute(.unique) var address: String + var addressType: UInt8 + @Attribute(.unique) var addressHash: Data + var publicKey: Data + var accountIndex: UInt32 + var addressIndex: UInt32 + var derivationPath: String + var isUsed: Bool + var balance: UInt64 + var nonce: UInt32 + var firstSeenHeight: UInt32 + var lastSeenHeight: UInt64 + var walletId: Data + var createdAt: Date + var lastUpdated: Date + + var account: PersistentAccount? + + init( + address: String, + addressType: UInt8, + addressHash: Data, + publicKey: Data = Data(), + accountIndex: UInt32, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0, + nonce: UInt32 = 0, + walletId: Data + ) { + self.address = address + self.addressType = addressType + self.addressHash = addressHash + self.publicKey = publicKey + self.accountIndex = accountIndex + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.balance = balance + self.nonce = nonce + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.walletId = walletId + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentPlatformAddress { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } + + static var nonZeroBalancesPredicate: Predicate { + #Predicate { entry in + entry.balance > 0 + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPlatformAddressesSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPlatformAddressesSyncState.swift new file mode 100644 index 00000000000..67e862558f3 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPlatformAddressesSyncState.swift @@ -0,0 +1,41 @@ +import Foundation +import SwiftData + +// `PersistentPlatformAddressesSyncState` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentPlatformAddressesSyncState { + @Attribute(.unique) var walletId: Data + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + var syncHeight: UInt64 + var syncTimestamp: UInt64 + var lastKnownRecentBlock: UInt64 + var lastUpdated: Date + + init( + walletId: Data, + network: Network, + syncHeight: UInt64, + syncTimestamp: UInt64, + lastKnownRecentBlock: UInt64 + ) { + self.walletId = walletId + self.networkRaw = network.rawValue + self.syncHeight = syncHeight + self.syncTimestamp = syncTimestamp + self.lastKnownRecentBlock = lastKnownRecentBlock + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentProperty.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentProperty.swift new file mode 100644 index 00000000000..c7cd0f645d9 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentProperty.swift @@ -0,0 +1,55 @@ +import Foundation +import SwiftData + +// `PersistentProperty` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentProperty { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + var type: String + var format: String? + var contentMediaType: String? + var byteArray: Bool + var minItems: Int? + var maxItems: Int? + var pattern: String? + var minLength: Int? + var maxLength: Int? + var minValue: Int? + var maxValue: Int? + var fieldDescription: String? + + var transient: Bool + var isRequired: Bool + + var createdAt: Date + + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, type: String) { + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.type = type + self.byteArray = false + self.transient = false + self.isRequired = false + self.createdAt = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPublicKey.swift new file mode 100644 index 00000000000..69c9261a40f --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentPublicKey.swift @@ -0,0 +1,218 @@ +import Foundation +import SwiftData + +// `PersistentPublicKey` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentPublicKey { + var keyId: Int32 + var purpose: String + var securityLevel: String + var keyType: String + var readOnly: Bool + var disabledAt: Int64? + + var totalBudget: Int64? + + var expiresAt: Int64? + + var publicKeyData: Data + + var contractBoundsData: Data? + + var contractBoundsDocumentTypeName: String? + + var contractBoundsKind: Int? + + var privateKeyKeychainIdentifier: String? + + var walletId: Data? + + var identityDerivationPath: String? + + var identityId: String + var createdAt: Date + var lastAccessed: Date? + + @Relationship(inverse: \PersistentIdentity.publicKeys) + var identity: PersistentIdentity? + + init( + keyId: Int32, + purpose: KeyPurpose, + securityLevel: SecurityLevel, + keyType: KeyType, + publicKeyData: Data, + readOnly: Bool = false, + disabledAt: Int64? = nil, + contractBounds: [Data]? = nil, + contractBoundsDocumentTypeName: String? = nil, + totalBudget: Int64? = nil, + expiresAt: Int64? = nil, + contractBoundsKind: Int? = nil, + identityId: String + ) { + self.keyId = keyId + self.purpose = String(purpose.rawValue) + self.securityLevel = String(securityLevel.rawValue) + self.keyType = String(keyType.rawValue) + self.publicKeyData = publicKeyData + self.readOnly = readOnly + self.disabledAt = disabledAt + self.totalBudget = totalBudget + self.expiresAt = expiresAt + if let contractBounds = contractBounds { + self.contractBoundsData = try? JSONSerialization.data(withJSONObject: contractBounds.map { $0.base64EncodedString() }) + } else { + self.contractBoundsData = nil + } + self.contractBoundsDocumentTypeName = contractBoundsDocumentTypeName + self.contractBoundsKind = contractBoundsKind + self.identityId = identityId + self.createdAt = Date() + } + + var contractBounds: [Data]? { + get { + guard let data = contractBoundsData, + let json = try? JSONSerialization.jsonObject(with: data), + let strings = json as? [String] else { + return nil + } + return strings.compactMap { Data(base64Encoded: $0) } + } + set { + contractBoundsDocumentTypeName = nil + contractBoundsKind = newValue == nil ? 0 : 1 + if let newValue = newValue { + contractBoundsData = try? JSONSerialization.data(withJSONObject: newValue.map { $0.base64EncodedString() }) + } else { + contractBoundsData = nil + } + } + } + + var effectiveContractBoundsKind: Int { + if let contractBoundsKind = contractBoundsKind { + return contractBoundsKind + } + guard contractBoundsData != nil else { return 0 } + if let name = contractBoundsDocumentTypeName, !name.isEmpty { return 2 } + return 1 + } + + var purposeEnum: KeyPurpose? { + guard let purposeInt = UInt8(purpose) else { return nil } + return KeyPurpose(rawValue: purposeInt) + } + + var securityLevelEnum: SecurityLevel? { + guard let levelInt = UInt8(securityLevel) else { return nil } + return SecurityLevel(rawValue: levelInt) + } + + var keyTypeEnum: KeyType? { + guard let typeInt = UInt8(keyType) else { return nil } + return KeyType(rawValue: typeInt) + } + + var isDisabled: Bool { + disabledAt != nil + } + + var totalBudgetCredits: UInt64? { + get { totalBudget.map { UInt64(bitPattern: $0) } } + set { totalBudget = newValue.map { Int64(bitPattern: $0) } } + } + + var expiresAtMillis: UInt64? { + get { expiresAt.map { UInt64(bitPattern: $0) } } + set { expiresAt = newValue.map { Int64(bitPattern: $0) } } + } + + var hasLimits: Bool { + totalBudget != nil || expiresAt != nil + } + + var hasPrivateKeyIdentifier: Bool { + privateKeyKeychainIdentifier != nil + } + } +} + +extension DashSchemaSnapshotV3.PersistentPublicKey { + func toIdentityPublicKey() -> IdentityPublicKey? { + guard let purpose = purposeEnum, + let securityLevel = securityLevelEnum, + let keyType = keyTypeEnum else { + return nil + } + + let bounds: ContractBounds? + let boundsKind = effectiveContractBoundsKind + if (1...2).contains(boundsKind), let id = contractBounds?.first, id.count == 32 { + if boundsKind == 2, + let docTypeName = contractBoundsDocumentTypeName, !docTypeName.isEmpty { + bounds = .singleContractDocumentType(id: id, documentTypeName: docTypeName) + } else { + bounds = .singleContract(id: id) + } + } else { + bounds = nil + } + + return IdentityPublicKey( + id: KeyID(keyId), + purpose: purpose, + securityLevel: securityLevel, + contractBounds: bounds, + keyType: keyType, + readOnly: readOnly, + data: publicKeyData, + disabledAt: disabledAt.map { TimestampMillis($0) }, + totalBudget: totalBudgetCredits, + expiresAt: expiresAtMillis + ) + } + + static func from(_ publicKey: IdentityPublicKey, identityId: String) -> DashSchemaSnapshotV3.PersistentPublicKey? { + let boundsIds: [Data]? + let docTypeName: String? + let kind: Int + switch publicKey.contractBounds { + case .singleContract(let id): + boundsIds = [id] + docTypeName = nil + kind = 1 + case .singleContractDocumentType(let id, let name): + boundsIds = [id] + docTypeName = name + kind = 2 + case .none: + boundsIds = nil + docTypeName = nil + kind = 0 + } + return DashSchemaSnapshotV3.PersistentPublicKey( + keyId: Int32(publicKey.id), + purpose: publicKey.purpose, + securityLevel: publicKey.securityLevel, + keyType: publicKey.keyType, + publicKeyData: publicKey.data, + readOnly: publicKey.readOnly, + disabledAt: publicKey.disabledAt.map { Int64($0) }, + contractBounds: boundsIds, + contractBoundsDocumentTypeName: docTypeName, + totalBudget: publicKey.totalBudget.map { Int64(bitPattern: $0) }, + expiresAt: publicKey.expiresAt.map { Int64(bitPattern: $0) }, + contractBoundsKind: kind, + identityId: identityId + ) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedActivity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedActivity.swift new file mode 100644 index 00000000000..42b069ed262 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedActivity.swift @@ -0,0 +1,89 @@ +import Foundation +import SwiftData + +// `PersistentShieldedActivity` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentShieldedActivity { + #Unique([\.walletId, \.accountIndex, \.entryId]) + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var entryId: Data + + var kindTag: Int + var direction: Int + var status: Int + + var amount: UInt64 + var fee: UInt64 + var hasFee: Bool + var blockHeight: UInt64 + var hasBlockHeight: Bool + var createdAtMs: UInt64 + + var minNotePosition: UInt64 = 0 + var hasMinNotePosition: Bool = false + + var identityId: Data + var counterparty: Data + var memo: Data + var noteCmxs: Data + var spentNullifiers: Data + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + entryId: Data, + kindTag: Int, + direction: Int, + status: Int, + amount: UInt64, + fee: UInt64, + hasFee: Bool, + blockHeight: UInt64, + hasBlockHeight: Bool, + createdAtMs: UInt64, + minNotePosition: UInt64 = 0, + hasMinNotePosition: Bool = false, + identityId: Data, + counterparty: Data, + memo: Data, + noteCmxs: Data, + spentNullifiers: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.entryId = entryId + self.kindTag = kindTag + self.direction = direction + self.status = status + self.amount = amount + self.fee = fee + self.hasFee = hasFee + self.blockHeight = blockHeight + self.hasBlockHeight = hasBlockHeight + self.createdAtMs = createdAtMs + self.minNotePosition = minNotePosition + self.hasMinNotePosition = hasMinNotePosition + self.identityId = identityId + self.counterparty = counterparty + self.memo = memo + self.noteCmxs = noteCmxs + self.spentNullifiers = spentNullifiers + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedNote.swift new file mode 100644 index 00000000000..c1fba995245 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedNote.swift @@ -0,0 +1,66 @@ +import Foundation +import SwiftData + +// `PersistentShieldedNote` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentShieldedNote { + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var position: UInt64 + var cmx: Data + @Attribute(.unique) var nullifier: Data + var blockHeight: UInt64 + var isSpent: Bool + var value: UInt64 + var noteData: Data + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + position: UInt64, + cmx: Data, + nullifier: Data, + blockHeight: UInt64, + isSpent: Bool, + value: UInt64, + noteData: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.position = position + self.cmx = cmx + self.nullifier = nullifier + self.blockHeight = blockHeight + self.isSpent = isSpent + self.value = value + self.noteData = noteData + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} + +extension DashSchemaSnapshotV3.PersistentShieldedNote { + static func unspentPredicate(walletId: Data) -> Predicate { + #Predicate { + $0.walletId == walletId && $0.isSpent == false + } + } + + static var unspentPredicate: Predicate { + #Predicate { $0.isSpent == false } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedOutgoingNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedOutgoingNote.swift new file mode 100644 index 00000000000..e4472338602 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedOutgoingNote.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftData + +// `PersistentShieldedOutgoingNote` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentShieldedOutgoingNote { + #Unique([\.walletId, \.accountIndex, \.cmx]) + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var cmx: Data + var recipient: Data + var value: UInt64 + var memo: Data + var blockHeight: UInt64 + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + cmx: Data, + recipient: Data, + value: UInt64, + memo: Data, + blockHeight: UInt64 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.cmx = cmx + self.recipient = recipient + self.value = value + self.memo = memo + self.blockHeight = blockHeight + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedSyncState.swift new file mode 100644 index 00000000000..4cacd31b34e --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedSyncState.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentShieldedSyncState` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentShieldedSyncState { + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + var walletId: Data + var accountIndex: UInt32 + var lastSyncedIndex: UInt64 + + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + lastSyncedIndex: UInt64 = 0 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.lastSyncedIndex = lastSyncedIndex + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedViewingKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedViewingKey.swift new file mode 100644 index 00000000000..7c5f09e07c4 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentShieldedViewingKey.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentShieldedViewingKey` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentShieldedViewingKey { + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + var walletId: Data + var accountIndex: UInt32 + var fvkBytes: Data + + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + fvkBytes: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.fvkBytes = fvkBytes + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentToken.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentToken.swift new file mode 100644 index 00000000000..0bbbac9a295 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentToken.swift @@ -0,0 +1,403 @@ +import Foundation +import SwiftData + +// `PersistentToken` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentToken { + @Attribute(.unique) var id: Data + var contractId: Data + var position: Int + var name: String + + var baseSupply: String + var maxSupply: String? + var decimals: Int + + var localizations: [String: TokenLocalization]? + + var isPaused: Bool + var allowTransferToFrozenBalance: Bool + + var keepsTransferHistory: Bool + var keepsFreezingHistory: Bool + var keepsMintingHistory: Bool + var keepsBurningHistory: Bool + var keepsDirectPricingHistory: Bool + var keepsDirectPurchaseHistory: Bool + + var conventionsChangeRules: ChangeControlRules? + var maxSupplyChangeRules: ChangeControlRules? + var manualMintingRules: ChangeControlRules? + var manualBurningRules: ChangeControlRules? + var freezeRules: ChangeControlRules? + var unfreezeRules: ChangeControlRules? + var destroyFrozenFundsRules: ChangeControlRules? + var emergencyActionRules: ChangeControlRules? + + var perpetualDistribution: TokenPerpetualDistribution? + var preProgrammedDistribution: TokenPreProgrammedDistribution? + var newTokensDestinationIdentity: Data? + var mintingAllowChoosingDestination: Bool + var distributionChangeRules: TokenDistributionChangeRules? + + var tradeMode: TokenTradeMode + var tradeModeChangeRules: ChangeControlRules? + + var mainControlGroupPosition: Int? + var mainControlGroupCanBeModified: String? + + var tokenDescription: String? + + var createdAt: Date + var lastUpdatedAt: Date + + var dataContract: PersistentDataContract? + + @Relationship(deleteRule: .cascade) + var balances: [PersistentTokenBalance]? + + @Relationship(deleteRule: .cascade) + var historyEvents: [PersistentTokenHistoryEvent]? + + init(contractId: Data, position: Int, name: String, baseSupply: String, decimals: Int = 8) { + var idData = contractId + withUnsafeBytes(of: position.bigEndian) { bytes in + idData.append(contentsOf: bytes) + } + self.id = idData + + self.contractId = contractId + self.position = position + self.name = name + self.baseSupply = baseSupply + self.decimals = decimals + + self.isPaused = false + self.allowTransferToFrozenBalance = true + self.keepsTransferHistory = true + self.keepsFreezingHistory = true + self.keepsMintingHistory = true + self.keepsBurningHistory = true + self.keepsDirectPricingHistory = true + self.keepsDirectPurchaseHistory = true + self.mintingAllowChoosingDestination = true + self.tradeMode = TokenTradeMode.notTradeable + + self.createdAt = Date() + self.lastUpdatedAt = Date() + } + } +} + +extension DashSchemaSnapshotV3.PersistentToken { + var displayName: String { + if let desc = tokenDescription, !desc.isEmpty { + return desc + } + return getSingularForm() ?? name + } + + var formattedBaseSupply: String { + Self.formatSupply(baseSupply, decimals: decimals) + } + + static func formatSupply(_ raw: String, decimals: Int) -> String { + guard !raw.isEmpty, raw.allSatisfy({ $0.isASCII && $0.isNumber }) else { + return raw + } + let normalized = String(raw.drop(while: { $0 == "0" })) + let digits = normalized.isEmpty ? "0" : normalized + let scale = max(0, decimals) + let integer: String + var fraction = "" + if scale == 0 { + integer = digits + } else if digits.count <= scale { + integer = "0" + fraction = String(repeating: "0", count: scale - digits.count) + digits + } else { + let split = digits.index(digits.endIndex, offsetBy: -scale) + integer = String(digits[.. 0 && offset.isMultiple(of: 3) { grouped.append(",") } + grouped.append(character) + } + grouped = String(grouped.reversed()) + return fraction.isEmpty ? grouped : "\(grouped).\(fraction)" + } + + var contractIdBase58: String { + contractId.toBase58String() + } + + var canManuallyMint: Bool { + manualMintingRules != nil + } + + var canManuallyBurn: Bool { + manualBurningRules != nil + } + + var canFreeze: Bool { + freezeRules != nil + } + + var canUnfreeze: Bool { + unfreezeRules != nil + } + + var canDestroyFrozenFunds: Bool { + destroyFrozenFundsRules != nil + } + + var hasEmergencyActions: Bool { + emergencyActionRules != nil + } + + var canChangeMaxSupply: Bool { + maxSupplyChangeRules != nil + } + + var canChangeConventions: Bool { + conventionsChangeRules != nil + } + + var oncePerIdentityDistribution: TokenOncePerIdentityDistribution? { + guard let contract = dataContract else { return nil } + return TokenOncePerIdentityDistributionCache.shared.distribution( + contractId: contract.id, + serializedContract: contract.serializedContract, + lastUpdated: contract.lastUpdated, + position: position + ) + } + + var hasDistribution: Bool { + perpetualDistribution != nil + || preProgrammedDistribution != nil + || oncePerIdentityDistribution != nil + } + + var canChangeTradeMode: Bool { + tradeModeChangeRules != nil + } + + var keepsAnyHistory: Bool { + keepsTransferHistory || + keepsFreezingHistory || + keepsMintingHistory || + keepsBurningHistory || + keepsDirectPricingHistory || + keepsDirectPurchaseHistory + } + + var totalSupply: String { + guard let balances = balances, !balances.isEmpty else { return baseSupply } + return Self.sumUnsignedBalances(balances.map(\.unsignedBalance)) + } + + var totalFrozenBalance: String { + guard let balances = balances else { return "0" } + return Self.sumUnsignedBalances( + balances.lazy.filter(\.frozen).map(\.unsignedBalance) + ) + } + + var activeHolders: Int { + balances?.filter { $0.unsignedBalance > 0 }.count ?? 0 + } + + private static func sumUnsignedBalances(_ values: S) -> String + where S.Element == UInt64 { + var digits: [UInt8] = [0] // little-endian decimal digits + + for value in values { + var carry = 0 + let addend = String(value).utf8.reversed().map { Int($0 - 48) } + let width = max(digits.count, addend.count) + if digits.count < width { + digits.append(contentsOf: repeatElement(0, count: width - digits.count)) + } + + for index in 0.. 0 { + digits.append(UInt8(carry % 10)) + carry /= 10 + } + } + + return String(digits.reversed().map { Character(String($0)) }) + } + + var hasMaxSupply: Bool { + maxSupply != nil + } + + var isTradeable: Bool { + tradeMode != .notTradeable + } + + var newTokensDestinationIdentityBase58: String? { + newTokensDestinationIdentity?.toBase58String() + } +} + +extension DashSchemaSnapshotV3.PersistentToken { + func setLocalization(languageCode: String, singularForm: String, pluralForm: String, description: String? = nil) { + if localizations == nil { + localizations = [:] + } + localizations?[languageCode] = DashSchemaSnapshotV3.TokenLocalization( + singularForm: singularForm, + pluralForm: pluralForm, + description: description + ) + lastUpdatedAt = Date() + } + + func getSingularForm(languageCode: String = "en") -> String? { + return localizations?[languageCode]?.singularForm ?? localizations?["en"]?.singularForm + } + + func getPluralForm(languageCode: String = "en") -> String? { + return localizations?[languageCode]?.pluralForm ?? localizations?["en"]?.pluralForm + } +} + +extension DashSchemaSnapshotV3.PersistentToken { + func getChangeControlRules(for type: ChangeControlRuleType) -> DashSchemaSnapshotV3.ChangeControlRules? { + switch type { + case .conventions: return conventionsChangeRules + case .maxSupply: return maxSupplyChangeRules + case .manualMinting: return manualMintingRules + case .manualBurning: return manualBurningRules + case .freeze: return freezeRules + case .unfreeze: return unfreezeRules + case .destroyFrozenFunds: return destroyFrozenFundsRules + case .emergencyAction: return emergencyActionRules + case .tradeMode: return tradeModeChangeRules + } + } + + func setChangeControlRules(_ rules: DashSchemaSnapshotV3.ChangeControlRules, for type: ChangeControlRuleType) { + switch type { + case .conventions: conventionsChangeRules = rules + case .maxSupply: maxSupplyChangeRules = rules + case .manualMinting: manualMintingRules = rules + case .manualBurning: manualBurningRules = rules + case .freeze: freezeRules = rules + case .unfreeze: unfreezeRules = rules + case .destroyFrozenFunds: destroyFrozenFundsRules = rules + case .emergencyAction: emergencyActionRules = rules + case .tradeMode: tradeModeChangeRules = rules + } + + lastUpdatedAt = Date() + } +} + +extension DashSchemaSnapshotV3.PersistentToken { + static func mintableTokensPredicate() -> Predicate { + #Predicate { token in + token.manualMintingRules != nil + } + } + + static func burnableTokensPredicate() -> Predicate { + #Predicate { token in + token.manualBurningRules != nil + } + } + + static func freezableTokensPredicate() -> Predicate { + #Predicate { token in + token.freezeRules != nil + } + } + + static func columnBackedDistributionTokensPredicate() -> Predicate { + #Predicate { token in + token.perpetualDistribution != nil || token.preProgrammedDistribution != nil + } + } + + @available( + *, + deprecated, + renamed: "columnBackedDistributionTokensPredicate()", + message: """ + This predicate never matched the once-per-identity kind, which is derived from the \ + contract JSON rather than stored on the token row. Fetch without it and filter on \ + `hasDistribution`, or call `columnBackedDistributionTokensPredicate()` when the two \ + column-backed kinds really are all you want. + """ + ) + static func distributionTokensPredicate() -> Predicate { + columnBackedDistributionTokensPredicate() + } + + static func pausedTokensPredicate() -> Predicate { + #Predicate { token in + token.isPaused == true + } + } + + static func tokensByContractPredicate(contractId: Data) -> Predicate { + #Predicate { token in + token.contractId == contractId + } + } + + static func tokensWithControlRulePredicate(rule: ControlRuleType) -> Predicate { + switch rule { + case .manualMinting: + return #Predicate { token in + token.manualMintingRules != nil + } + case .manualBurning: + return #Predicate { token in + token.manualBurningRules != nil + } + case .freeze: + return #Predicate { token in + token.freezeRules != nil + } + case .unfreeze: + return #Predicate { token in + token.unfreezeRules != nil + } + case .destroyFrozenFunds: + return #Predicate { token in + token.destroyFrozenFundsRules != nil + } + case .emergencyAction: + return #Predicate { token in + token.emergencyActionRules != nil + } + case .conventions: + return #Predicate { token in + token.conventionsChangeRules != nil + } + case .maxSupply: + return #Predicate { token in + token.maxSupplyChangeRules != nil + } + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTokenBalance.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTokenBalance.swift new file mode 100644 index 00000000000..cdac24c75a8 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTokenBalance.swift @@ -0,0 +1,198 @@ +import Foundation +import SwiftData + +// `PersistentTokenBalance` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentTokenBalance { + #Index([\.networkRaw]) + + var tokenId: String + var identityId: Data + var balance: Int64 + var frozen: Bool + + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + var tokenName: String? + var tokenSymbol: String? + var tokenDecimals: Int32? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + @Relationship(deleteRule: .nullify) var identity: PersistentIdentity? + @Relationship(inverse: \PersistentToken.balances) var token: PersistentToken? + + init( + tokenId: String, + identityId: Data, + balance: Int64 = 0, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.tokenId = tokenId + self.identityId = identityId + self.balance = balance + self.frozen = frozen + self.tokenName = tokenName + self.tokenSymbol = tokenSymbol + self.tokenDecimals = tokenDecimals + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + self.networkRaw = network.rawValue + } + + convenience init( + tokenId: String, + identityId: Data, + unsignedBalance: UInt64, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.init( + tokenId: tokenId, + identityId: identityId, + balance: Int64(bitPattern: unsignedBalance), + frozen: frozen, + tokenName: tokenName, + tokenSymbol: tokenSymbol, + tokenDecimals: tokenDecimals, + network: network + ) + } + + var unsignedBalance: UInt64 { + get { UInt64(bitPattern: balance) } + set { balance = Int64(bitPattern: newValue) } + } + + var formattedBalance: String { + let decimals: Int + if let tokenDecimals { + decimals = Int(tokenDecimals) + } else if let tokenDecimals = token?.decimals { + decimals = tokenDecimals + } else { + return "\(unsignedBalance)" + } + + guard decimals > 0 else { return String(unsignedBalance) } + + let digits = String(unsignedBalance) + let scale = decimals + if digits.count <= scale { + return "0." + String(repeating: "0", count: scale - digits.count) + digits + } + let split = digits.index(digits.endIndex, offsetBy: -scale) + return String(digits[.. (tokenId: String, balance: UInt64, frozen: Bool) { + return (tokenId: tokenId, balance: unsignedBalance, frozen: frozen) + } +} + +extension DashSchemaSnapshotV3.PersistentTokenBalance { + static func predicate(tokenId: String, identityId: Data) -> Predicate { + #Predicate { balance in + balance.tokenId == tokenId && balance.identityId == identityId + } + } + + static func predicate(identityId: Data) -> Predicate { + #Predicate { balance in + balance.identityId == identityId + } + } + + static func predicate(tokenId: String) -> Predicate { + #Predicate { balance in + balance.tokenId == tokenId + } + } + + static var nonZeroBalancesPredicate: Predicate { + #Predicate { balance in + balance.balance != 0 + } + } + + static var frozenBalancesPredicate: Predicate { + #Predicate { balance in + balance.frozen == true + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { balance in + balance.lastSyncedAt == nil || balance.lastSyncedAt! < date + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTokenHistoryEvent.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTokenHistoryEvent.swift new file mode 100644 index 00000000000..23579c68564 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTokenHistoryEvent.swift @@ -0,0 +1,112 @@ +import Foundation +import SwiftData + +// `PersistentTokenHistoryEvent` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentTokenHistoryEvent { + @Attribute(.unique) var id: UUID + + var eventType: String + var transactionId: Data? + var blockHeight: Int64? + var coreBlockHeight: Int64? + + var fromIdentity: Data? + var toIdentity: Data? + var performedByIdentity: Data + + var amount: String? + var balanceBefore: String? + var balanceAfter: String? + + var additionalDataJSON: Data? + + var eventDescription: String? + + var createdAt: Date + var eventTimestamp: Date + + @Relationship(inverse: \PersistentToken.historyEvents) + var token: PersistentToken? + + init( + eventType: TokenEventType, + performedByIdentity: Data, + eventTimestamp: Date = Date() + ) { + self.id = UUID() + self.eventType = eventType.rawValue + self.performedByIdentity = performedByIdentity + self.eventTimestamp = eventTimestamp + self.createdAt = Date() + } + + var eventTypeEnum: TokenEventType { + TokenEventType(rawValue: eventType) ?? .unknown + } + + var fromIdentityBase58: String? { + fromIdentity?.toBase58String() + } + + var toIdentityBase58: String? { + toIdentity?.toBase58String() + } + + var performedByIdentityBase58: String { + performedByIdentity.toBase58String() + } + + var displayTitle: String { + switch eventTypeEnum { + case .mint: + return "Minted \(formattedAmount)" + case .burn: + return "Burned \(formattedAmount)" + case .transfer: + return "Transfer \(formattedAmount)" + case .freeze: + return "Frozen \(formattedAmount)" + case .unfreeze: + return "Unfrozen \(formattedAmount)" + case .destroyFrozenFunds: + return "Destroyed Frozen Funds \(formattedAmount)" + case .configUpdate: + return "Configuration Updated" + case .emergencyAction: + return "Emergency Action" + case .perpetualDistribution: + return "Perpetual Distribution \(formattedAmount)" + case .preProgrammedRelease: + return "Pre-programmed Release \(formattedAmount)" + case .directPricing: + return "Direct Pricing Updated" + case .directPurchase: + return "Direct Purchase \(formattedAmount)" + case .unknown: + return "Unknown Event" + } + } + + private var formattedAmount: String { + guard let amount = amount else { return "" } + return amount + } + + func setAdditionalData(_ data: [String: Any]) { + additionalDataJSON = try? JSONSerialization.data(withJSONObject: data) + } + + func getAdditionalData() -> [String: Any]? { + guard let data = additionalDataJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTrackedMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTrackedMasternode.swift new file mode 100644 index 00000000000..ef91265c808 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTrackedMasternode.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftData + +// `PersistentTrackedMasternode` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentTrackedMasternode { + #Unique([\.networkRaw, \.proTxHash]) + #Index([\.networkRaw]) + + var networkRaw: UInt32 + var proTxHash: Data + var label: String? + var addedAt: UInt64 + var snapshotJSON: String + + var network: Network? { + get { Network(rawValue: networkRaw) } + set { networkRaw = newValue?.rawValue ?? networkRaw } + } + + init( + networkRaw: UInt32, + proTxHash: Data, + label: String?, + addedAt: UInt64, + snapshotJSON: String + ) { + self.networkRaw = networkRaw + self.proTxHash = proTxHash + self.label = label + self.addedAt = addedAt + self.snapshotJSON = snapshotJSON + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTransaction.swift new file mode 100644 index 00000000000..4ab87cd00ab --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTransaction.swift @@ -0,0 +1,167 @@ +import Foundation +import SwiftData + +// `PersistentTransaction` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentTransaction { + #Index([\.firstSeen]) + + @Attribute(.unique) var txid: Data + var transactionData: Data + var context: UInt32 + var blockHeight: UInt32 + var blockHash: Data? + var blockTimestamp: UInt32 + var blockPosition: UInt32 = 0 + var hasBlockPosition: Bool = false + var direction: UInt32 + var transactionType: String + var transactionTypeKind: UInt8 = 0xFF + var netAmount: Int64 + var fee: UInt64? + var label: String + var firstSeen: UInt64 + + var providerServiceAddress: String? = nil + var providerProTxHash: Data? = nil + var providerCollateralTxid: Data? = nil + var providerCollateralVout: UInt32 = 0 + var providerOwnerKeyHash: Data? = nil + var providerVotingKeyHash: Data? = nil + + var createdAt: Date + var lastUpdated: Date + + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.transaction) + var outputs: [PersistentTxo] = [] + + @Relationship(inverse: \PersistentTxo.spendingTransaction) + var inputs: [PersistentTxo] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentPendingInput.spendingTransaction) + var pendingInputs: [PersistentPendingInput] = [] + + @Relationship(inverse: \PersistentAccount.involvedTransactions) + var involvedAccounts: [PersistentAccount] = [] + + init( + txid: Data, + transactionData: Data, + context: UInt32 = 0, + blockHeight: UInt32 = 0, + direction: UInt32 = 0, + transactionType: String = "Standard", + netAmount: Int64 = 0, + firstSeen: UInt64 = 0 + ) { + self.txid = txid + self.transactionData = transactionData + self.context = context + self.blockHeight = blockHeight + self.blockTimestamp = 0 + self.direction = direction + self.transactionType = transactionType + self.netAmount = netAmount + self.firstSeen = firstSeen + self.label = "" + self.createdAt = Date() + self.lastUpdated = Date() + } + + var txidHex: String { + txid.reversed().map { String(format: "%02x", $0) }.joined() + } + + var contextName: String { + switch context { + case 0: return "Mempool" + case 1: return "InstantSend" + case 2: return "In Block" + case 3: return "Chain Locked" + default: return "Unknown" + } + } + + var directionName: String { + switch direction { + case 0: return "Incoming" + case 1: return "Outgoing" + case 2: return "Internal" + case 3: return "CoinJoin" + default: return "Unknown" + } + } + + var typedKind: TransactionTypeKind? { + TransactionTypeKind(rawValue: transactionTypeKind) + } + + var isAssetLock: Bool { + typedKind == .assetLock + } + + var isAssetUnlock: Bool { + typedKind == .assetUnlock + } + + var isProviderRegistration: Bool { + typedKind == .providerRegistration + } + + var isProviderUpdateService: Bool { + typedKind == .providerUpdateService + } + + var providerProTxHashHex: String? { + providerProTxHash.map { $0.reversed().map { String(format: "%02x", $0) }.joined() } + } + + var providerCollateralDisplay: String? { + guard let txid = providerCollateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(providerCollateralVout)" + } + + var providerOwnerKeyHashHex: String? { + providerOwnerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var providerVotingKeyHashHex: String? { + providerVotingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var isProviderSpecial: Bool { + providerSpecialName != nil + } + + var providerSpecialName: String? { + switch typedKind { + case .providerRegistration: return "Provider Registration" + case .providerUpdateRegistrar: return "Provider Update Registrar" + case .providerUpdateService: return "Provider Update Service" + case .providerUpdateRevocation: return "Provider Update Revocation" + default: return nil + } + } + + var displayDirection: String { + if isAssetLock { return "Asset Lock" } + if isAssetUnlock { return "Asset Unlock" } + if let name = providerSpecialName { return name } + return directionName + } + + var formattedAmount: String { + let dash = Double(abs(netAmount)) / 100_000_000.0 + let sign = netAmount >= 0 ? "+" : "-" + return String(format: "%@%.8f DASH", sign, dash) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTxo.swift new file mode 100644 index 00000000000..24afbd99db4 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentTxo.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftData + +// `PersistentTxo` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentTxo { + #Index([\.walletId]) + + @Attribute(.unique) var outpoint: Data + var vout: UInt32 + var amount: UInt64 + var address: String + var scriptPubKey: Data + var height: UInt32 + var isCoinbase: Bool + var isConfirmed: Bool + var isInstantLocked: Bool + var isLocked: Bool + var isSpent: Bool + var createdAt: Date + var lastUpdated: Date + + var walletId: Data = Data() + + var transaction: PersistentTransaction? + + var spendingTransaction: PersistentTransaction? + + var supersededByTxid: Data? + + var spendingInputIndex: UInt32? = nil + + var account: PersistentAccount? + + var coreAddress: PersistentCoreAddress? + + init( + transaction: PersistentTransaction, + vout: UInt32, + amount: UInt64, + address: String, + scriptPubKey: Data = Data(), + height: UInt32 = 0 + ) { + self.outpoint = Self.makeOutpoint(txid: transaction.txid, vout: vout) + self.vout = vout + self.amount = amount + self.address = address + self.scriptPubKey = scriptPubKey + self.height = height + self.isCoinbase = false + self.isConfirmed = false + self.isInstantLocked = false + self.isLocked = false + self.isSpent = false + self.createdAt = Date() + self.lastUpdated = Date() + self.transaction = transaction + } + + static func makeOutpoint(txid: Data, vout: UInt32) -> Data { + var data = Data(capacity: 36) + data.append(txid) + var v = vout.littleEndian + withUnsafeBytes(of: &v) { data.append(contentsOf: $0) } + return data + } + + var txid: Data { + if let transaction { + return transaction.txid + } + return outpoint.count >= 32 ? Data(outpoint.prefix(32)) : Data() + } + + var txidHex: String { + let rawTxid = txid + guard rawTxid.count == 32 else { return "" } + return rawTxid.reversed().map { String(format: "%02x", $0) }.joined() + } + + var outpointHex: String { + let hex = txidHex + return hex.isEmpty ? "" : "\(hex):\(vout)" + } + + var formattedAmount: String { + let dash = Double(amount) / 100_000_000.0 + return String(format: "%.8f DASH", dash) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentWallet.swift new file mode 100644 index 00000000000..f0358647fa4 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentWallet.swift @@ -0,0 +1,95 @@ +import Foundation +import SwiftData + +// `PersistentWallet` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentWallet { + #Index([\.networkRaw], [\.walletGroupId]) + #Unique([\.walletId]) + + var walletId: Data + var walletGroupId: Data = Data() + var networkRaw: UInt32? + + var network: Network? { + get { + guard let raw = networkRaw else { return nil } + return Network(rawValue: raw) ?? .testnet + } + set { networkRaw = newValue?.rawValue } + } + var name: String? + var walletDescription: String? + var birthHeight: UInt32 + var syncedHeight: UInt32 + var lastSynced: UInt64 + var lastAppliedChainLockBytes: Data? + var lastAppliedChainLockHeight: UInt32? + var isImported: Bool = false + var seedBindingVerifiedMarker: String? + var createdAt: Date + var lastUpdated: Date + + @Relationship(deleteRule: .cascade, inverse: \PersistentAccount.wallet) + var accounts: [PersistentAccount] + + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.wallet) + var identities: [PersistentIdentity] + + init( + walletId: Data, + walletGroupId: Data = Data(), + network: Network? = nil, + name: String? = nil, + walletDescription: String? = nil, + birthHeight: UInt32 = 0, + syncedHeight: UInt32 = 0, + isImported: Bool = false + ) { + self.walletId = walletId + self.walletGroupId = walletGroupId + self.networkRaw = network?.rawValue + self.name = name + self.walletDescription = walletDescription + self.birthHeight = birthHeight + self.syncedHeight = syncedHeight + self.lastSynced = 0 + self.isImported = isImported + self.createdAt = Date() + self.lastUpdated = Date() + self.accounts = [] + self.identities = [] + } + } +} + +extension DashSchemaSnapshotV3.PersistentWallet { + var label: String { + if let name = name, !name.isEmpty { + return name + } + let hex = walletId.prefix(4) + .map { String(format: "%02x", $0) } + .joined() + return hex.isEmpty ? "Wallet" : "Wallet \(hex)…" + } +} + +extension DashSchemaSnapshotV3.PersistentWallet { + static func predicate(walletId: Data) -> Predicate { + #Predicate { $0.walletId == walletId } + } + + static func predicate( + walletGroupId: Data + ) -> Predicate { + #Predicate { $0.walletGroupId == walletGroupId } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentWalletManagerMetadata.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentWalletManagerMetadata.swift new file mode 100644 index 00000000000..081406062ad --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+PersistentWalletManagerMetadata.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentWalletManagerMetadata` exactly as schema DashSchemaSnapshotV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit ecb9d20b80. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV3 { + @Model + final class PersistentWalletManagerMetadata { + @Attribute(.unique) var networkRaw: UInt32 + var combinedSyncHeight: UInt32 + var combinedSyncBlockHash: Data? + var walletCount: Int + var createdAt: Date + var lastUpdated: Date + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + init(network: Network) { + self.networkRaw = network.rawValue + self.combinedSyncHeight = 0 + self.walletCount = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+Schema.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+Schema.swift new file mode 100644 index 00000000000..a50b0b62e7a --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+Schema.swift @@ -0,0 +1,47 @@ +import Foundation +import SwiftData + +// Generated release snapshot; never add alongside its live version in the migration plan. +enum DashSchemaSnapshotV3: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(3, 0, 0) } + static var models: [any PersistentModel.Type] { + [ + PersistentIdentity.self, + PersistentDPNSName.self, + PersistentDashpayProfile.self, + PersistentDashpayContactProfile.self, + PersistentDashpayContactRequest.self, + PersistentDashpayPayment.self, + PersistentDashpayIgnoredSender.self, + PersistentDocument.self, + PersistentDataContract.self, + PersistentPublicKey.self, + PersistentTokenBalance.self, + PersistentKeyword.self, + PersistentToken.self, + PersistentDocumentType.self, + PersistentIndex.self, + PersistentProperty.self, + PersistentTokenHistoryEvent.self, + PersistentPlatformAddress.self, + PersistentPlatformAddressesSyncState.self, + PersistentWallet.self, + PersistentAccount.self, + PersistentCoreAddress.self, + PersistentTransaction.self, + PersistentTxo.self, + PersistentPendingInput.self, + PersistentWalletManagerMetadata.self, + PersistentShieldedNote.self, + PersistentShieldedOutgoingNote.self, + PersistentShieldedSyncState.self, + PersistentShieldedActivity.self, + PersistentShieldedViewingKey.self, + PersistentAssetLock.self, + PersistentInvitation.self, + PersistentMasternode.self, + PersistentTrackedMasternode.self, + PersistentIdentityBalanceMetadata.self + ] + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+TokenTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+TokenTypes.swift new file mode 100644 index 00000000000..70f170ff45e --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV3+TokenTypes.swift @@ -0,0 +1,165 @@ +import Foundation +import SwiftData + +// Inline value types exactly as schema DashSchemaSnapshotV3 stored them, generated +// by scripts/freeze_schema_models.py from TokenTypes.swift +// at commit ecb9d20b80. SwiftData expands a stored Codable struct into composite +// attributes of the owning entity, so these shapes are inputs to that +// version's checksum just like the model's own properties. Do not edit. +extension DashSchemaSnapshotV3 { + struct ChangeControlRules: Codable, Equatable, Sendable { + var authorizedToMakeChange: String + var adminActionTakers: String + var changingAuthorizedActionTakersToNoOneAllowed: Bool + var changingAdminActionTakersToNoOneAllowed: Bool + var selfChangingAdminActionTakersAllowed: Bool + + init( + authorizedToMakeChange: String = AuthorizedActionTakers.noOne.rawValue, + adminActionTakers: String = AuthorizedActionTakers.noOne.rawValue, + changingAuthorizedActionTakersToNoOneAllowed: Bool = false, + changingAdminActionTakersToNoOneAllowed: Bool = false, + selfChangingAdminActionTakersAllowed: Bool = false + ) { + self.authorizedToMakeChange = authorizedToMakeChange + self.adminActionTakers = adminActionTakers + self.changingAuthorizedActionTakersToNoOneAllowed = changingAuthorizedActionTakersToNoOneAllowed + self.changingAdminActionTakersToNoOneAllowed = changingAdminActionTakersToNoOneAllowed + self.selfChangingAdminActionTakersAllowed = selfChangingAdminActionTakersAllowed + } + + static func mostRestrictive() -> ChangeControlRules { + return ChangeControlRules() + } + + static func contractOwnerControlled() -> ChangeControlRules { + return ChangeControlRules( + authorizedToMakeChange: AuthorizedActionTakers.contractOwner.rawValue, + adminActionTakers: AuthorizedActionTakers.noOne.rawValue, + selfChangingAdminActionTakersAllowed: true + ) + } + } + + enum AuthorizedActionTakers: String, CaseIterable, Codable, Sendable { + case noOne = "NoOne" + case contractOwner = "ContractOwner" + case mainGroup = "MainGroup" + + enum WireType { + static let noOne = "noOne" + static let contractOwner = "contractOwner" + static let identity = "identity" + static let mainGroup = "mainGroup" + static let group = "group" + } + + static func identity(_ id: Data) -> String { + return "Identity:\(id.toBase58String())" + } + + static func identity(_ base58Id: String) -> String { + return "Identity:\(base58Id)" + } + + static func group(_ position: Int) -> String { + return "Group:\(position)" + } + } + + struct TokenPerpetualDistribution: Codable, Equatable, Sendable { + var distributionType: String + var distributionRecipient: String + var enabled: Bool + var lastDistributionTime: Date? + var nextDistributionTime: Date? + + init(distributionRecipient: String = "AllEqualShare", enabled: Bool = true) { + self.distributionType = "{}" + self.distributionRecipient = distributionRecipient + self.enabled = enabled + } + } + + struct TokenPreProgrammedDistribution: Codable, Equatable, Sendable { + var distributionSchedule: [DistributionEvent] + var currentEventIndex: Int + var totalDistributed: String + var remainingToDistribute: String + var isActive: Bool + var isPaused: Bool + var isCompleted: Bool + + init() { + self.distributionSchedule = [] + self.currentEventIndex = 0 + self.totalDistributed = "0" + self.remainingToDistribute = "0" + self.isActive = true + self.isPaused = false + self.isCompleted = false + } + } + + struct DistributionEvent: Codable, Equatable, Sendable { + var id: UUID + var triggerType: String + var triggerTime: Date? + var triggerBlock: Int64? + var triggerCondition: String? + var amount: String + var recipient: String + var description: String? + + init(triggerTime: Date, amount: String, recipient: String = "AllHolders", description: String? = nil) { + self.id = UUID() + self.triggerType = "Time" + self.triggerTime = triggerTime + self.amount = amount + self.recipient = recipient + self.description = description + } + } + + struct TokenDistributionChangeRules: Codable, Equatable, Sendable { + var perpetualDistributionRules: ChangeControlRules? + var newTokensDestinationIdentityRules: ChangeControlRules? + var mintingAllowChoosingDestinationRules: ChangeControlRules? + var changeDirectPurchasePricingRules: ChangeControlRules? + + init( + perpetualDistributionRules: ChangeControlRules? = nil, + newTokensDestinationIdentityRules: ChangeControlRules? = nil, + mintingAllowChoosingDestinationRules: ChangeControlRules? = nil, + changeDirectPurchasePricingRules: ChangeControlRules? = nil + ) { + self.perpetualDistributionRules = perpetualDistributionRules + self.newTokensDestinationIdentityRules = newTokensDestinationIdentityRules + self.mintingAllowChoosingDestinationRules = mintingAllowChoosingDestinationRules + self.changeDirectPurchasePricingRules = changeDirectPurchasePricingRules + } + } + + enum TokenTradeMode: String, CaseIterable, Codable, Sendable { + case notTradeable = "NotTradeable" + + var displayName: String { + switch self { + case .notTradeable: + return "Not Tradeable" + } + } + } + + struct TokenLocalization: Codable, Equatable, Sendable { + let singularForm: String + let pluralForm: String + let description: String? + + init(singularForm: String, pluralForm: String, description: String? = nil) { + self.singularForm = singularForm + self.pluralForm = pluralForm + self.description = description + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift index 192033ab627..33d616a8874 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift @@ -3,6 +3,6 @@ enum DashReleasedSchemaRegistry { static let fixtures: [DashReleasedSchemaFixture] = [ - + DashReleasedSchemaFixture(version: DashSchemaSnapshotV3.self, resourceName: "fb711a3d216f05936c5ad8f6dcee161203b8f5295c744f9fba3acafd475d72a9") ] } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/releases/fb711a3d216f05936c5ad8f6dcee161203b8f5295c744f9fba3acafd475d72a9.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/releases/fb711a3d216f05936c5ad8f6dcee161203b8f5295c744f9fba3acafd475d72a9.store new file mode 100644 index 0000000000000000000000000000000000000000..0f01dd2932d4561107dc82af5cae3b42f991d5ba GIT binary patch literal 671744 zcmeF)2_RJe{y+X%Y{M{Gl%-JC$i77)DNFV>TS<(4i?Ow+6iOi^OCpg{Xx|hqN>mD^ zQYevjDpaEU#;3Y1_tW=t@BiNWzn_o$d^f$PbI$vm^L{~`Y`CCoAfzA>}PQfF3$(#)zrgjwYXGpp?rnbkI4X0?O!wKI8r5zRLLXN=8-C8q)HvB(nhK+BUSoHwRNP*82L>U@&9AR+!X}^5P$## zAOHafKmY;|fWTi^fScKOBCVdle4s!80uX=z1Rwwb2tWV=5P$##An+FvkSC5OP$(SC zLL@RP3OoB`0+l*EtXHGbH&xZ=)z{}xhFhSATZqR<{!gOy6PT~d@BfR0h;Bjv0uX=z z1Rwwb2tWV=5P$##An=a}u#m_UB8f~SQz)Fw_9R;0NdDjVkL-#z5P$##AOHafKmY;| zfB*y_009X6H3ZmLC`2NO#KJ}$pl2xCo2#JC+S!z{nOG+&Lq{mb(+w zD4+BH%LLlxKffM&2muH{00Izz00bZa0SG_<0uX?JDsjxnuNFQR=KKC-$5F|j-|g>z z^2_u2C?*IR31{NKG00bZa0SG_<0uX=z1Rwwb2>izckpKV3XmMZ&KmY;| zfB*y_009U<00Izzz@J6{`Tw70D9{lIKmY;|fB*y_009U<00Izzz<*5O^ZWlB3AD!l z*eP&e2tWV=5P$##AOHafKmY;|fB*#k?E=$@9L&!LAWT*g8RH%l$Ov(BXMBBj4Gy6* zKNBFF?(w&O;Qs%=eE@Jk2tWV=5P$##AOHafKmY;|fWV(l;Pd_ebp+bFKV8?*AqYSK z0uX=z1Rwwb2tWV=5P-m_5?Ds0GVj=PFmKzl+nSn=BYpn-{;N7;{(++W2Sw=*3hw`p zs!IvMKmY;|fB*y_009U<00Izz00e$l0Qvv#vS1|yAOHafKmY;|fB*y_009U00bZa z0SG_<0uX=z1Rwx`e_8&v;gw|f0_(CKmY;|fB*y_009U<00Izz00jPW0?7aWazlm= zLjVF0fB*y_009U<00Izz00jPNfsy>5NV`H{K2RV40SG_<0uX=z1Rwwb2tWV=5cu;8 zs1PXxB85T`8e=Nz=@ZEa3!yJx$^Hk0o3@@nqD?2z3YirO1Rwwb2tWV=5P$##AOHaf zKmY;+Cz22|!6tHW5IG1O%tB&k=O#@44TjfrN+5|atXHGbH&r#Q;p?*v!!6LmEyRON zQ2v9$Jj$eB5jzq8uk(6*JMZo<=iUG9ysCdtKHvXeNuaI#)!DHg0uX=z1Rwwb2tWV= z5P$##AOL}XNnj0u$j|(Z0*U!61r81hfrCOJQrOvLZK(v)spKafq|fR6LGstAw;_|L@;4I5-Rh zAOHafKmY;|fB*y_009U<;7=^T$?O}EHbh`PP#^#S2tWV=5P$##AOHafKmY;|7=;34 zGKDgd|C4Ba1ll08LV*ATAOHafKmY;|fB*y_009U00Izz00bZa0SG_<0uX=z1pc=L#K@+U zKl=Rt&#(VKNuZtl-?l>Z5P$##AOHafKmY;|fB*y_0D(WHfC0;zF%-8Y|JoldMZEw2 zPdU2i6a*ju0SG_<0uX=z1Rwwb2teT9Ch+z1|B1Ah1m*(;0uX=z1Rwwb2tWV=5P$## zAOL~UATX9JONlb@iDZO@(Dgh#Lg=BPBlrIayrUsF!~g*ZKmY;|fB*y_009U<00Izz zz^??5|Nlw`>mdLE2tWV=5P$##AOHafKmY=xM*#W%=ow2y0s#m>00Izz00bZa0SG_< z0ucC>0P_D|$zVMMAOHafKmY;|fB*y_009U{300Izz00bZa0SG_<0uUHI0?7YI&sZW72tWV= z5P$##AOHafKmY;|fWWT=kpKTm2J0aJ0SG_<0uX=z1Rwwb2tWV=qelSw|L7S@L;?W_ zKmY;|fB*y_009U<00I#Bl>qYpU&&xS1Rwwb2tWV=5P$##AOHafKw$I;ApajdV~I#0 z009U<00Izz00bZa0SG_<0>2VK{{Jf(tcL&uAOHafKmY;|fB*y_009V$9)XekpGX@b zFdrxofB*y_009U<00Izz00bZa0SJsj0Wz6DLH<7q2Noeg00Izz00bZa0SG_<0uX=z z1ilj>lPSpmzw^L9ApijgKmY;|fB*y_009U<00N^?;9LIRLZG#b#t0D;1Rwwb2tWV= z5P$##AOHafKw$I>h>=YxYJX7h`G2GTIzWUFfB*y_009U<00Izz00bZa0p$O<1|R?d z2tWV=5P$##AOHafKmY=xUjX_4=pSQ52muH{00Izz00bZa0SG_<0uVs{k81z|5P$## zAOHafKmY;|fB*y_F!}|M|BwDLMuZT600bZa0SG_<0uX=z1Rwx`k^G-X8zL|tC=h@E z1Rwwb2tWV=5P$##AOHafj6wl2nLzoR|34$po{ho)5fTI-009U<00Izz00bZa0SG|g ze@=jptVD4S3S@-1xiegYL+GLOkZ`&OUjP3;cOf8P2tWV=5P$##AOHafKmY;|_#YJb zeE)xtKpXrY3?3mv00Izz00bZa0SG_<0uX=z1V+CA3zy5NLNsf531KmY;|fB*y_009U<00IygWdhf7A^r0)hYpAOHafKmY;|fB*y_009X6oxn)`PoxbIm=6>PKmY;|fB*y_ z009U<00Izz00c&%0GUjo;Qs$899V<|0SG_<0uX=z1Rwwb2tWV=5co*|`TtKKScCus zAOHafKmY;|fB*y_009V$LILFeqi|3W5(FRs0SG_<0uX=z1Rwwb2teQ`0p$NbfnX5= z5P$##AOHafKmY;|fB*y_FbV~b|Bu2!MMw~U00bZa0SG_<0uX=z1Rwx`p9GNq{{(_X z2tWV=5P$##AOHafKmY;|fWRmeK>j}p2NfYf00Izz00bZa0SG_<0uX=z1bz}g{{IsQ z79juu2tWV=5P$##AOHafKmY=xPyqS=C>&IT1OW&@00Izz00bZa0SG_<0ucB~0Qvt< zAXtO|1Rwwb2tWV=5P$##AOHafj6wnA|D$kF5fTI-009U<00Izz00bZa0SG|gCjsRD zKY?Ho0uX=z1Rwwb2tWV=5P$##ATSCAkpGXuK}ARqfB*y_009U<00Izz00bZafu97B z|NjJnMF>Cu0uX=z1Rwwb2tWV=5P-la6hQtz3I`P-K>z{}fB*y_009U<00Izz00e## zK>q&|2o@m#0SG_<0uX=z1Rwwb2tWV=qfh|(|0o<(gaiQyKmY;|fB*y_009U<00I#B zNdWo(Pas%?00bZa0SG_<0uX=z1Rwwb2#i93k^G-X8zL|tC=h@E1Rwwb2tWV=5P$## zAOHafj6wl2nL_!T|KB6f?v26#5fTI-009U<00Izz00bZa0SG`~^b3fyP)sT8e^5s9 ze-iB-f%c9zIQmHtAp{@*0SG_<0uX=z1Rwwb2teR}N`Oq}ps%9X|Nj&(jtc<@KmY;| zfB*y_009U<00I#Ba|nF9|KCEOwfs5ygkC@Z0uX=z1Rwwb2tWV=5P$##An?x#jC@+4 z(jS!1_y6w@Xm|d(W1}?$AOHafKmY;|fB*y_009U<00RGg0V%TWAO24PBfZ4y|Nr}A zg&sfv0uX=z1Rwwb2tWV=5P$##{*waVUjL8J|Nl=qLh{`S1g(Kgm#T~h_;F5Nzs&?P~afiTI-qH=;_-U zTbSF}S{s`il7yzP5;Qo7mh_NNpHK!pkYO9*78vU0&hQBeB#Dc&5==NazHMCK=I>8u zn7IYIdC^16=nOXxH-;OZ7%M@JgYt8$u~S(IQs1w(jSOOnun`EnA4#kPF%HUiycN?cnVkOM`eyxqS58dB`?qTmkkMIffGNDI_3jK86uT5;C0^MyG zZVbAZAS=Q6_YPta#_$Sa9>qL}K^GGEsdHZsGqO>HpOv8Zdq>b?4n?>RBZ_|_D?#OZ z9?P&L{yy%^?oFJ)N>KWKSIdx~V0s86Y8oFa!S06*{%#D(VA5DeEjr9GyF}9=!dNAWM4h#-s2#jSVsC~clw;`J_X5_m2Za-#!BZ5La1bBbi z#W;`|q}#|Fc}AW&?6;zSP2&2pTpRLIo?c{FN6E_Vw}i5g7Mu6zqXr5{Yh!A zrMZo{TL4{%>!)Fv8{{4qF!GfU6-=MV`SV_1HVRRG-i!H_pdS>-U|zJ0$sDYNS>Jbd zu3M;guv-*!U_5>N>66)ivDqzZyn*eQADaG|~lT z53K1+!|0(5ahCtH)tCMVGQT-!d~c@DJkOVFgrE7P`om?X$GmPBra|t06PVv&ei&Lk zclV&MKn6GSYnjNhh`@ZHKmY;|_%{kEy~QZf^6p8H+pJ z%q`L zEALoW=Iknr5oGV?9u^j0N)M;|yYhSG(@(*$ReE_1r-1kc~V_4tjQ|(mb73CG}ZLCav7<4OTS6&A*%VnWruw$NnoIn>6+%r^{Vco{qwMC`sV=oP(5I{Wswq182db+pXwEe^ z2w*HR)AN&E5*eZyWEkk7WN06yVG=b*F=Ey%SKjGMqx8buBj+ipcsMzFF4M5HVJuM6 zHw#!gC&Vt&+EU5a&UW4s4I{-U<}`HHeO~dgh8^9&4)jOUw}nweWzT3OrJ+SxB~aAXd} z-$tDAyFV^ndB=Zi@of_0%1isU`nNf!EAN*f1F?ZAAx@PiO;;GzU^%JW0zcc$9=c$>m<{aSMb}~uM^j= zb5rJ(|83*f3D*xRzs{+CSow9H>dHIm+wNbF{cZaF1MJt$BXi~-M&Zlsl5%bIS+i|}3=KE>>?+(o8nVl=|cYXM}@89O&KMdRN zW?Db&|98pH5263I^~>D&hYfSTo2ULT8eiwbKaBt1W_v$`{d*JAh2K<|iJmL(lyA#l zuZW+f>aM)J-`aeR*Me76r}0JBI%`GoLahO}xpJ}BN&WZY73C)-6eSH@U~ia4WHvcG z(|3hb;eD?MO53)!_o+l@dp(&li#wxf_Jn2QDPFhinN4EvUfIZJ|H?o2nqSG5_Sgp> zE0UMkS;#KBJlAyW1Y`RZ%qG&lXSa*dnr2&mP#M_J8Od1S`L@$uW2weL@p@Nz!I(j2 z6F*}E{wvRp%C^PtR=NM2e|;bKy#=lNS7ubcNcL|FVA;TI(q_G5mN!{z)9W+Q>2IFA zKGDJ7<5ee}_aSewqYJ0G_(^7yyQSNvoyds zDQ1)ToMDXuOQIfgricWD#|=E(qIWdrVD?!quhX7S)>&U)z-;m)srJq?_WhgWMao_; zC$SYx+U}?o5Z`R-VKw~LAlj3L18wje>~&h%M*7ysV2H9Zd;F4TrU1(oMGKK=9G`9+3G#2{LCi8)r)o1oH#<8y75NVZHKHp zxq3r(j-f5bm`zTzFV!;PbRTXz|47B>=_-!g#HXzL` zx@HbcclN(ycJScSq|#erPZPI1FN@f8u*;I!WLHxQ`$9Q)Gr=J01Bp+Z1!4~w4E-@V zTb|SF#rGO!E@U?O*x&Bd)2-!^EjH=8<~a`Py)9gKI_0uMq8AJ`9ZIer8G!uw2Qw_H z%G3pp30Yj3&63Jt(Ae_$qDs7#!M#ns&)3T^o1Cnk+|4g1!8qNpmci?7Wm4*-bdRp1 z7;VDtYZKSHYdy1x1b>uB`}3!R`(oqDIj(S8hd8S9_o~lXTaqD(_)=zU@Gn+V-@JveFAR|7je45upPHH#Xs*C>o zGZp5)@;C83w05f^v&j_2$3{l6y-U2pv^(G4$erR_bW*v)lcbt2J1t*bWq}W~iNL*w z*N;D_)-9^@nI`PaPw$VL)2GE3aG?3YV(Hx#W?9T8fnK~ig8utgvh*D-G~*px82qVM zugc%Q=gis1esehq`$n1+$VzA!pHoTgytLO)h$H0Y!QvZ!hZa|!tUhHnd$*xEvq_E5 z*`48U$;zWtx~&vug6oZo;I164LWbxme+YQn{a8bef?6>*?ge0d$t+bw)bxI zaRSSY#Eor@{pFo)EV0YwklgwuGl$hxBFZy*N=R=n%QKT%zKCY%HiC;tJihUGsKct zpSkxsuHJrdMOd3rqq5ZnW|KXQYv#5d6585&>QM8?vqK@gnk`j^;aiTJX!EqYYw;op^ynBPo%LNKT|cQaq`QR849ny(bHj)ybx0A96G~jl73^ ziTr@v$HL7b%QB0_jwPHWg=IfW14|Dp2kR78byic>0M_NKd8~(6n^@no39;$0xv{Nd z+s$^F?Ik-ey9&EKdlY*P`x*9!9IPDD93~ur94Q<}IBs)%piHLBrnpn$D8-bklpfBp zoNAm7oXa_LKTqaK#4nS2=N~8coI8HY|B3er%m>Qo6Ii>=hsY9l+&*U|d(OCvgYRCv zD0-Y)(f6EtW(@Zj-pw-ovyZNrbl-yM21^Sa7)?Ep`&2*X>p>k1C~l{K$nS(mb2++((Z z$DCVh6075pz$+MQP@p8eDbUc}igoW?yFG$UJC;hv?DQU6&efW@wSZRd)F<#k z;A70S9fzMR=o8Y4{}dl>xN1l56N|oyAA}ap9_BFOWtqM{#^M!M^ZBMZe);?A&7Me5 z#~X0c=fql0yU7_rIUH*kJ8)j*{FU?0=kJ{tbgMXEcSm-G!jSlo{LmU!|8q}gkS9Kn zebw~dX@ywehI+}FZO_s%d`;ou@F&$nQG(E6T@&o7ugT<|dZ^?^v!A-$oz z_q%Py)l%hC>OdSxKR>^w zz9x7>yLA;k?h5y9K_~vZ0_6hpsVljws0GxfI11a66Zc>3XkS{jr0Q@SU)*u-9wBpn zbHQlE9OWVJ83FHWUmb3rSS3*9ShYXyKJ_tmSY;^ieN}MVtKC-0MFK0UT{?DEU)gk< z-w)Vc)^i5m8-L zEmqx=XqdP=@$shN%&U?cr?qZzyR5{Qm7QIt(XYz4ZCTc`ZGkHNO6i#&vOi?SWz8v? zeNOb*7+QK%6h}ED8g)?*>h9Z>`LX2i?!$<8W?x`(w=(V5b^XsmfhOr z-z(9zudeQDz}1CU7uK!3`ms*`s$1x;sMrCC-YZ=TuFBT!&vsQg6Bu1JR1}pyAlG}Z zE8=QT_I;HzL7&c^zB`mZAl56`>(qOxE3C`nYF@Uk%BsLN-!?BD-%rg$8wXCaI{R;> znxyPXdE{%NV&Z2aWpZ%W8TtwOkzIF{8|KKV$o0x?oW4;`!C}$i(|(SA&ECy!1JxmC zLvDuLeRIn0kXDJg-`VV-ik7=~mL>MH`I-31`Z@azD_)@MClTz7|Ag2Y+x<><#bsrNxlzSxi zVD53ZZFOA-AKj5ZsCKZ``_#o-P2t-D4g?gw40leKTOt&+ZON${1_3t$RQyBz_XUXD z)j3ppu=MCke`l}33vY{Gs@!{gtp6DIvBYC$#}xdx1)K_q57519e(zed{GG(`=G=fY zH>0yEiYrc5lzy~dt}sYgUcB;1#a*3q(Z@gP47v<Fgjk;TOMBzr|{CnGuDZHG0xBW@8?fCpFBHJrF3!W?hg4&tvnBRBrTt0Gx?oXk!I1% z?$DN=%=XNunU6AGW_D!uL{`7fao^Q3`I6HmgG=F;bS_0Cr6s8(>2BY7ql+=~aL&AI z`*Lp2bk>|XBPc99f+vJ0)I7vIysbUw;prPu8NGp0ORXPMA8S7zckSY}i8=c zb3H>OO(Y|4bKaJTH5RoNSGXQ|bxh?(&*H9WcS8*z$R+rX%ii3k+fB*t1Jwkb}=+(iB*VxOz88w;an79)RWt$ zvC?~)bcM{y6(1Hpy>UatC@-&4@ncrs;~TCft29;ye2D7{c)I)sZC9Sru*ApXeZBIB zuXJCI;5)&&a9)`~nTbnAd3JgBsk`OnCzhRJD~~BVSeCi(mRdtt#`8tDRc}*olgkWU zlFM_0^nCJd-`h^N-<1`VxtDd6jVntl6LHz?qFZi$>e@;96N!QG z_a0vhbA1;0Ft9uDd1X`Um04#OJ`;Xf7JU1Jd-sO!Q!Npvc9-`=To2LhTJ&I1dvxu` zLxX(p=D*w59ns>~^1Ri#b$QGE^5hfAr!1mKxm1fHsViwOYhQORd!s#&OR~t{U2~%* zbi-q73!Cd^xz5@PwU0-!MHws$d^cA+ZI;cf!?ThjbKg#XSJHR-rDKO<&%pV^O^2&O z&-ErxjAB)Io3NK6n;JH zk#pDbws#3VVfSZNMywS1aBJY!VCAcT-pPHF-+p{~>cQZpCik~LTZc^DK1H-n2o z+rkpZa+%DxCVO_kV(SOp1WJog=9tXMk;(@YUoBZY@4>FKSJv3AX>$TNp6E!ftSHjgZ-P@1dh}DYNczRY->;` zSXI4x(;9)b%4}ZLTK;UM>F$vRKGo|tEv~NFbZFC?P1&243ZyA_*Jfrb&2%@f9@=zC zc>;az$|hm7Fw9-QI&&=VfhF zalPu2GT_;@_bN?A_wtZ-s9#sx)v7E*WefVJtx@v5dDFMhYY%yzJgn`g>3GsH!)swm zSb+VN5_`wVhqdT@Uj3H^Gu5R1JnS9q>9^LEY;Y9!^Sm`wGSBh4S4DQe`K?Qi&kw27 zU)J2s3KM<4_}1%^agJVxu6ce;?~gd9bT4ar%J!^Oj z>Wj+es>3FS4ZUuABwxwRS!wvTqWQ)lk(%6XA0yw6Yur&~=Zzx=*r$J(Jv%rd`TPOG(SJ*&J$T-jTB}H2cw!TAyta_vc%WVQTwuGN- z4H@iudqu8pa+=V!#7=|I-JNY&GiSZ4Y+(<5{75J3lxl=yx5>%EmsFQ3tqa(efh+&bsn~CmYB3Fi|4ZkDVE}j<5=P_aY1Q*BPg?js9cvxGxmW}U|<9Fhf*(0Cn z5NvVoW{e|i@_22#SN`W@S%dgi*uIK7R}`bbRVj1F`M~}Mv(L516tGHht&IJ2ezu!| zo2Q$-TezD!^W`Xu+@jmlV{h{vlX+ozr{X|iu#uZ%`8|K+07&RQ(={WE3M&aqpj(F=e*v#9Q zkSn*W@=4v!h=f}c1ElXd94UUXacAAUnHwvoX*n^G-Y)nXVs$O>CAR}$^`j@7Yb3T zA9##k##Zf2*;#FV^G>)FC!v+PWy2v$^~-uErD>1eiHMY}J|90%zhrllii6F#GQnB= z(ibKBONE8@3hsD&!N}d$Uw>{$`t%p8tvRj6Y-!|J7$YC6EHlThz}@{r61Q1Gs_Y|! zxp$fAZsJJ(Po+5!Xm4m8v?kgmS~;zRmP^}Ai=i=??*(ueeJL>_f&c^{009U<00Izz z00bZa0SNs21gKOBfkfaSQYZwSHDjn;-z{-dIlo()N@X_rTgxd_j_;PpRQB(dCQ{kH zTOx6>{|w?>3F2lD}IL<0gGxB93%r3W>^m{@eLPf4l!rdqiM9P#^#S2tWV= z5P$##AOHafKmY;|_$vzt5P6s>KY>CZvi}x_P7Fz}Mx}46MzQ`#{!gOS6KMCD6$%6( z009U<00Izz00bZa0SG_<0)GhsZssQdk_ZAiYa`tm%#W)Y`TQ*sVV2HX_n-iIx8PuZ zy1WNN{_{3@Mo^GHCo?K4?IM9TM0>}455P<2{{zrLYoj&OZqlyPE;BcvKmY;|fB*y_ z009U<00Izz00bcL=MZ2aQ;5`&ifg3e9H}TH6~{=$K2oubRIDQv%Sc5YsYqlpg))-= z6KO-t{Qu7p3VHzn2tWV=5P$##AOHafKmY;|fWW_3fJ`PJ|Nr-r;%E?n00bZa0SG_< z0uX=z1Rwx`Kb-*X|NrTR1Ra6^1Rwwb2tWV=5P$##AOHaf{M!Y--TxmX&<6kQhsOaS z009U<00Izz00bZa0SG_<0ucD`3$T!h6cXP5|KA@7^Z)`7fB*y_009U<00Izz00bcL zpA`6Z|G$qw>-$fh3CD&21Rwwb2tWV=5P$##AOHafK;Zvx0WLB-MXyGsZ>mPI{>c4* z60Mm)>tt3a5P$##AOHafKmY;|fB*y_009X6wFEfH0>nsn20fHP5+cu{xCaHuy9EdP z)8#!F@}JksGlGKrnL#Bb$;WRjn|;x;RMIyuU{?#r!VP6}5*wQKP<#{0^ltBbUf=(O z?IyK#eRjb%ORk$^kIvC3xV4AdcVnR5OUvaqiD7fMTY52MpT(5#4BRvG*_vfz$x36# zDUIVgI9BLjn(9MQ^Qmc!hf@O|PFeI&EPAY{fOwimihrtph_P_36l>w;@hJwKV?(B$ z+~i_jCY^p``C(22chTx_I=$(r-9hW-Qq2j8aZ6_Hy;8mPa@&W3$$Q1}!yi3L;q{YT z@bPt0#YU5awdwiRniipo{F80Q<*>+&8eep=1Dp0z8bn$|HE$j;~A zW-BvsXmIi1;&})5PQR%#o@*;v^T|=avNg+0S;B4!3y35webl7J&FGb~HM|^_@s@i2 zjFDZ~$x8DIy%{^I7Vh))UGtGzM^VhRYD&3W9_Pns2+jAdE!=x(?BcnuR-4_-q|eLj zwMuC_DkgiLe%7rx$8psW57Qa8R>Cqt3np+SKR)#Ox2T-3#{RPq@tL zI9xfq%JuD;>u+iCl7#Hu!Z8}hy(fzet-m`*OKWKE#P)z*`J$zqV6r6DerO5b zo!he?c}Z*SI8nGzd~D7U7WvE>2Tl}De5c0gpd$U4ekNWOl4)f#wsU>cm;=yYl1ftDcPMlg>^S z)=w^ws!#JKF5{e)IANz({nR7;a(X5W${}jSl2x@cJ308oWmF61NXcxm&fm&uVdbXN ztoK?m;l{M+7*1K~Mf&rkp3b@WX*|1hWnzMCab)KFvFRbj3~s)*T_+UMb@JolYYA^A zP0O#A>`eIhMDxL5R3B;Cn9|t_`56te-V)3BTn3)=o@|@Duip99@Z>wkMXy_`C74;~ zh^{NACBp8!QX`n)W<%yC@-XR7~HOpCx_o z#*l?)WBW_wA1~$c-s8z{ukV(}`-DYhpxmK`CcDhGY!Ay0eZCxmDBmR(tHK1)keLGd z*=L9S7Q2c*kv)=q{ATpsedEz@qS+Rl>uJmTSa9mBzf?DRuIrG`TF21h6>*QgiBJux}L7odM{P0D0%sk^b^ z1+%;r?i!X!+)jC3%UvjNGDTHNBV|INgwMP{$x>sX#;q-L?xwsgB+GXD^>J&h=Ght4 zFr{y@)~2K#Jq=b2>SRNydgDH_R&4a#-NuF?vQPDvr7r4JZ;bDgT3gGjyoD+zR%2vs zaM^hMrl|$SH>NZ=9N4SPKdnb-61VWlqWrvdAL909j^nUrP?CkoOSzoJ4KX||DMi~Q zs~rx=uuDw*XGr_eboyVY@((6t)Q3$ z8_1L;cxk3wwjAJ1-!wM0Q7pgVf$^5k_)A;e==~|#O3!b)Tt5;bdL)ot;$BEG{oOv9 zjTt(&714SRKdJq8lfdn> zCtBBpvKP2LKJmc8CU)wJg!VA|s(^7NWufze8RPG+V@Mi(nkO%{{p@zpkW#AbmIW@( zn#$wb=PR(Rqb>@b+@F;1?I`Y2b>Xya&GR#{Hlh+hYciC)}yIEoU zY6hpC*1Q<e6uQ$H zCjN>$@>0e*-7*&4r?V9(X6uR`s4ZBXv0~l{gA)^0h`%@JQi$z3Z?@-X{n)oVbckwa z9zWYPhFjWQ>q@7r-|DoGX!Y^CmRQKN5h^)XjPG@}ePMPnT734!=k2o}Y-4-qU&X@N z$)&d=K(9eqZ{R%r0lzxO_6N85Tn;RVc~x$(d};PM&VlJiR!!Vt>0N64kQb zwIQZ^4y|mh6Y<&j{N0vU$G1Psa50@Yw%J&u$y}XOC3Lc^Y=K(;vXGC_1VMtwx)0g; z9I~r5ZM|iw6XIqs6kv=S&*Ds&xO?Lzxk)R?&V%|~`f(}ihos$96*{=2+qn#M7M{-N z>zb!3R=0&TX9FkyRB9Kie;i5uFv&>C@V-%?sRzk~$Ra0cI##k^#SzxC&XzQ5Qhj%! z!c+D!J2X#DPibrAUS_>N;zsPCCC!cPMgNZKIliiX<7@A%7u+wIVO2okaoM_lTEbT4 zQ|tDSsFbwkgff<`^H&$^oVC<_uR^s=m(1Q%c+s~%cl?CeO_d(9&)6K<_qEMd<~V=P ztTT0Ple75rTedx#?NK+)BFDTfx2js?8)t(w=} z)Zy$t4MOHK8kIXnmpo%Q(%*V;W5e9K;oQBI&iM(>CqHo{+Ir?Iz#R;ZB=u#IBZialylV!R|F% zsf# z`Ka~0lDk;yn6dwy6>IARjpf2EkJOE}-`1a6F|P4h#e?lmF?08`&h#c%9$$I7Gw{Sr zi|Jclm2EIK38txS8@^r45>1)dv8X?q@~-M*gI%QPi-q%qMLV8VjE&l?ZLq13En1-C zjZi{-zFNudF}jMR1qEvRABYNXxDu?%({2^1D!jJpeYp071rO$XzvbAF%3mzeQOly7 zVmdXgcCrWO$+VVvcPnE2Ud@TPf@09Y0)oAU0$oI zyuHP+TS}#7y7IQGQ_R?#WSXN+-PycYB6R6wKf}ZX9^2I3vPb%R{oE_|O?laKo-L@; zVP(R#-of?)-vJJ)iPIp9lw!QV^)yGBDjMBz4!u45)fvM@4i>b+MG@tGN7Awv&|-3p zO4Kj6n+Gh(Ju$ob;)86Xuzu-lF3KCr*`4I1&+c`yRr41UPrdp=z+e2V!QP^Ci>=1C z-sg?WW6LyiPoO$TGGdxk*3sSPA6rl+C@CxHIpv_or=)aio=!fO%^kkR0m`~(7f)Fo ztJ+ze_PW?MeEeAhADNkJOpUs9`2AJS8u%?9S9_sTa}qhpO>>fPRmco3L-QqmQ%g1- z-*-pxQXKK#w1rm}JV${mDzgq#n@-7nV4hpN!Uo#qLCPn^?0cHXd6xFMKja z=7oK5xGeuE&ixUG)YO#hLj36!ncYkLIOp%@5~Fgsty^x>K&W(3O+C3cXoB1UT7tdM zeGB);lRbos?@TM&DBmEL*}jK8YOYARAyu)|Q>%0n$H0_fn!%Fx#jLt_W{#DiWxTdB z-Mwt#?gU;T_oUSL)f?j1qz~*039McBN?OOEPT{Ftlg`zLhAkq~R(Rw;ytqU?YMsl$ zaeMvvdM~UvID0zF?Wj2nj_uEac6--KPxc$0r?uql@>AYf%DJY(tcEkMpD&1u%!|wxBy1Pm zvcB%(whI%Mm>f)xJbcH=U+frP(3pE0UdGDqc$z29Rb$iXb7-@+{)Ci^9u&vapCJFJT#+HN<GHPW#B%;z!8!VCv z+N?1pz-3kT8@%ld9#s1!7{mR=*9xQvZIs4f&&hXPg!F@Zj-yc}9^GF0G`*PzVCGi;j zaoY~_g-Kn1&FGDKL*jU39O4rbsgT4c`>dmae@wxiQx06qTZ`L8eBYbjs~fwg!AO2r zoPO|w#tTueY!_4CEaqJvx|Mxn0snGskySkW&gQvC2tf+GH+bt_*(;0XaSpYz@SQeR zd((bk;p_?GTyoS~q{VVK>(4K?NZFmLxtz_9a$xJ;=N<=k*WRermS206<0221DpTP1~@Jr0Bu{C2OA6T6bSi-meic=5}oEvwJvN?b46;Zn|_>>{L> zbb#aatf-m7N=b)5-V%M9urW+}f7h+PaZVDSNW<69>pZv2j_SJV|7_v=Vc{3IlW%W4 zvvJjvg>9~>Y25oyT{v@*>!I%0R2GWKPH|(lTInqD!yYLmsoXD98pYY8@>a~YPovcu zdT=|M=tw@Vk?OR0BQTItA|23;YlglJpMLI^UC!}P3h-? z`M8ok%EXB2^F5&L6OH1wUndoRdAYQqw)SoAd4zM-+4tBB@;x>5QeDd0l3((QWOf(# z9;w=SF2T}R+eet+;(YwAx##KmJk#G)-w|IMIqmZ5rP5=q=93PkpR2iJSyD9BVZyVC zJ-pO1&Wn<^hShHhPbIA_zd@C{lw9pNnK$Wew&leh&YQPOBi25n@oY`p#~u2x_UhY3 z^&8IU84lamy`6lg(xT0RHJrG3qUr%!4SVs~PsBUAf_HomX<6xnPguR3-*V1$ku=`f zYbPWPZ7$u=d-k1e$=QQ3sqD%x4E@bKH&8baEsDL=ljcP1w1@m~>!Wp!iI}-R&>LPn}KBk$g&7vRl&;^Bk+?PdLQ4kbC+|LQy3tF8o|xlx36|-Kz0SQmN%Cx#YTfH;1FDWCixpMQUfp}c z>%jFIui~Y??Kw`*G(YB)7C$|i?q1rztTMNBqT7NGw%qrqKI9zkV0PPZjm_L!$1_|e z2DD8vj<2va+1j|;CA&IRBQ>!$WGq`K;cEVvXOf3DTvC3zd_BFPFdhPoZggdf>OLf=U8=pBp9D3mTlAPG*r&j9?oMG28Y@fYybK0jZpBh;;pNi-U zI`tkR3b2sX*R$0wR^;cFT-&@@$)Q1b?eOFMM zEm+V)zx*6MSV)^~|2eN$LZmpVzNDr}w8WW|&`w%N?vU}PsjJesIz?m%t|YF7PxWf< zuT8mDFB`LCJmv5y`cwMl&Rrjzciy$UxnFQ-mB)b?cYW=(Tk<|m<&zMrYRcZBY-)UO z`J6dIlj_Q2ClyaS>TkNGSIwO_k=!&l{#GR1xJTR_v?vf0x#FMuAQu&=M=SssD~hbN1DV%Hk(1*BYxivuT@|?>1<>YMlMt_|t`3w&O37 zW^6RDik8%s3RR?fvS!*XL#yHTt!`}wr1?7OR|CTDjGY_-<2 z-Jb2HIYaH0%!C80Z&>DcOpNd~%d$}^nY)j=|J<;~;yg{YeGhMzNxAP?y!gzf;5}`n z0&PmC_l5e!(Wv(vySvp&)K_@F=g7I|6dd^cZ2n5$_g7z?bgOV%>sH{F*3MPsSv8?5 zBd#o_y4+m&Tp%wuKu1WWLh?VRH-<6;&oY3n^d_PNoG{Jk%a z?&Dx@7T;}Y=jdT=R?;25GrR9pM}|)lk4*@c1p9o6zvT~ z>DivhclF|7hjtwf6gIgYJa1m6#EWO%%kt`_)io08So&^Ck29XRiDGXTD|6TI&V^&` zRysYlHn#eYOO_7!Z?06g=YA3Io0(TEyz9BviD!FDvvi*B-ydoumhyzRf5tZMESHMi zYr9Ijo>gS;)8G>?3YDxh?taQYq|Ymhj!ZvzP`yntGD4=)CSNqFCaWwiXBIWc z|Em4XE$Ii=rl(voJ-C|Bqm(xD8TIixefyO+eM`wjx27dom8NrV^{8B0-licFW12Jd z7Uhl2j`zh$QzOSLmpL8HR&$+U6s+S@+oG!<%~Nwd%;=S4lC5_cd*`yrV_mkZ@WnP0 zYPgrh5+_dEJ%=GSTjSaK7%wx6d8agpA!!DuPU?s6s?Uj2APmG7X`U2`*zI|J+ey8^ zTXzq~2U!=tn6Nsq!EVBwc#ZVS(Ucm6u=LpV9j6@bY0J*obVV;{w~(u%KWnG`E%n=l z+fF=A*%K_6Y#%M0ny66laNdj8v6~*Irdm1%h~L~lf2D0@XkmG3tBi4-T|h@_b5WQb zvC^vMG<)&t*DGaOb`+7SX-f~&c|$eyOh0YmI)9N=_VJ;>Iko9xie-lTHVL&4Pdsp4 znY1l+3}dmtxx^O>LTA|Kjp08oaQwM4sl&V@`bd{!hkQriTJILW7Q@(Wd+K8}Gq&1# z_m{l(>(HnsSFf!ODh^iRbF#_j<`eyNT*{0+Ha%WaaMMD~s=Hp(?ohAAHB8U4Ze#Cn zX6+7K;4*F7!32V{hQtk-xEAi3)}^HnYbG2J55M(psn^Q3_q*F1HQbYq$?*jmcbD|o zt-pR#B zixZ3GPOjx#xTijya!`nGcJ3?(&bw)xmRXBrO<$V6G@EQX*=)P%cC+Kz<(cK#;ZyGzrmVbV>@8P|b2RF2bye!;QnVx^ycl(mTzJiNO z1B9J~<2*LEhTGg69@cMee*fU(2j_O%Cv(TPa_v_cdp0X@{Nh9*V<|~bUYiV$0yS#$ zq|J4j%6q1sI-h#SsN2h6iJ(`)lf=StHm@pepJcQcbuZX-;buy^*_dNH{TwMRM)Ms8 z-@3Cl>uq{e{azCE#YZ|pKJmljfZ`sN1 z!UqcLV?NmKwQ00XxTjKZM^O4$(mj>k9JlIKSQn}sC3*-=-fuZ)sf_A9(xK8MPX&kZ z8RyPtQO-BNIzd|#eC+1U%QtV`%;74SsB(Yfht1DV&;};0s9}45hVnLxW^A*~SL2Gu?T7T=b_g$a6^t%Cy*>44@^|H-k#%yPk3g-y$U9L^GqH(tTW~`!uMdx4I@|(nR%Gg@xCvY~| zu3vq>o|^0v*Ty)$o>34WsFCU-$HV zy7jFh$0_EK3Xk*UjP8YBgK_-EFNtmT}P&s;EuL z!trCZ@>hhKgql3)(6Z-zSJ>3)&UQG%G$kzUh9Jaz@sbhW;+A0}-?MbatL+soOryO^#1T ztlm+G#y)B9hHXI_$xY3tPhQsE#_jzmb6=fW*V3-_n!ZU{esvvnTEF)bo>3fSxkmg4K)dA`+F z)ON@8h7<9eHpRgTd_`7nT72CF=RewJ>cwW+68ijQuI8U7+h&@?vKK2RbJDEZ-KrZW z^^&_zcCpv>UZrJ;sOv6?zNvkGD05(G@3=0hINldan+GTO$mthSMHoE!K2KLDjbTEDQE3QMT4lnTqJ@GKRUQ{g!( zJWmC<{zWRhLWPx7c$EsTQQ>teyg`MxsPHxw-l4(=R9H=g4@uJjYH~8HT=T?nbnxg% zb!|_eefxtRxf(vm4NS0urA-#0Hz9 zQQkj@mW>Hj1{DJ7I@;`5{B&p(ds-~27F17kD4&cpsj7^X)t5b{I+#BX%E9sH=u2gJ ztnw^#-7_+M!qE9Bs$;c#C{&|9c-yc#V}eosT(0gmzxL*dE~y(j7D|e$MGcIEg5}j! z6_ffaJgEbWO6hfT$%d9`gjp)*)iI?`vTndoax0w)=2OkA)LgAvX)QCFiF}jTd(1{N znfEa7Fz*yPK>;=98t@*?s4$-u0`B86Dm-rCHZh6F7mL}=xCI|TtIXfR|LFrLX?&BKpzq@F zyR?Fy&Nqn+ah__3(=5{gd^cNBOZLdVVu&wM;bk7r?<3xe`MH+d_#Qy3EDyr}hhU%i zIj`d9J_J99-(kPrg+nR$xq005o(5K!=}l*BsiwDrF$7`!J_~2+49XkkGK)pjC&=LGkg?+3|rX0NVLMDrdM-nZ}s_d!x{FgqFmld`$&Yfi?yq+OJ?n14Y4j+z}nMVY_7KUw%%s#gZ5baTKnOkb%1rC zb&z$ib%=GS#bh0BEwzrY2CXBlW!7>kY@otM-W}USg)gYEnRULlP~l4wp~6-wY@@<< zDttu+u-BbDLD@xx-Bj2^g}qeRM}_@V_?i(OWgTrDV+~txw~n<|SSzhn)@o~wb(}R~ zjap;YT8q&-!8*}8$vWA3hjoe-`g`9{;Q$p5QsG-F9HPQ^R5(n9BUCs_g=17WPK6Ux zI7x*csBnr3Kk^LjG!=fL!p~GVLxr~I3k*0&|XXp+JN9)r4KukLYxP3)+*;r+A zL8PC82S|B!%C;dtL-h7M%1ni6j_I3ugub5hp1f@-m!}JM4-o~V?j5rovl07 zpt#}iGg#4~{tX?0x>*hludFA=XzRO7N-=e_nc36;005BdcrltH`Z6mXs;zOdq+}K- zeoq0`cvA8dQ-9E-4HrSy-Kb^AVn7zx-KgbI(7%N97FD9=YPIK8PZZQP;UjlSD#OV3 zW}e5|TTNv+coW~G2EUENZ_~oR;G1MaT@2g@pnc-*hhsOxk}O`&YM87(r6jA#I-0zv zVcn+mG{6J&t?Ov=p2qh|PXkPR3y~9-H4DvV0Sb({FAq>r)~!-IRlJJ`*1E+K?^UHH z$bMXq{j`Gol5bKF2i82xs)!@W!>lYxV?+l4$pP^sjnf3rwlxKnvknC0K%L5gL)?}; z>u1W)GK3ht zKNW2+2V}WU+rNf|Cm^=5kOkQ)sPI2EEwWX!w5TU|{rG_>yCOz~KXiGLZ36PxCZSm@ zPdd+v{F4=(a-L+n3-Tn}-BkFiL7rqCj8E8NN}dGi$sbCdWV=tBC)sA&X6f=I+XJ>| zS)Syx&9yyfn`e&N9=1JVd(<}H_88h@d%`@$w!loR18oa!&)62(7Mpk4mfDt441G<3 zV$50}ixf)~8z?qXY=UG7n^}h(TX@HB8^u(A(OYhN&i1@@o9#v0 z3foJzmu;`uR@z>*y=Hse_J-|E+bY{zwzqBXn0MITv%OETOtFVzFLTz|N3oycEQ+%! z&Y?J$;yjA;DK4P6km4eW8&P}-#g|fi85>2!ms5NNX*wWWRUR&5F9~ZD3GqjfqVcq# zV~EdHWG^^iZ)6IU^Vy1W!_iSl7yfG$cX&*{*Ik^dfa?pD&PSd&IY<4jQ=@Ou3<_c?NTdU z-kH5ZQa$)DNp!0Ew3uv~9C(XAu#!XfC*#o7ig!c|^o6irp5jFo?J>)@8ei zb=hqD(JI?F@c$s}vo4!k8Qyq|4R2JkUsZ4@X_sv_@Whp^^{AL(qJp7=n-ravmV(>5v$fRFDq?@?jkkY)(uphWSdv z*sXROZ^9&m-Y@~i5m>bZ9A(0idc*MO!i4lFRPxbybm2k*mz3~T zLM%KmUJpE&!qnbk&4rN;=zlcEiCyPBY11U>vE*Gvi2>XQsmKLg03x zj$4R8)>M$s0P-0f5&~DE$;frhvIvlibV!JI2`3fDm2WX17weQS4VL7C*$lgsB}KNS zL>bKHUgoWn}_QjC6*q2azm72-epRFGPQWFRJ3-%XvnS}ji`v#Ut zWEmUVSK41SN3DGfo$YU;J@&WkZ;P$$@7mupMeHBgS6jZce`H@{Un@oJ>(Cbar^Y|c zE$!>sG;-X8;-);K+)RF__!^c>TubqFWFf`ZQ+xxPn1!2Bd?Opz#LX#&IMagcqPQi2 zomPBu<2Z_MW=vdV-(>&7*3!Pk(8<2lzRkYf{*`@)eW!hwX`FqJeXnJUeZT!{`!`a= ze$f7{{gCmzxrO}*n-q^*Q{0B-$ha-}AH}y&3|zFQxC6x8Hdc$f zQrwN=?qny$Jt*!;ac@3bvxedl(sW?_QPiRALFkU^PN|$ZzB(4J8r_?}DXYg^4|GI2w{4(ZOFLt9@_eQ6| zb+6ip4vT4CD4p=JGG&Bw4g`&rOiAr434aucb+~X7zDW)JDo4MnK{w@_Wad0aKgz(N ze2^hEvTt&-Z)(V1!#BxX`%WGp~@mo^MjC>w6sio~Euh@J%w8$*>;)@&mWu@4^WoRe~R{of42`i%~bJe|IA?b0C1)Y>c#XY7BLZm&)W~$jgWh(-& ze4D~kAM!DC{kRp$x>yo>3rk|t7NS*Yi{Sqf*k?&>mXgH2!jjlo?AL5Klr)K*1MIN6 z9Rfx=vB`6cDL%9U40UJDAGfk7v*!}kX`v!$@D@xT*RvHtMCr?)9Ct%r!7wt?dQYKTw! zg2ns~z>?wzwz4ar|2|MRdXcsRi0wji!~n&E7^8!M=adI8Ah9W4oc1-vLmCWT*w^9{ zY1{bV1#ST}mBEX&BYg1UVh!7z*Zd?j>?!zv8unSkx|D9?Sk`SE&VH4`q52uSX=l@Z z)g>-z|4X}o)+|r^J?)RQ^J#yk{e{aY9zk)C;*pg7E+?_&Y5zEYT?clE4(Kt3C>}-e zXo`VEnBv<>Q%&?8T>sdnCZ-OLzgsN zSlxin5s8q@88GA@fc!(v_7o&kFX5|^$vK*EaB*b2{h|4q*A~6!KU4^ROj$3fErD^AJSsJ98BS{PdS(n^8 zIy-LF>4>A7VEzeQDNljw> z#-pRRR$?)}NsT(c@#wfsOTCtFlBHsE8w|TJY%0SpjA#jvEYZsgOgPc2RO_I90oqsB zNE2X5mT=B(Fkr)xi3V(R?Dhwm{q^jEqe*yUxL&{mxPS*}1w4swlKJ1c4F-G^o>Lj{ zncHB%M?oeS@X^(FFt9sVUt4gc7n@b9gAM`c5Iq!JaKgvl)Yfqf1?W(1>oCtfB^9E> z069!g6g+;yiK(iP9K!)RTq`6NC=yLfts`0r$WomeLVUT{#8e%-BLF!<&o0Cz!h@)@ zBScjv9DZDls?IL;BSi_4S7#4r&-&!mS>o)AS~&Z|9^`(nQoKO>a>Q#C(9Yq`v1rXp&Qj+HXV5tk2c6~4kaH9+!?!!bXb;6tQT#N;3n_kvfTvqT z@nVXXKz0Dly_B?j$ywp7bXGa5oi)yJ&WJPWj5%waQ@z>ICxAj%D-|JifA9?91;mm=%v0_TmOP>r}{qa z`j=fhBcZa2V7RhtRp$zpD-NrhrBV_qOZp7rgCI|6Y-e3#Kf#0u)lc+NPu|!J361R> zp+kbylD^lIA%lPn>X0DpM6dSbO3jcX0XZ_R)ExN?EXmyYgAJxXFl4gn56pHM#d4q+ zzpblK1cgZWV$gUkLx2qFY6*IDv3G;knGr*d0_3PVGvd%Cup}M_P(>7fhqy*aNnaeU za*VZ)^>Sd6$9m&64FkVnT}_w5l0vKKgX|Ne{#BvQ!^~S8q4>49w>ZWQKyJS%e&7do z;1tDg=sKOwpMarX(1Wbg`6gp!6)QN!S4TU~L7&t4KZ@V_k9S8qPq98H%l_X`x}45` zwD-$pEQ>lnBO7HKTC$X7vY$c-ufNNpauX}x#9Sf{)d z^XT%8Xq9{u{AZv1WSw%KGTM^HI^}B_k&od}eWzS*E8n7*a|ii$*5SHT?kIPXJIlAq zUF5E~OztlCkbBC-axb~J4BaoeMD8p1ll#j9_Cq zC0ENe@;EsnN9CAYE032a$P?vB@?`lAd5V0ee3yK;Ok^tGBTtp@m8Z$m<@@COiek&uO_dDY6mQs+g zr+g10T{+vX{^M3$^g}{coW561_pUeAI(*oy2LL^`@T3n$z~OZaoXxd`F9 z0elAT3ec{E58$iV^{^z%s2*xK3buyuQLyBlb4HU7XUOs43{G?d-y~u<-ncyg+C#@M zIM##{nN^16o`CGBVVL{lP05lChAakTaXi`JNO0p{q*hrzkRkWdR(3PrB+KU*xHmw1 z*X45@454F7Dss9FpttGd^d&4Q)K;HbtsyDiTx2v$cwI}hnz)s3QWI5Q4)3cK)i%CK zMO6I&+D|Vkh^yOEquifU?ysf%72l*nc>q8M=qW=i-;tWg26D;+wIbWeH>oksXFJP- zw2bfKn^bInFhB?ER0L9w-Km(;5I_#mnG$3ydr}eBP(TjV3kx!xy@`m%*&PPZVLBfP z2~xtjxpCxhKn~X-_rr1#WcuUvR)p1#4HdBpWjz~NOs#E4@WvUTZQHN;CN+4F!-HD* zH++*ym7%{YkJMEe^2YQq1XdO*RsSl?Ttj7RyjE*K`;zv@U3H@C;tfTooEWF#3eg?Wd z>1V0vQ2RmY=h#>6${=Jx|6x2hJ0h;DWnVDDxs7E&=?`^ncLDqc(c zRFvU>U+oCFEH10AXXHwA`Ouo@T@IJiCA-qm9+w-Jxx7?#QPE9B4;8)8eGz@Yn&^Mt z<#z>K8LmuMmMhzp+xtthy>R5c19%HjXtkTBCeLNl9|ub-VSSj!QhtzYmzz!yp|8 zGb=SOMLsm`}w5s5B_AKk-*sA4ZGWYz|j0D*y_Em6?K*eTEhEkQY$^ z3-J$AwWur8!6>&o*LXfSqx_zPn=N%scHQBcLd8Z@yqt=Sb%RE(yFpMCJ!BdYZbUn*IQJ)F5YDCa<>V#21R)42H9L6x<1moxQ|_5 zvB4kNwa)d4>r>ZfuFu&w&0HICnb_L3*|kM%@7fBsa|IQfQSnA9HmBlERBXXMZbrqH z(A!x=#a2|jnLJCy)?icJSXnz5yRkCnytq0xf=-GSt!V zb7=jls;kOEy04!#`0APNEA`2`*pGldtOJ`;U#U#GFr@$)HB07<81NGSexf)d1qhN& zIx~&|KLw!jO68yeg!V`{Gmg0;2K)?w${Ur76(F=?($}jp;O7ARTwBK$up|P$rvP=+ z9RZli*T)`d@bxhUOY-%xIGY=R&5c?%Tf&muOFz=kOLLmZ2K?35(0%fZ;ZpeI8RK;` z@VZ&cD;Q9#6h!LU0>CX=Ah`3JQ)qqNhsYSCNj^jtXLKvj+p4A48kQ9FTQ@Pc3#q>? z;T{8S$sKmzPDO}^y;NuAuEg>0m?`_?m^b?j6f}OI77n<_Q}H%k*l|xn9`_yS5$2Qo zF!oA-Sc);gO&|uiDHZ$vhxc`lk9*(S6#wd;k#Kfg8saVExOAm8u-K(l6RP_m!g{qY2?KRWm21h4V zm8m0->}TKjtWkc)LlXosQ6jDR7R0CGt@$n?(>eJ?!VlByDzx^ zVY6M?^Ian7UYK=Kz~aWTSuJ7(6)V+yX<`)>tJ!1^<+|7`{mj{xgUFtc8d9 zCYf5zZ#Xa+Ajvv1Jg_pNXMp6Hcz>PK1f94&`5+9#p5+XmCCVTS2an~OWKnW{!^u_r zo%qRKB33V{-#Fplw34den-mDU&jIwDmN00(SeeYt%x^fiinE;T?y!o~&#mIfWG`)~ zm+~LHvVUl$%p*v`E`2@xJcpmx!mIfv1zG9mSTQW0W0e}=zc}H)w1jK;Cbc&Ho5TOs zw(&T=NezC1!!Kyz5xz;qDEp3L0^&Ivb@qCZ=c#e6FQ*jOzAE4q~Dn7{4i+SW@Dn3NThpG4o6`^lApFr{fUF659 z_ymg|Pf~FKEB&PB2hSpVA)n{Ng#|IqUh=^PA_K=YO8xJ%4!4d;awNMa8G6 z_%sz4QW1KIi>SDm;yzS_5|>hO85N)!V%4!=Mf=+F(IE&QwUL_Y zXlO8W;Mol2@^-O7eCAmw+AcP%?lC>a9DgA!d#Av^SnnRLf)M75Q|PGcto9qro)t`f zf8#Mw7XE&3kNSRZd>hl2Iee4cJ{f#BhwoPHlfxh2n?!Ks@iPYB!{IFC^p3;l@=Y?o z!oYh0x;O4uIQBtUQmQsCV}yCSr&b%%Bb4~|YJH{mbFTMmD?N{IQsMd=fPSOn8f@dC z1-|P( z#~tT8+4s0vxMV~R1IfejaLH+c&v`7F17*k~fIJd+pd1OV>haX7`aQ4e_u8sH!8ghB zug4nZU$>DIZwxcC$2i$zT9tf~Z<6(y82C6qkL&8Y02Uq}ywCIAX@XlL-WA@L>TivB zU-!O&+Id&O9(eY>RD4Z)Yh*ta50Pvtz6uAj)#&1V*ZZC>+;~@e`M9Ci`=R$E?;7t~ z@5kPC-cQgT?`Ni#-u2!M-i_W(xXioRyT$vZcdK`scRLkdr{Wt_e3Oc+sQ4BY-=^X_ zEVR7KKJg;HN5%K4_yHAHvv~9&6+fcl8Y-@(B1EQjjM5J8PVX-7ZtouNUhh8de(%@b zZ@dS*2fg2V4|%`y9`+vbe(yc%J?4er_6Zd~rQ&B){G5vGskniP5KcEy@e3+$rs5VV zeo4ixRD>=Nu=N!ccTjOB6?ai_Hx>8rAT%Zz?HR6O-^uG;9S!r2MU;O5d`SIQ|Dyes zX~+HH;?5!Vh)ks(0!P>lnpkmdMFo#k(E+d*0lz=63NGtVIWekyyzPots92a z4R-<^?VJBb*8eUU6$%Zi`!LN=?U!pVqFi;iWgZ8G_)vhg?zYTIrUuYhuP5stGw4SE z{YceI1qp5ZMk>fPfLx1X=eHX@*<} zNc9kZ6V=Bl$ai2#=4u{qc+YBO(ym?|#m|7^XF7`S!jb~g)KdK@ zP92hceJSBLapE-B1I_h1nqblIrGnf5$PGFqID&*9p^vx8MnG=VwI=wFgx{g>qarr} za+40Z8kQ8e)sH}}BuPi0)VB0)28x?CZOMJyhpFi77C>&%@d~an;pgk)^7#^wU+R$H zQ7`rddmVBsAh+s};E30zg4_nkZ8{|Q?~hZEtam#gx9jv^9V|S;`Pf@ge9&jur*?^a zFW_cNA--$^J&*CmdpCY&CA;)hDt@gSEB3vCJib-vaW+=W=8uR67>)U*@|B4Pi?zO3 zDgQlGyu|mu4>au{6~FzD(PQ7M@zLY2_4n=|BC01j$6qUB%RbiK^Fe(2E?$Gp{N5et zC#zY)xbEBT`>H;!`}X?wq4vJ7VXr+E&r|VxZCwA0N|@wOk$s~$M-Ah?Z+(Y!aou;s z_cL0v!uP%JsPCBXxbKATr0)mcDc_H14=$tPQ7RszA_UkIR6I$=AEiaF;$gLPaW}w`AkBndKxgK`kdOfSj>pY|BcTyaBy%7P zc><6pblgJQolJ%1NkE>|(S#=dAr<5gfc!y+1Wif!ai(~yoC4%29k-xh2|rRENB#)N zA9cvnu%yr``apX)wLtqs!(ecj)IS67F9oCakD%h8s!{vPn3Xe|i60ovt{6kbzjc1f zKNflXmFNlPr`QKo#D7@9{|7(ikAR=@NBuD>Au0*~;ivp#;(qFHy`SD|2+RgsDq!~DC|Hb50#7>KPCC7ludG} zWPk&?s$22^005Fd?$_Td^FQTZj@G>3f7-v$|BR_6_$csD{$=2!Bomd)RI>0+#VPKZ zsAMIv7yQrppZCAuf6>3f|C0Y@|118LRI*XYPNg&|IjH2Ml1!y^D!HiSPSGa~s{1T> zGW~oKs{OP8tE!(8`$8<^HQ_t#`l!f-fLy3ULKPFfijHYELp}q@XH?BrkQP{y>GqQi zQbCSPDHVLOf$ei-O1A%G0}ED=saWun4J=qn{*CH5ul{8qpJiHJB`Yi`Aa%CSDc0Hk z1!gG!P|2$rN&xd@#lH}w5@Ss#o{FC_!=(Xe5x>p}1b|Y&ft~~>AhEGwDT7h?KRAJa z8=OGE6Yx?glS*0t;RFH})d@&`y%X>+jGyGn=2x6RppY?KM5UZ~4KC$Q0JM|UETIz! zToq_s?*szZ1+GUO1I=KsBb6?vQohy+TuG&-B#%mYa3D{$`9O<6OPv!4v<|dKYnBDt z1lr=V0GN3Jl?oXjMau#m0v!XL0-dSUh)S1G=~60P#`^Wn`j@;Zk4Ih1vXg1+0;oY7 z0oJ&-<}$W{fr4cA%#dvX*;a=vgeCcOl?DHrRLIat=c_OyVPx9@*>-Uw;bfsDiXuZxPOD}&Q>h)vr_!}>AV02E z3j$B+)$EzTGNxvW0*gT&OR01{m2O}xHCq;VHn2SKT;O>s-AJY8RJw^uElAUWqsEVI zKPeXaf3&>^TwLds{y#`_Nfb9(5|)NC4G^M;DvE{ZqDyp9L`Muw^dhRnydVonAP`0L zE^szI*~BR>iJfHAb~n{2yPM7aekq&em+WTOsg6_r-+Kp1gJX;NyTqKEk7eEap7%M= z^PF>GxVP;Mu}iM=B8jh3tkpYJmY<0yuN$-zyW}(1IP=a&7A$8T<7;PjE&eUL{B2~H zyzFh3y?uvW%CfhL>6`j&-VYch^^-idzbd-C7<7om>W&Mt0f4*fv2 zD6=b4zHF3yd0W}_Bbi-47V7BNV%NXiVOPW!yMAf?E6Im`b?AS#GvU`7C;7Xc9*2H& z=(q1QANu{FKOB1bft5qA9QxCtKff#g&|ftwjh$xKo?mO!8cAdS*e_MzduN~KU780p z4{9FLJgj*{vsd$|W}jxi=77dQ^O)wK=8#6C!I^Quc=>0{Y78q;sFUQ!p{kRGIz^~c z<)0v{PYLyDp{5||@*8l}0Guh*Sc7R1>CoTytC#t~sHJ&_rsYG|`$EO{`E?3U#Yc_Y1XI zsD)6Y=s}?#5^AvSdqlXg0Mro7y|d>rcwR zGFe;EA>RcSR<)W;J*KRVdQ)3|B~w~MdAa}q`01uf z`9@X#K#THRPpi+Xl@&nZ|0i#-DBd;;OU!(5cQlk6_Pfvg~H-siyqr zMpLKdJ)Bl&-FZTKer;Puc}JTJ+5x2^cP?|cBnR$3oPArij>_LYseCg$qeFhmRh#MV zIR`7PNV4rTjrD1*wdbq}B0nE?>s6=n-)wF*WwbU`x0W|Fm@2JVcPG^ZnG*Ld@O^Cd zop`6(ZVwjp4von!Qu)BY#L&8=sg|EjsHxMO*VNm^<1D|ii-Lq{*>Oz`%J(!(@%1L< zvGc<2Q;)v$82*X}wg|OJsH^rU-f^-hE&HV9q4oHm;$)iEUBR20Hch*xLvukx$F3IY zTA@BK)D1%2B!84~r=M6YkyJkh&(UmoXV$0Wg=>_9{O~WUeak;hx9y6`TevjWtgl$7 z9OUQgTU{~h&W+W5f1Xt9Fq+CY6N6WZ# z8D&mr-1=Chb*<&kP1<(VcPm$YxAj$=98G=WS!1@(yDqT zP0t?XP`cOWoSd5MqIXlazJ5+qT2p$XNwL`bv-sX>KT}rwne}RY%As_x$KBOly^%Mv z&`ZifFIg{iNja46b!#)#SH->FVq2^1GzKrd`nmS<5<7krPrg0l%tOCt9{v5+7;H(7 z=_+BTZ%`q zxKK~pxMjyQdCQJz)>4sXsQ9F<>Kcg(lG?x=Ej!ye(Nym!g*&U=@XOWsrtZ-+-X z)+?*kTdy{u9NuD&aJ--_bisO|Ddk|JN7%-&en&G5`}3hcZ)e!FP|xg<@1fMGufeb@ zGQ)-(uR30HyzU6SW`%lQs27ELS*TZSWtcK~I8ItKVNN-`Nrow-h~tv=br+Pw8)uj@ zhd935x)e*w;ca2qN0ik*V!hgma(Js5_KdR7Gu8{ODF+)FW>UZx^zN3)#8p`;I ztc?G3{Hf#59DnZk|AhMQg!;Wg{eGeTpiqCe1PP-`Z9TsgcshB?J3*Nw5h?kAPQ8)umEv?r%j>ry~4RP^tAopH>bwGVK13LReNU$xkqFs&=Z8->jxlYvflz$Zv0u*P^RGFVrvWQNA6T zVK(mc#`9(Qu?iX=t`V=&JJoQ_tv8CqXPNS-)oNn^} z6a2gUTc@s;_fU+RYlI{}D_m;lk9K_7#u7Qn==hS!tI$z@Ue4%qq;eM>8}7cgeT+lF zWygZS}BkC<^+9wb%T!a(I&-ru_bc z)4y3?_iM`GjeD5#v3aMTSd0A|%HeJCFy(hFoqlD#+P9R$TkT=WuiZJlY`xI8m4l5Q zW>Y;h4=7{Y1J?D(Q4aS_Qu&ny`Sb^s(;u`>@{Dq@iQmo#oE_lzLz;)S^ZQ?f`d{}b zKQzkyTYft`!Ea}0XBTI!v(8yB)c+>b|1Q-3A=Lj@sDET5znzcBi8~*$-uL&F!<*x` zbD(nFKhsxnC=C`x*76|7I>xF)- z9BkybZ44`LJ`2Mh);zqOVgD)AKi#AJOfv5^7*-}TtlYW6`J8j5vq`9bCe++*{8Fg@ zTd4oXR)#ew`)jaf!q1h%n`Bt0a@|hr>;6JHym5vJ<&wg>6#u0h-WG-pE2|A#ul6hD z@K!VIma@<->xKSTIoQZB8@~Ujc?A2r^UN{J68U`Da^_yn+`H|}0>19IGs~|a%I7|+ z*zTid%X>eSlg}y#TNd8Osr$AqT*#L#XYS|B{oBqg;>$K6e$n|$2=RdCz;+@2L8xEe zqkI&g_%($1RauB%bN;&XH=Mue{4JsWqfq}zsQ)4)2FP8u3Q-w3oVSe}uPBE%DMV%N zaQ=z)b^oj!-nbBz;lg>_aPe2=@U{q1nJ}EKCk)9>IlRR}blEL0iEfk^y z+;rH+%@V$B;-Vbtc;&kB*4KSRIlN)DxF}C=c1gFU z)uYPc?NE!0@-$zU0_)ZGD~GpQEz0kgyPUIL$U!;Ss21DkSMSmQ{hC~ww$tyBkTh`b zAYY|>4fp#X%4jvpm4(Vg;v%fs;-nnjBny?< z!)4g|x-QD$jk8dhGF+^u3`wUP-WC=rKP+`wv0lxf9NuaczDHT;J=P1kDu=g}g@5nz zQCRqt%TwD~cvwgtu+W_^mTS2G=eb^wh)>Hze8%Njm*-qQ?eZBR9TAe3kbH#XDfTkR5`pYL{z397whl3NPf!U zttO%}6S-K=L{flqcuR@+2bY&2;wvt%Y$xI|Aq7K3nrTq!UcIs})Ao<>ZG9&1bx5dH zK|-yac9(XyR;`tU6e6TBAsrXe2_Z$=NJ8yHG6dR(tjQ9p9NruWwFi~!9<;sy005Kj z;IM)ubo~DpB#jaBP3GL=JNZJYej(0bGTfA!?9Kpe@!ahpKS@ns?72X+OoX+J2W zoULEl(0*jss~=9LeMqTeOj&7KC69hcewU5+UK<|Xg{m{oKubJ zd)hAuDbM^_A>|9Hz`QJ^vqCBqQjw4#dWrlKrx+oXen9&r?U%J*(f*_MpS1t1{i^nB z+OKQB;Z&{tmi9#7F`-l^r1NIKLgmML+1(8l zP4(8lk5hE-*TQ1jtscpeTcvzp-RkM7dCC=%D~q>2$8Y(?f&%5=k{w17C@JR@^$o|#%;f@rAt#C&}FEWcXNj;KYvncmM=5SRQKAyu60jamaCpo zrLF05h1Bw@U)a*Vu%I^<6lOBaOHDL+m~>Jk$7)ICD#6;hv&E(+<= zmJ{_0$t+*7Q`e>I*7fLmb$z;vx=Xr#omnSz15TH9mvv9*t_Vp8X+TJqh4h4w_}3vJ zvA{JUT^G`@kVb@bL;gv>G%BPqA>B0lm6+rPdG~qDrF-|4S5_*&nM11sHszh}mcJuE z2;b@#)X%mz-T7`o5th|(=U+FyqFa^Ey!Y|% zN7bi=^e`H9oDCtm4o7wdJSf+cNEgZ zmV4`6cG>d!aJ@lqy!YGG`XhQzRkEJDK^3;3fp z+ZuX{HFfc?!V|m_MX;rlRj;JR!B4QiwLFJP5p6wxc-DbLLVulIU&spX+cPf zLRvEW)w{HZ*!a1 z576fIgF@PPRh!p`?E1OB%hKj&Sk%(y^~2U}UO%cI+fJ$p{RibX@1&p9PuVT#r}tIq zXZ3UXdDZvyi~1$~vVKLss$YY0xAhzPP5qPdBPsRo(Z5&!KK=XO5h|p=6ViLk1wwkS z{7NM0eL{M_kUk(cd+u~TB%}`u=_5k=dm(*Prs~Ip#DMT|A$>wvo=+)#Qb?Z?($hkE zM%X3GX?#fkVY_+#-|sW&Kc;_5|8e~%^qO}8U3^R=k%Y}e@6ej{ssMK^`Fy! zUjGmJFX+Fh|C0X8LgM1jnHj@BEx+bT`izjC7t#wtVujBM>GMKjTHs#ki$eO6kiH@$ z1oKZq`l^t=A*62#>03hLR_NP8`i_vkE2QtC80GQ#U3C4ucb*-2=a+p8EaO9YTTKC_ ziBTS8S-ST?b8FKD?tfa-x4$;{&L3yod0B9Ya=O*5^4Oif61VMbxy2RMi@xSxv%Jde z5vviz@~YhZulf`Damv;=ZMn)T)7tXnw@LB;*7H!o-FN@UzaPG}Vy^WojkhmodC2tL z+_Ja5cldrXlhwV35GIk?q($3i*oy->V#U@P{aFy;Gb;7F@L6E2943>zsJ^MTPQ-OPnbG2F~?6Ct?L3vgO2OPF%J=5d-PjNA}jgCHBMZEh0>SrB>Yjn z_Wsp|MR)&Yt0!(PN>9A+6F9eOslUyVx9`||Gd%6xdI*Xymzm%)EG?6$pLk4vR0e)^P6Z`WJ5yj{=GW@smsO*!tR z^EDxTUFi@Xw>6y?xCH4K+U3jLO9yZN_#WkAUpKoKS~-?2ynm9Up0wuuC-~ZVbYGL7 z|Eb)zd|escm2-E$pd4&i*7^ycmQ&>ifZl&dy5makVMuzQp1m}$1=lD4L{p9mKlCw zR4K14vhy^wJG@}{mHZM_!%K!=?{nB)W%!-p_q)y+UN-#E@XBL9H~e{bgyFB|6q)Pt zw_ko_dAX|eV`I4=~qJfA0hp(kX{nfudxs_ zJ^MF8`mK184PQAn={=}$uXvylEGKWt`iTSv30warv1fA>TlT#78;2g&HD zV6b4QsBKGbWFl&8KgBR&s?52-bdq1&X8E^m(@V|Pdtck)nra*6hq;vQ-PdMhy$3Yg z@B9C6$5s?YjH0yEs!gm~RjaB+?N-g!reej2QB@RGd)KPjqV|Z?YRyW8R!Pj*2|?_C zyx-r?cl`e6JkR60uIF{#iE|w1cqOmbeV+*!|1c|Ax|q9{!u9IdiSwyXs>(d@>)uiU zxv1ox>5TsH_rq8-g5Ga8Wy|^V2#-T5et z>C+FPU#g)Dl7Y72zZ|gJc>rk5KD}hhnt}Po(94icg=95B*@%tqA&Jp|FQt&5JNJJA3-fP#i zyw{(+3pn9TXYkozQoDYUXhM5p`{(UE;q7_^PU)9&zn5hA`lQ`Rx<{yCHm-rth4bsR z3r-yP$f@4CpDBA{%l=20*F3?_MTsVdQsmoD7Yfi3#qFQe>AHzVtwG%1LGCo)1BL!w$YvDoA+4{l%Ljd98k@S%YAQ$X6J0YFiQ8?eG&7#+?;nV z@L+C?LV9f}cBRbxWqW<`SK`C08dX#%^b(B5=ojnYDW)e!k1kqAFKEe&;Zgxldt9rW>9$|Jjb^8K@=A zY)Ax>!P?!v{OEi9_*zjOK=J6&GNoSs&J!xd<=MD&xUu-s!9jV?_ww&dk$-2>mvt_F zN#wU|eEnLkdiTJ2tMJdU^2+pkn}6W)pUp4c2iu#$ zzEl^rFHh#hYs;%IDtzlp4b@S@e3lt*eD?D1-K@(;<^K@=;{NP<1%96q?VfLaEY$wv zJFKiBQT@@uu~rq!js@eJyw+EX<+ZB4a{)gbY$dnb_T-I!bSK!tHfqUhp}ljq(;=RI zwn(v0bs)za*%N3m@VLakhMf*`+obNZTS=5 zsV^ThIzBc#=_5}#&pyySF}F>%uw_3`tFzm;e-beMWnFL+@$9hHWbSaQxqP{RzNO24 z|NREwDEw%Cd0tDc_|&lHJi1kP`bU99qs|1j8oLoKn_v7z1q(VUK>K6&tzPk57$<6s~r ztYv2GcS`e74ZO81_RViyv(lHC&w*Wgl^vhA<_f%8+bHW4kGP9fq=TUh>K=NQ*Tgr& zmh(~rdW;29$3kOe3O>Jf-7u8rZfU6bv8pD<`g{LL-&C_PBk_OX+o!;CwPuf}SkV&%1<*dJPzuWZD%FO6f|KsLI3XhSGOe)GA$$Wb9 zi{V$jyH!&~_F}B(W4U53Glowqk1Zb46}A*xncG^}nln_yUK7xJ+#Hi%QmOgg8lTm;`O@s9&HDZKU4Nfu%Z-t@ z4O!MN*Y@RQdSAW#^Y%-!-cq3O>Q)Qqt6s?Y#YpGx404 z{x3xThL9M)SNLF&BRVOzZn*k;8Sr+hu3hd2MaalS*~7{?eOGEhv=+scf%uyOX@fUt zpq)^cR%kN8VnOd)c-12&b>nDOW{Or-pw>5LoVGxU;YXHon%$txS-w%>(JMjP zq%bvJXFjG3mgR3sz1r{g;7h02pyEjWojB{)^2Jb$P+Pp+g203c^|N=G*VQym$u+x} zF7!)-DQtMJ59p1uN+wu2PPle+7Xs|)=+cwQu<7m{QjKAkzV7Ib$P$Q?kJA-gkUttXSz-m?W-5opLk1K;UE2D6Qq`t(-a^&XFKK~m?F zKQz9xOm-(H)b+pBH9(p+H&DG0?6kVEAv3BuJl|62L;Pd1>Vq^vnI!j%@GDDT4S@p| zhgba3{PW~Xei3p^_5GylJ`uq{2jcU#xE{HvCdOI*o(+->o=hQ6=8bP5@rTS<97!mM z1i*AELwdn@s!o=#5>W{^B{Sq1kI}lqY#7agogh_H?$HCdZ;`|@E-ew^fcY1EJg5?K z#mkfw0pBjJL++hs5=<6cDC`z@4Wf8gchxBZ0*Y#_Orkm(mr8F)iU+w38PFjcy^a2b zv6c7FpYZ?HGPkP#*(<%3_HCY-kk4-9KGDfOKi=!MHSjuIq#}!*Y5b>IqwC`nvxU`f zl8;1r{|C}47_2gnT{+yvA7d5gf~lxU>MAO^z@X9@?be9M-@r2Q%>IOl^Kc=G zf_So#{|{hdJZ+Q2ZbM`eD8QktiwY6-PHxfod(L33iLffj}y~Pb=(|j z!+>`nZ^$IAxrPae6)2AG(V|BnBjT=MuaM7q76P~ikr5CEyau`IxCWDK;Zt%hc_PA) zJ%-K^%~Xo+4klE=uhH!kv&5Z(1&=s!?t(TlscFo0@=?jmz_)~A<~eSqdFq`+?A$-N zSMwGzbT98MUTE= z{Z1PkwFRh`E!jIptM)&B=qwvwVBdf(&WAWc8)t)lCkFKGQ?On0tHlRdI2&1<_OPjY z_c7Vv$Zr<=!kK0USK>P}0!C!QP z3b+-TLBt~4HaJG*^i>@9wl|!|vjv)_qS(5~ybX?4;YAxW{DlqiUEm7s zrPeXVwcAVUM7U1o@h%xN`IZA(p(QF^J4rk3iznMlo5qkhKK_cer|!X-*}TF7@Fj32 zkO{s4C-goAhhF35iyer=;o(@k6iE541#fIz9PT@uaQL)d=`9B^Rv`}72h9e9w7tcd zw#`7}Zvb|Sk!qmvI6%n0gT%f=H*xc9a@CNY5GL>r>5eL@6VP!Oamb6EgPw1bCaa=8 z12#f>9GSqI#A3P|pgIaYM{6eUIn?oP{(^P}k1v8y&4AN`hFcGX@VdELk&_U3rva_U?zk{w*iY7^(x@Z>)z4Imnk+|a5zv8yDbPl zWCk5mzW|aZ3pzk3@_|qD1hGrhAf>w(icuUqDae>VdZ+{DmM#4qZ1j(O?-^E!ke`#F zEJ_gii-^qu(OO!Jp9lBan-Df=!d20ABB%S&-T8z!B3%nKA|P}$vHqx=k43Vc2j3|N z_cEL8?g~l%2gFG%JZ+YEW4E0HnliZ)^_io1Z=tJ?ZX~2ANK$quW~FnygCqO!?w*~) zl1^A>Ma{p3>l|?c<|Ns&Ntw~vGycEEQJ?;M!lt9AkItrJ9Q4IBs3gd1eJOFJbFFC( zwaj4mzc6e(H%xLVyp>mQoI)p@yEbT2r30tyrMS#c-hqCvkh>deHs}4F+c;=3an5^y zdm9Yr@oHIQ2<$*7DFp1ts?0g87}@U;@5dh|o5Qp0bX$6kgbhx{orVsM{lAzQjU$S8 z6+XNHl@IMf0RA2os_-?U$z#8U+;#iV!3?FJ86hn8pz(~O zZyYAOPR@Hol^S<#_GNSuzHrzdEc~zwok`$3I)S;VD{cXXPRBd>n)XUwK@g+KzmL!p z5by`Sz0ym~?n130-anW;kI)tnLOLI0InMs*vzL0yGNAbs^?}c37is|U9%7O>g56MW zSpsC7qEh+Pmc~?5)wA~o(#~+t!$C8Ae7ggBXKUQ&SAG<9oDTgcnE81ryH%3{tjqQR zATT^QS6y|qfOmCX6UBTEj#h8N4$z(A$immrOy=hf%l`plkgxg__Pv1X2%4^L`QHd& z>H(1T+$I?&BBFyVQ#P&ZOS*B%@S4GbD=KBrfErm;8s_kxVRDsdrEA5Oqb3Y5^VTEp`P& zj{B~$(xegmfIm$9-OSB*LE={~=s)sNMj7#+bG{CzS}2GlxbRz25+lA{LyC@~frxMF zkaa@z?RM1EZU% z&^4Oi7gsW=BHjLl9kaFv6q~?*D)4$1+9OR3{1+8LSLB$AS2Piz*VzEmJ$`rIZ(UK- z7FSMa$u{`w8GA*du<`t%0@MZ6|GyM}^&12_?R6!D?wPtK<|ef&%R05hSPWQLZJgFl(Zt4p7Fm@ z(89G19%#Yiwww2a0jH8KdQFw0B+j9c>#aF-LR%`ETDJ=3DtB!&YQkCSH@$8S zi~y19;(NOTSUiD&&7`_m-ZoP;K%_PRM949emBEIMV6*K28ApJOHAP9JgY1>JR8edC zF#Jb{;D=In$rO*TgTttmqTgu|5urzYaXtDtMoK+;yj=_P_c}s6x-I5G^Obe>yMOOi zSW^ek6?tAq2t{W{KS;ZB{D3;_X0+PX-VU~?v6u*J>N)19z{`drJK4ZplqEHgj8<3Bu$wMb~F>7j1fzi@Maj$d&&pL-fzUyK72C_}m9Ae6)vJXvobb zTFR0K$0j$=MBbmX-oK#HX(+2ebs8+D2AM-K)btY5p&qlHU* zP)}bPTZFnsn{l`Gkal@ajClQ@{x+me${*N&rhCnbQR~OkC2rmPr}#)IUwX$W>PvYt z5(T&K#b!vID$>7%R|H;bi8bAjl42+seTtQSJI5UR6^?p(Eria|{^=4|><9Ru%-ifZ z>4a$09VtAJY89TT*_(MC-lc=md`d96))I$Sed@rgTSxC0{uDnXb->;~*Nbww77~&h zGB^M`^&wytRnL8PA-Qun#n})b1IRcT473jd)~cT~keP(!&fyh1lt<5#;aiJXf@0^2 zVu*@yozm!eQbYG5_DHcqiP0;`uX7QBQj}9na^a0r7(fsEDEa|;_*@bf5zC4oCF9Wn z^pMZ4nlbT)oF6{NCq7fENnd-16o5(5&>+Q55uiHlKyojx#QXGS?i}x^T5l@{HQ>Vg zR26s%ACd-|(25LrAKNR2&<(isqV*J8W8kaayR-w2y|_$o5D2&(n`Wk{6q9D6D5Ix% z6itjrxe;_CmU|Ro%m1Y|Wl~|0aRkjNmZBxsVT2WPN{ofuB0bBO@jzq{O`(+Avte9V z#ftJHGs@$SW5ujd$f;^dszv4-KR~h~_9;}CDX!)57f>r+BLxUzTd0rqDU{!klu(0I z$U+6iA5hG`)zD&gy-6&3M2NtIGZSnnnvzgmYEdW0)OglNh=4H7u{}joa)clAaQCHV zyS01TslgniI|7&{W{eiPkTW+1#Bp^_ekhW zf(0l5yUt=CuMJ4S@Uz$_eM=L>E#33yjrSBp>kEP6KMv>=$KS(q>xURdexHl5wjiPXCa)|OV$nJ6j7n3@ignPP9y^6I77Up>-EF}s=%q%=1r_*}Kr@hP}M zOkIU(C6js+p6IeA9u%*cOza5t+{#vx;WE-$Oqd`%lU33)>vr8uid~#=mF3zj9+Jrh zmcS&km1MJzbf&PqC$JJ+$?;TUt_Lh5n=D4g69~y{C4Qo~Z1om- zCa|};c3u}jq!E&_Cm*i)CZNw%VrQSBp3n#N)qB#*+^663x>Uy-biNFgUEe!B`YsNr zS6_rZ{fmAg?11o24f?hO()MeLd|LBKFq3B}Kl;$5r?tsNNzw>us=7C@s%L9-pRr2p zY6HbbmQwMQ$(HNa40s%2LLYe9>6)LR&2P7SOSQNin%tM*h+D8c*16sA@HXg8pPAzt z$7lQ-%V3?`t$j(XOYC$=$4ZaeM}5WJmZAI~e)lh!ekOR`J`DQo!1kk>O&T+vg14fXOFCh@^XD+bxN@Bt1NG4?*- zP~e#uV^}||J+0b@WlU`dHe;uIF3zFFwHyb6pCb1g?)S zH@=){{RTAx?Y~~-mF89JBc^*#K&nFdoVR#wSjH{L4kc(%1tjt`gu7&R`8{vnlSt5D zZbhD_Xk6UWFr#r5B{jMcVMcZ)9(l^rBOE!)k}1eKc#CvY1jRu^&>_pcA7MsAa3(ub zM*pCIyo=ao!H!aR-zUgNc8j2PXt1{;^V2lCXb6^MN+J)&DC!Nraj@VB+OjE_dp!l( zgtS3k7F?Wmu!BJMz2{8vs4VLI_=ti*VESug)(JNOWus`bcvsn!xy->imKkdSIg^j0 z@xwgvOZkJd?_Zk;EwIKDv<1%7?{&q`ZV07%37nb;%?qt(eGAQt))VpxPm2z}k>bJ< zBn;aV0_SV<>LcW6#_kDZ2>I$Sy zLgVzk#b`Wl!1jf(hGb(xy9NT?LXBAzy82j#^@`NBA43>PP9}qXcq-cF&e09{-t!It z&boVL3L@~%=fjFwC&$ls4ay`%CX-XqVRxF0%DxU^)i~!ayMzz)^Oxo!@Y1Ti0dIQl zpK0hhgwJ{2p_UbzIwTTf^ALM{byuIX?y9f;^^B_^aZ1ob?9SDed@|u%4yjQtr4LH3 zI?rAOsZ!3B_OxGJ`E(VW(c7s>n|<$k-PPUuL=Xz@s@+;ji<0~pVL=NnC8G0YjNN;( z!-pt4+F66554R9^D0l8Z?b3&Zn_=g#;#1y8eq5ld1e=P6NwZZyoNyD(yrO#RH;1GPc*>r#yo;7DoBCNxb%+KKRW!_TlWZbgYBhR7Nyycditr^grVrZiOyIh zD&u}_mb)=ifQ*R2^%g_2AymN)E6m_! z2oL|+b0;w*PPXxV?1!HiKeu6jH_S~2p3=`5vcC7U6x=Yv3`XUj3uLiI$K^86k5EVD z4kH)m`vc^YFzLLkTX{+fc_$J6&gK5cFO<*Zco@sz-Tuen%4h0%*=0~KWyMmcnew$# zsOf)T_J=+=oGN&fJzd z*c&qEzhK|pUeC}u`!Xn=aO`B-eOoqbMD?Eh@9$;mPLl;+vQ)pU)y=qy=h%FKiE-yY zFA&7OdAU2d!h0(R^kqnpyL*tT?j`nne?kMX{qmpj! z%itICP2bDRrz=GPe1k>iN*Fmn=ZX|`=|L6HJzcBD7iGfO^c~$L1V~&i{0ww11iF9F zVpLjs2c7aAw_?oC(dzVV(fjfAV2m^w=v)k3PuH?jLWlwQMoANZ^@EW=ln}S#T5rcG zFFz;;9&0m(DwDIjWIB#T^l|PgLFeja$yA5O?vK@hUIj&VE~rgT#Sp5n0?m1s@k37MDH;4GQ8tP>bd0U2cLqD7r5wu(@vY!z z68V`4&c3*Go0V0Yd--(WR#z1chUGg<%e{FvEoli5-w6|mJwJ&C<4Y-z>8!S5Kp8f^ ztpy`7Dy`lk99>HbVy$2o37a9EjaF|(j!<9peF@-bACuoi055Q85u(gAx)N!`JH7M| z&~ZWdEl^7;z-14+4su=sxu@&Z>|+01T3&nDEYNoOUgqjv#be&4?@;w1tVr_l5-ba( zn5H*BR5HI5X~7F#CSrCJiVRxH2Gm_ncBSb-j=XR2(eJ^e_?U*vEmqR()sB{lP?auF z%TJbEfm*9|WhPJUqJz{xV9ig(A@-KnhMO&$zN%4ta?P((Ow}=2-?KvUy z6({*Yrf5&HN{0$14J(c4vg_fc#U79y2a_Igpf)J+jbsO=L<_S)>0qy6FR4*ZxmikL zA{J3XNw6eUEhp)|*<(hBdQPgu{to`6WB2xwMRn(rQ%56;n|?_XCfdS`XDxa6*^ z?Y-I=9c zA;!Umx<6)kCC2;fJEg*Rvu`lTI`{#B(^~3ovEH3G*m;2#8;azFX%}Wi_M##I*+&$7 zZ+gB^OK`xJ1whTOJ2{H71bjuwx&;)iwM}jc6bS6zB4SGN*_nX5&9`G62QzK2+hjQc z^|zu*`W(!L+xlE9uGzdnyGjGa%eMOvo!7!|_1nLB(wwQxUQ+Z7)O^j8-R#G+`dhks zeM@FCcWeq~#=HoGDg^4VU*1fmQqSWpW)_)-E)7q=K_hFUq_YIzO z69Fs3$58-9OGV!QY&NL0pK+v6dY_`jDPzv5^ghyr^Ym6pc*f^foG&tn#YV50Tq$%gM^a-Z#7=RP%p>F5*3JcDl zblCEp7Ms}lbXcixXf1H$gUtNO? z{Lq(RXPmUWWSJ!5sQ-!f*oyKngbm+F8*J4lNYPYVILhW=LkbYXex-FMUjKivj%^w*;sAiq4{`-SmtLj zY&)%@X{5_z)Fc~NhjK22@pKf{?&p05R0hSZjKcQ)hHY0f&qgErl-B)|T2F!C0PAP+Qh!lu=i@KU_k&Gt zgte!hJx;a00jzvJS|c=Y-~SQKO1k&Q)F!csK;o$OSi1LDmQykG%NxP9!~#*6%Jt|W zIV(83#p_Pd3)al+@kW|?nIbpVZ22N?);8%BXgtu+8~F`l)JKu;5>+Cs=w7@nJf-lt zD}LVyGac<|NJyXwm-dl-CcweknmO1P4KZALMrJ|GmuxaC4Z(#p#*bhFq6AAyzABU> zo2A9X>L+Z6Xs{+*XW<(=3rge1hz8p5s;Gj;h{iYB=F(6LpPfg2VRzZi?PKQ6BlUg_ zP_GP&2K%vfw!WFOiHWVkqC|Zw1mZ+j+C+mNvF+FPLaQe3$#KGRhW2l+j?+U7NckfUA8=oe8kuJGy!s$LaMQZ0zk2ex z*3F@-hzLCfFrpC21-2s}dr`~c-9#nv>mN@_>sQYqS8yo9-u?d)_;OgeMzdTF(@5os z?Afwvv8Cf}LYe*pgR$CD<0eGq2jyQ;TH?o5#FGe%VOFx12E^O(otqHVA2?gsyAV-2 z;$iLVqllk^FV^%gUEU`@URqr0Ohf#Tyve~l;qX_Kh4`EWU4}buk@yYWIy``2&m&h;QWk)( zRX%vAXS6Y`$8%Bv4G942^K?{|6da+%zlDe2cRtVMgs_%JPjs1UAA!r2-6Ma zV=JDviW9X+;q`HCo{$Q`3+0Y>Mv1ASIOX~}XhMMZXU5r$o-&I^!I-T>byykXBFC_kiX53vV&Q7}Najw=lAsODAswWzfZ)>O^?v>LpR zuM0l5P;EiLOM^ozE?eaLC^IIteQXymUqzhBKDIL7?m=?k6uj~{heEYFe}5xuU<3V7 z^%|l9qN<3HDL|URgTeMD7oP?WH)brz{fx=y<|D@BQ5g*0-K5(JQme8Lx~T|=9yRWY zr{sQ~^2WVlgbqid4A@VRX9}pH%V|m1LO!{kDyn){6r>|ESgxmriZHJ+ z!i6#>pH@IoGN9%)OuP#9dR!x z{tp%xM-VkxZE|MK&g)ZeT@*^voI#&B^JIz9|L0phxsr6}ZLyZ>Pn3MFFn|@dl)VK( zmSKf@NC$F<(OD7yWDXg{v89w#37g z~K-`Fwvl>vGGaox^8C<|^vQ=~l+MRD@G z?j_qE;K$q>Utz8Cipbc3=Asy4)HQht_1YSa*&43j8h*Gn9J_^@8om9C@$fg=PG#?r zikwGz*%nw!Wo|r)_c!kItH$vp$wsr)0|N?hliBJktUI9Y)%vV4WDDD!H~Txw?axqU zUOplzuyK)R3(=gXRLs-y8+Z6>{ZCSNQ`!rFrT;s7K-Vu#PoAZzNC{=^6r=l}$mUlO zW`L$GhyL{*17&OqBYuMP6%Uwrto%+y3C}Dt;K2@KXf3qU|G^<+XbsdV@LdQ1xYc6# zE)Wo~t#HLvR2D>}VM9Y?hGo1oTWY28RPw ztDwBUH239w6y>D9*zpIakS4PstQ*9b#KZ z)Fn^_9OEmLAUv7sQBkf^3%S(-OD;SKvka1LXdQ*)o&M%F zbb!MCR&4WM@2}i7fhGH%p_SYt0VS=TyBlR{e-Y)}JKCb3coqL@sc&ogsp1!S-4R-e z!6;XzE(AGWFm{4h770I{&`RDQP2hFz__y2gfax5e1yKb*XOdfwgsLJc;<)xv8L9*) zCOd>yUNELl6;TH6PJjegK9)SJ?ZhwGZLf;9YuYGz6^5uy&OO0?R0CBqLH5xZVc0Gv+rx>F zV{C=m@pC>q#6a#Zy}GrMvM@wrav1(|vKpw7scRqBtVWmujE2pI?|&{(3trXP?H)7? zN;wZ>eku7gL?U%ZrzIfew{5gF2QD{ZIFtkTJzVdAjxfnPh86QA?N81h!c}BCWYX%gATP`K zaweczHY35D6Y-lv%`qR)$ia4*FjiZ(6xU*>M z+~~f-JNMRj1cP|XJB)!5fiSOU()6G_xq`o|2_YN1f_U_owQ1BT4 zoD8)WRpk$Uh}vKnmjCb(lmO#bb$O@cc4eFCVY23d?CU_r1G7SmuHhz0zr-Ili+8{_&KDWzX;uV|#(kfH3rq%4C8aVc{2_uF zGO5oH$&AS_6gEK_m_+QeqK+wKhS=O8G1>N-dKTkHm+6Mh^$f9Dq-{~T9qE7=W|j81 zH40K2vCLNALH5C-XNk70wRgX+5GO|1WeXl+J3eQtZzYqr7(Zll520l0x;x)}sj#{S z^I}WV(Y0~o`&WKni%Qd)jbnlhU3c!gzRO*_$`m=hvlxQ6XC1Ueu~gMQPVpzFhZLp~#5E zI`u34dfXVowq;kkWAAj{`i8OgPL5q)MqM$w3b7&D6`DTxip|)Ta;FNhDcaFSTUSeL z%;wlQO?A;8$vjZch`*sd3dGp?{juF!7X`EK33TGR3uh&8TG{G~d&slw}s1{f#j z9+1czoM>eWwMJI_k^_e(ss$x-*_P-cF-J%Y2eM*PPBDam$4;<&1F^1r$cnP##`ek?sxr{)RVJ8Pt`;UI#DQ@Wv>&+KM!9c%LZCwHYVYPKsR$j@wuYfgAfv!Y6ujW+x{P61Y~`5LoE&{z?8L5ASXd@7 zZel`epHnx1IGWj|_V$pezR{n!Hdt3s1T&| zsXP5>a^&(g%cKdje(Z?!x@s)@0Opn>q3>Fl|67QX?ztnz`|T4)`r5zW6ucI+6wnh! z&ha@*KCGvnQS(OI>0U(cw$UVeemOVlzzFYsGs3phKonp*!jPb}jrnMU%ariKASAlSagswPFh5`=UOG2Rr$Ef z8QU#lymCAG(T9o+J&MOU{K+)ya+;mOgN|fl`TTLjg4CE5`!Y(|Xk0}YbxJ(rj=!14 zH@gQe(h``X5zdN|k7P1Lk+b4cXb9@Wb=YwlMR`(rR(vYW_H_}=4OYSuh2K7fRWAP+ zO`{^IE-M%&)G#Epw<(k_7w=-!TczJ3OQV)XbX$#jbM!%esa^8=o@q<+`f6!QGBmnG z3>hlYBnz+INJG3xwKDoSFN{7CIx_6t(C-Lg88Iqu)%VT*Rv-*>VF?vp^GaKi)v?dH zn_vbeem$1mD_M@k-a6j8%9YtGSXQxk^_nr-+H6Ug($fqj8^dRa2ACnd-W(gH{J9D& zD4sWheZHkA-*;P%a-a+~eU&TkZi$(LQXfc8N4z|f?ovHIw6#XCrdzuOWMj9mxxFXl z*t1(5&)>8@eG_Ihkz9t+Guw2d%`MNQET5hfm+I`B^-jnKC;Xl-o@z#2hpCucxUh<(!?v9!Blop(QHq}8n;2DV)m+GKA z^n#sPJAf0~j2D?F5x!i>nX!J8C^?4JGW4u$kRklUO)4k$a1smQnk~y>E5pUQbv(^h z1C0#JCLWaICftH?vd0DeJLMBSvJknE^K#rzw+??U`m(INvdS&lb2%*r7wjbGsef!M zB$m@XHpu&uobJN;Bng}y4pfqPuoCCY0%zh(i#)lmjDzg|0UpB{8bFml5-~}y_}|#i zF0zxsXK{LGla~Jnnh7!l_-#97I^zbE?EfG^Z+IcSVOXreL5|M+UtoOL&&PgxIpYSO z)Mryx{V$w8Wmg;NB|bKmdt>0qx`M%(^V&p%w;MaX3wr!EVI*q)w%wfsH5c5^Ct%q) zwbk)epsG7#*l1CLlE1WGt>18nA7K+{rye&j8aV@$837*WI+kUX*pH^{0oyViN3-FA zE7;dcE7eM&VM(E@gMo}*Iq*L#6&t`g`MBnhiGfwr1u%1TqHbjz2MqI10uxU^DkF>UNOFm)Rybc;ok|`%@^4TYE(&P8iRKS9b-TN`Fd%4h@gDsF|*a6;4#pg zx=G^d#1d$F#E(xYxnMYL478u82iQX?G9~RH0`reY&E|vFRg$+?hMw~B4Eyag_1ylf z!DOisPFLNj5ynxmL#AxWpG@iAuHnJa;}-c_;=v!vQ%BMZ%N})hV*TVfIkQMe%o&Wclqqr(F>yw^4y~hyc=Ic5EEwFmYs0pz5eV z!=6CHntXZq(gXxJMXl}iEA}i-j)@V0vuDgBw|{nX zz`RqQ_%P?)UU-q<`aDJHRf=xQU})pLEsk5m0w+&CZYLnZQaW#c$dd`;sJ}k~v346s{@Z>Ji0Q`q#u0wm<0P8~o%6LY-YgNp5dxGi1;EVWU z33RK$(pPOy16MFjfpu}M0V05A^8;#z1!#S@-JrUaBFO;NqRnLcY~;qa%!i6Cmau0u z5OK864_8Odjt>*tcQz!UGj}q@&Hgy~J}C4TOZCH$ z4=3;g`r=N*$Q{eu(7rpT_Ra^E{f-~dM|V2?vXowBowphm*i$7q9iQIW8@MxTUGzb0 z__G+kf(_giwOaRJjBVGtC|?ZYOKDp>K4~$&OxyK^?9jZ0$tp6C_}NSK_<%*lB+-k~ zwLs0$a@i8yVM{T5F53!{4dh5UW?3XG=KX?l%(iH!dV#KLxb{-uEm6Dn@273w&(=kc zYI>UJ*4RDlNgn%S+DBUdC^0+DpxPI?dpdGj|s#Nug!-%(oYH>pE|O2TRS=1 zmU7e*8=#kLkZNDa&ikHK!}V2os2u18!&cn{U~2q=EA&YTQ+3K8u8t>K=VIexsIoaGcFAIwXjJ!LPM-VHLQSW zF`QSY%yTK#Oq5LZxHE{?nhwahx9Bn;E6`lIR?x8*;C`t{y`kd z2hH`Hc`a5pqo+MiH|2R>anpO5P5$v-lSg)r$ayUyn`bUO4#IPI{kWMr6XWJ|U79Cm zJh0P+3ZCQK3aA~bxz@-o`EexG@o$nGhWZ(YM23TA*k*lX|E2lDv#^d~rIrET6gl>? zevBd1kGyV1!=8IH0NUwUk7NM(Ks#TazAKdHMmR%HzA+pRQMGy$el`L*ryY5*o_iM8 z8Er%5Cy>HN9;x%f$G<5qjPOlEa|)F(VY%s#R5=KTG@!!7FJXdL@4S(Fy*`8d#FX&w z-`W>BOJ8Z?6tZ4iuFai^mdWvUW)1U5T};Dl=;QZ=fDo22)uRhJTO=-V;KH_Nr?#`r zp7!kk77bT$Xz^%~vFWFR$I|n?Jb~52rt1O9FQtAXCWSqI%8vZ~wY*+yaaiVNrk_JQ zwkUawIhhZg+sWvhw?F^1Sf@-t;wjixRl$RkKi30nE2 zCaHUUHaGxpI&cyYTs`TS2dpkxRL(`>tn#djlE&5v)y7l^bh$C()DTRWtKv}Ae#~os zdn8!FqO-klZnUC2J~*T#e{B7zdhXCdH4)L?y6*ERxg@yRr?a}YIc~$PG=F~msD@X4 z|GE<&;^aeJpv$j4D`KyFC^);iyeOFK6VaJ`aOo5U2WqIQQ*K=pc18K}S7h+>QSLJ@ zl@P<;j-NAc4`yXv4=x?V}5!O0bEf2CXVh|~WE#ny76weY=gh8B;W7vy%_vlR^fNd8Ig z%~dLuTFrcgaclBUIqY3>N!jT!hI-EkKl0qmUW44=Ybrmv%&JIsE^sM~(S1Ce{4F>5 zOC&q@tTls_XT%Hga0Sd~%`ye7E4ifn!dGTU;`XX8uj@Dal6g=3pG4m;6J83tp^FM) znQ{xY6E(9p#kkk1P0i)Mf7g97bI&nx&h4AMU?`Vc^+c@)-m}EU&3|65t%wVUB=%&d zFtKENRl+v%Yb8U?7HK=R!VL;Tk-|#jOH>}dQ2C)jVUQfUQlDLRC`wrQMR9}hlkw+B zd7KsNdG_Y-e+zUyO`MV9%Y`Q4P}!MQquoM%UfCh2aL~9AQ?}3Ku;0jbx$~*)V)+wc z*&or)S-&>9i?fol*D5%LN2J`!N}QWR_nL2Zy;x~??s^fcFJqW}R6FB=*pzo)X?4za z`#PWf^e26qbFS=<80YqytkvwG+Pn~5i7q!Vwnsrbh{GuJ7o37uLu zf7w)JwRLQs+1UH>vH92E7B#*d>B&~h52~tP#^{R~S_M_6XAtq&tA^m7PFLewlpbsS zbc(h?-pVtiYUvTTYI7_3?^#lhj;Ve#56~~?!><#Mxow+>xM*&j`HlM3uj1kR$;h|~ zUBlqzvHQg+zj>{J!Op`UK~*OYZW^kR=khAS$%lSLH;kx`uc*0AdgVa=Y!w5G-F-caJ=N{H9sQcZbB2WYtwx5zD%k z=qf5IB5eZ%j6eb;fPfHLx+S4Wk!HZMR#wHXuy)smD+nS@WnENIL{t{3HV~y3Q4smh zO>UBVlbe7q@B4kf_xtZY`&`K>bLPyMGiT16Idk0he^YP7Y5G;$Fig#_V&iMhFVl=V zRBkQV*!j(lon7aho+X!9)m3>PFs*A&c8aWPEuu%lUuEG>=V(1|donyfuw!+RU0UXI zcT@PhF-fcCwnymvSmGX;rjy~MQn%h~i?@zJa-nltdr8^NI!T}<1up&*+@k$V+9V&*(kT+pS+Vh0?T^a(XdX-jId3h8f8-J*}Hea)js z)W=313$#0X?9uNDkOIjmre{CD=O?O;PirkW9W!TK?aQ(`<#q5$yO68@Hp)d+IWQ#c zeM#)+_j@9p0__g0sJYFBw1SnYF@g88=6ou04_bHYLaurNHQH&x-%}YkKK@}A7f|eE z)j8?i@a>ORers#BoEkEJS+rF!^~H!go=^0~Y@&TT&8k!Po$W7>y~QeS$K#c;gHF!f z`n=j|9Q@7tKz-xSinQvYe)@h{sMU3wI|7Xt5gP}iGLCrtD`_c8vjsoW$AAU|!ef8#`zH7&h7g`=M zf4(+1fB0wZ9*#~*+VWc|YMEt?b!TpIGy`5`9@%oEseJ7&&bZoVL0asf>?E}_WpnGQ z-g6EHW+#>GeDa{M%mZR7!MP-&}q*E@gUsN{Uoo}=_??#>p& z()wRY)O|xWE@xf;^0I#4;`&$DV@e+QX8hHrTEt!v=^L@D#M8HUWDa+Hmap^H7Sr*8 zzU?6~YRRM&y^h)f&lvsC*5WC9LxYn^&U(5f(VY{<)g3RGP}+VoVT)a;n`_MYcW9xL z`4hrF{5@R{u64HS`P3Bz#bk!ImKj~pOD}Y_YJ23OlF<70nrVVw^9}gumQC>-*?RCw z%qYKkyrKclqpnV+#1H8(zq@mz^T^CmBOA@GyN{e}esk`v{2_~{dChrIw=rcD=kNf= z^x!3v6GzS0IeuUx_m_>1v*5>d#@%}Z@_+DJ_@ZR;WVKPPhko4&klt!Eoi zdTMRDbH;~v33ibg9q%{!Ce+s73DQVtZ)RwW5A`k2eU(wa(YfnGvsFS|XvpsewydwY zRFsyH{ycX|MtMV9L_&M$8}o#tHJh9?xUIJ@sd6$t*6qvB2z$J}+If6?M9o%{(7G4S z<3@KS?BP5GC+ttt?rG6Nr+aHmbGIJ(kQT&vJo{Kc%MWin9uK1}p*^NO&e(fu_Ycl{ zgDqYz98z+s;n^w2c|(eRSh3f~;{Lr;N6l|*4XOAcZ0}Xwt?j4Gm)&prA#&=AqZ|FL zr~Tk=xF>LL^Q4-~=J%hT3O^J(kY@kuslBHfc(3F3*wIqs|D=p0(Z>G}GQTEts&{$2 z-v_-pB ztt9=DGhobcAPiESUPV&F4k`oy|3G3B4U zw8CT6CboSl2(pPUeY=+(9%vY!(&=@UQ4tW=qu{6_o6ek28DBnJ<{012x(qOxGIt!WU~vA< z+aBMNPA>j#Q~r!FlTWKz@h3aRKYev>$9Jb+S%t+!#Q06w@zU#C)03Qo1r2r|?wwns zw$JKh(xRb9tehwA%RO1Y->SXh+{JPGM!1G8^oZG2tmwKx-{Zip;?u5yHxI5E>))_5 z#6R!Sf~r8Zz584OlP@Td$?x(JZ(0o7MYTAXCx#GSbBI@Pg`7+SA)KhrFkK$GE?nrahm0 zVY++UsV~#&+G$-D%|$<`TO5vj9C!4|t5XxEw`q}Gr@^OiTHPU9=g)hxrny(0T5;s{ zALK)e-aI@Sl^;9o;O(Z^P`_<)XG&F>^FxM?>;wI^AG||XKIhVNFM1zZXrWc; z=k{EEZr$*M`==CE9sR3iLEhO6y|vxuB?g7{PrOsQlfn*F9ryk;f9>V5OAh~`>XEm| zHF<1l{@wRuJH7vkILzzb5#sT>c!%zhCiVIKtH-7k?nsZ@?X8(KH#@9IMn~=GD#$0H z+U@aC^TT`9{pWj^XU12b4ADQlrCN95gA)4|_UX4?&YSa&M3#SU369^=qPwHoMOWoj z$ancICAxa~g)M?lW_4Y4NLYSho32HE3-Wp8QeLK;5Fe8AK0TtUO|$#-#E|(7YVH)a zQisE%qNvOoPeTwL+; z!qlt`H^z^tiTi7}PW|G1B715|+nzAzJ?>`gh$C|jy!E@Mzv4vh)I(R@JT;EE+H*8= z>)b< z?)nwaUS0jN{}b0fGWSE()smn?)AxtOjd#A(_<378n{0hGV$&Gg*ZJFyU5jcTotW_P z!-_vrY(KheYPa*rdb!{J1#h&o^KSQ9SJzPgx_I~C=!#LZGN;=%roDVHy29|nN5?lc zHulNevTYI*I=(n9>^xs=_aJi_<3fc0z64uuk9{7tHK9+-9ZuKTge0$eXrr@wbk&7& z=hLTczBryfX|u%Pbh}NtLCA*`#{)hrh=iODJ|--N@F=9~XbA zp!smdAA_nFIy?HmN(kR_Rj0`9=C~h=R;EIw%GAvb3&Rp#gg&rHs+#7InDQ(ng(;b+HYtTHKopBJaaEzl5jCMc3omZOYV*0q6e9r59_X9F05H{>~cx_ z`=lhgncy>@ldY!KShk^IP?%QjHs!RRvoHU>p>A{PqS9k(RnLDdRP$ROy?K$BS9Ii} zV9)-4sd1u}zvJHhRoUVBZ>N>VUZt3-%`Mwdvso=5I(|{<4{GU2x6IV)Us-%9eqCab zSq7g(?mO?o(j)5;V^*eCncI=DWSZlVfNM0nBUd@hXKP=sWF{Q(b7fxUKE8EkO~uAL zXF7wK57*vLwb0`{pK?Suy@vDm>uVMt*EByrb0oO1?20#O&Eqmpo!SF0O<0>oN%8hMl91eX)grW{qxf0= zA_ljmH8~xkItx;a{B=GjWixcH zt|l$gxl-)g*>Lxw`ll>cM%~~u>Pt#|JL?}6@6x+w7TYtaS>G|`v*@^G9f_ziWveK@XzA1CcEPGSpbg<#D{{e20FS4UfO|Lfjd+*rL z=ARkPwJ+A^r+k_a*Pfm-JqA9WjN0N6q4k-1IzKZ^8jwFL_1cRj7}PZt#+5#QbyiO& zBqsC2+to42A6BMq$<>P}2SBFAvNpFY+_}8^O?cYM ziSeD~rR|q8W6p-_))hX`zx*M5R^6*r6+w?PSCm%22rrJT)KYQFRy;O7EW^)eVq=ct z3YD-~>IYjkn5y`!+i)bhftlocb1&(0@XZ*~)tZ|-NFKVsZSDUlwDODgueV6$OK!eV zESpf-q!2?&TEBGEmiaF%>FW148HEJTP*L&z@Myx~C%Ka=#y|dZa@P3f*OT+dKYjSdb7JicTcrxfUj}4X;S2X!YMjcgU^^&=|)xWt}yUpPAI(h z$N0r(cDhbjygxFhHPXC0$3yQ-wrhiW;hjIUV(Ri;xf5PB|9vDU@WCIScGa{le>kD= z&b&(#N;Gql)|+3*S-<32;U7UUb@i^F)eASx2wwMdQ2gJ%(*xUwWW+sszcozn#kEn+ zI>)z<8XI|g^>p3Henq3M#|5_-XU1PorH2{BKe{ud;#lQ7GT@p^QM%n5#Q0p%+$G4QWJC`Va8bhnXdE)e)ogUdT4KIAXglF ze&DQMeXIwgrY+W)5z-x7!Egy%m2<2)n{06Ouv$jK!@RJn%?6q+V>6DXsy%#NdMNW@ z>$)=un&HQqYR1OIrIH`k7HM|q?Op4+Aj~A&GxLx9+E`6zy+r22*PjomHRbILuIkb} zl}D}1lL;$fT5@B{u0@uA(Noz>+AIW4Ix^inGFGa@-z zNLqve{0Mn_bjw5cZ0(ApEpOrT>eYDFYM;e9cT(dX`tEL4-@k`SUT=2qWNfKL!HR8F z)*tBG9x?-e+V;>o>DW~!({GCVm)N|e_jbgV#qY@=hn)^PdDds&%M0Ja>Thtzj(q)8 z>;65{$z7*Q9mx7in{wc8aoFy0z_F?WNfD0& zmyL_`E~n+*Oe))xZ1*_n7<@h~JIsarGw+ks<2E}w%sKPMYFxf|r)J$Wi^7n@o`oOG z9N6C4d!E_BKTDPsT8*3Q-9ax5axghvcPuGAD|Y9Bs;mXwr`M#%79L0nB%jsz)G%_( z0l)6Y`WdgA%sz$Pt2ppEd~2cEQRn-w%-Rd4IypVpJ`niu^f7<3SR%g?)1iSSaw~P06`Q9Pf&9Axt z<$6@n==96AIgmHh*>3UZhJ^XtZ@GJIxcli??PI$O#~$@8 z-8?qTv+>yO)niw9dd+o>+o3(*=fT^q!^0CoD#s-keatx1usXvjyZ(7+(cBwVDXTjx zpQ{xuPW!9)+L{}Ivz!Z`E-b(N>HRovQT_4N+bhSJK&j0IZ3lw9YBq1?r2D#Nl|L%j z86Ih5sud70C4p1hsg_uAccWU+pF7*RwcFKNDh_sU-p^_DbFEU-D?8X}!q8fOqo8nS z9H%wF$VlsI^X3Zf+qau{aq>a|M04{XtCYp5r{f(vQcpKIrgcBls=QRr2_IE@|FL6d zBVg zpwb0Mz5Q;CZBSA;saxlHrhh_tYEgq*{;~3Uug>9|piEl*+t>8X6$SUzFO?R25F|SX z`F(ykA@$^5o%NX~tKn1l>ez&aQllWhF1ks1abi&YaUJcD>C+p68|hqohr9RNegroJ zt@m*c@3&q2a}Ogw&%WGy>_29g{bG-@e)jDqwwpcNZT3ebwjNp6;#Q_>Kknh} zg%=uX4`jL=z7sU&jQx$==lSiNMU? z$odB7;NMb6>qBqW^ruhs>A0z+64rQ=q;mDkt;-P`Dx#gbCRI!Hqy+#V6d$ChQZy$TC;!$0Qx)w)M^0`UKl^p-(U$OIdo8p(=b4_>Uh<&8YnO@t ze61V)G)##AkA3b`nDPY%Ge~-Jxj@tHn_vXZ0{a7%&!z)Yw`K6eEuBC-B z3%c(5BOlM0q^6}RF&<$Ha!y7}Y-^w4IkE0{)90ZZD^9jg$Y^?7AKIM8hrX({G}RHb;X4+&J$ecw5jk-wnj;ucR$-Ij_(op)o)^8-E) zYw>glIkQjj(Ro?y)M!3EpR*r+C(nxWep9@*tu}f6<~mJvgTf}wu=qD0HFxS}e=IKR zHq6yX`ux2P`}M8ujt!gQ*l#}#JfjhKS4o%s_5+ok@#rt5y3D(6s?URc3p4)q)bM); ziFbcj)o#1pt>M>t=VAD*?(I9)ALl`eVVcJ0FNP$I&;;kKnRffmJ7h4gy~{a2F(D$V zV_KWz6Ruqrvy0Of`gEdYa1d`pU=Bc#LaZ*GH%=Zlwrd@8L_#e_}%J# zpDI4jYn%Qgo>g%o)27S5t@;U@)lIK?@gS1bZC~?hr+)coMqAd?q{oG7dKm@C$KFP5 z8JnBT&UhG2ao%^{D)@djb3$BmW9-?S^!QVavF#<_u4#@q5M@!`z^u5qa&J51i2-x& z{cNiVyPADs+qtn5TE0ZzW=x1^ZrrPNZu96}&HBHSPp9Yxzp0tUHB-N!x$gL!(Q{hj zoHGIsXFZHO*?KS`Crq#E{k-n8=i<-!9D-jC_s^aV`~0SRYTJRQY-XfUmqnZ2yU?cw zmWv-0arK>B+KzTt>??21d3x%w)!aO`^{iD+FBVKZ`za|drttHq(-mP|RqWr@_StBk z=K3~u)aBb4u)Dr*Tlen3@9g6d*E-g18&lDI$1ynT&GwY4;MKd}lRJvreeT4Tl%&QF zB~MZ=7$khjd>NC{P3=+{%^Gn>jT`b}_KJ#ke<;`vTwwQ;zwpzjIl650+l0z$^1;!S z*KURmj9gK`=4mOV+gCp5cXZ4nC&jYSk0NL)(1d|kW*uZI4UHJs zYWGv*rm*G9Pc8daW6Wch$?EWN`RUfF#-j7=gi7mQiVP>rK9y}4V)ZfQX@yNBBRu7{ z`{X%F-MjawZd!ZZX_L*_Z8Jlx4q7HH4?g8%`%B{O*k!ZQN58!E4VP-*bnEon>KPF~ z-gYd|U}Y`u8rjzN?!bA2(_fOJEJkq0G3PLw{OjQ(rpzy z6HOWr|9!{I+jb)+e%s_ZFKpWj=CJsmKYp8i_B;JMt!(q|J9EkV9q+VS=f&Syz=Y4$ z3kMChH!c6HGJ3_fPe}(E+dm~O-#@xAeBJ}q9M+0kXBPjie@_3-**jB~#X$XwSwD9{ z<{v-K+UzoK*!DN}O_y%iwK|{3A8Bv>z1gyeqmMm@)a|QG+L&q9>Ug4PWXArRmj-aA z`pnwxJvH6ocKp;xvo7_i-Da&$QH3MNX&g@**|q;s;8tfdw`KP>?3q5*by-~OBd4u# zX00nuRF6D7>v-zOxY-4vTg%P76OvfBu07c`bCWmO+^o%EmBGH|rR12{$CR3U>vXGS z{ZFSm-cN&1hw{+BPN!R)Z?Nv#e>*i+V_B2qIO{OWcU{&WWAB}vpJiua=N^%`Bl*79 zg<91aS&mgfxgXPZoVo9He%K=SE97X4v1{(TEkE?*?32IGw`Wg#pBsF6NA>;S%NL#R zeV%^^oT%V4%`P{c?eS%PsspEe-;uP^h&d_oxeuIoU1*+oGIQCBvn#&Dw%BW1>}zpI z4PQQ|A@_}C>iC(}iGFFRiY_)bpm-Ve2R-knf>-tctR(8CFj*aOvV9+__%f9}`*qq8Vo(@y6M9d{m5m2GZjyiAK7 zfAQD%quEvq*vGSmu1~mWMBTDy>u9HK5i{RrM3v9-I`@)wY*w+;WYWynKi#=@gSo)z z#pZLdG5Z!+k|)lp`OSI^>weTSZA+TcKEHHpy3M;w*4%yTFKo1%nm)6xd0AR|bN-BC zOS0NbccV8!Ge6oiyG}K*y?yzy6SF4yT&z75J=L)6qs{FE>p85Ke~gUTmv+DY_b2Q( z2fkF=w>hNxo?mHyx@hM5^N$_(m7kB;ch&6tYr}nE36GiZIq}ELaGQH=GgF=Kux(l$ z-^88UV{bG6!nVxwrK4=tE%UHhc-~_w<3i=~O${@{>|P{qt2qDm(z1A)j*E{ypYryM zwz+z3S+ecDUuJgMzE2s~{xmq@uh47h=c>})u`j>9>=5%bz;L>i_6v`me`5=u zHpiXWNP@${OE$R|SJ_)$IlrVqDPK9wN|Iwzj`pP*daQ5+~TK= z{tpsZSti9f3qM}G<*9smm5{FpBvtGs6R+gtf*+}(n-nHX~=5%ptB^> zvmyijJ(#|CVS~fy+3B6lI#t6CYOZ{^p<-Agb3=T;&)-(IDID#8b_Rt0= z^~wT;$U$D@pUf0L4=T00cJj5RJ@it@>IaUiRi579tt#7o zt@--1F6Qbg2hE~pv)SrTn$0ei{(dL&lAcZOlxvyCvOcHkbyrjrg;ihZ?rvCDXZJ-z zdFVQY;R+QB$|nbpA(;%?IPm4b9|o-KSJ`j8QjHRM__m=u$ee>%3H~epi(0a1uqsc* zKrworH^*p=-5e~b)=V0cMWwThDa@so6npsJ0)8zvvZhj4)JXy`lW25f z>hei_Zf+8TK1pVilV&*3sVqB&wV5@=Zjuqh+#Er`kVdCi+fSk}jeeZuz_6oJt!c)9 zAB|;CGbS#?T27cl=27O6R6Q*ed5!|wNp;Tuy+CYk;L6*TZ{_doTFFRZv0988SO- zY0WU9nN#QYk~jK}iCo&C*!;WL2Bo-M?gAdt=oJs4xb=>Q(8BY2$3sM*dr2n&5Ak&7 zkW}rp6?wi=EM&y>bEmku`MLZ13mL~n@DsxmMhp-Q#>UoECUb62oFE&Z1j-noFrX-S zg85D6O`J_qwFB&RBxf-O1lxgPWW=zcv!rG(rjUj#dSl8+0FsZiph+^%;75|G(?UgF zh!lM({{HR(=3Lj+Lh5qY1z2*uIRQ&SmWzc3g~_C{%o#>zb9$n$Jb)6&ihv@*#D-BjOG1?6~!VM2A1H+3NhR|7>JJjsM8 zXyQqY*ROh58Bi@u3qNCe1x#Yh@XaD~AJL)6JUva4s=JvY&rSxOG*ddrd1EH@o@(6# zwz#qjd%zi27zdabb6j~5{?VHAK@qY5MF{NxMw+0SFsgo7yZf$l_wS8DETsssz5N;M z0z5%LdrA)^UPD{Z)BOEUAVo|tJYqnxe~VV#8}$fgE{zFmiz%!vL=%?`vt$6zjZ9Jv znx_~wTfljkfurDM94dsV!LYq?b9b{`_p_OMh+#;8yFX?$P-!!(C;Imqwggd8_&`SV zFY0pGEg4gdEo}@zycpWEs6^t09Ob_N7<8VQV)QzxexkfU^W=cO<;FyM=O@$%5l)fSF1e0m&q*>EMhBMBP@nOI3yP*AMyLDBgq`@f$8ym@P3?FVj^ zUi&3k1@samNqSL}EXc)AM~r?VSrP}b7p%%hfXrjB0a2@?7&Ss#t{^kP&}EGa$JJei z(nc&&5X=lI<`lXSm4ingyO4YhbfWH{E%YQ=$vmCapx5ds^4NkNN{|o*!*-ab$)^bV zn@K!0Xzp5kx4sIb{JuzFLFTEiBB|N{_FD3>m%~md&TD#yGX{`+Oa=U-tn^x8km3@B z!S4rl_4idip}1t2`>>6QO8@<{`9Ix*jSEyrs+yXLzo-Pb{Jh2;qpJvw-vmh`&_QHT z49%(7lYL>GNiz_q0WI)0;;lqf-SvAX=#@}^YfU=DlXJJcBBMRM+ zYD|SQL>Mvgu#57|s8mZ-b0f;Ouo27wf|~ptnF-aJKS#v*d?sel&FwMZ5Z4nLAS)!Z zG1bW0-V!ku7{X~b7@c8+4gXhwlx(zzVPyyFWrQ*0mJz+_z+nIo7IzsZiC`g36*Gh+`(9j1nVd%|{vK`!qFSv5{Je z2-(~cVF+_j*MOw{W{NEe^(y^_cffqEk~^pWD>I||sgqQxRK;kLe`4{1D7&m+`NR|z;f`And$UjqXBY6F zHF9MMdGEV;Z_Sb$oLKjl^kUEBcYWDn*x7n#2O3xK1&K~DMsrN7QopEpl1^1 z0kH`d^1w_?qYElZ8g*@~fy=RmW4wJ&tZhm30JG~7jWAYTNpjV=@AwM)7Dp+r5*X}o zOob+mlgU$PfxfU5;&V%}wx(cBp){t@P>3063(9gDi;5FBkYr8;ho`0ZVv@oiM1xdK z8eO_3jV@V}za`)=t71WeWg1Mo#EgiH76=OqQ0plYoPmWS!F+u4s45!D&7fBS^elZYaZG=p>NU9Wy;$|t6f<(VD z8(oQGHk+C2!jsS?8-MISA&AZbcXAHlT)8r{-{ zg&k{5vZ~C1#uVzyDDxYa989nj*-V-qgnA(3n(P2IBaRNkiN}DBv26a<#;a66VtA0yZP4h*$~ufojC_kYSB*v}7W3 z1zJWc{0K7yO(5w6@I~TDa5Y{6XJIY@&cZALm{^iYu^KqBftLefT>u*h;wK~SE0P75 z3NR@|$3U#*d^G$G+(#HspbbPf%sv#VS(SH zWRD0EZLANQO1=#Zu2x}XA!0sfg53}-s))0)n2wepNX-!kVtjeHer8IgfCmo+{=wJa zXYr*dQ3lh-!a_P71hY(FkS7BgOi4-!ZX^_>xb5P`;|Nf!CKNR?f0{^!tz?;Gw z3hprT`tgK;9ry=Q$mu_Wj$DGEn+h+EBO;t4m8-A=U6ns)Fi+P1D(tp${PeR8~_hN+zUK3{=&bUg>VBQ zCcntRgrF_~F1S);o+57`NtFiJOeCO+A?VrWR2f9sA_o=8iUfFwi;zQk1Hd~Ea8M<{ zK`^3(#Mmqc1AH}t_Jsw=fxQ0Ex|*nUPxXPT`|$+s(O6Cp2q5Vcg^g&_f=g7=1^TiXz^bZ~$WcG>6iKR6r}o=O z;crr~ZDIdbh?a>`Uyu=w4@OHDvkWE?$IqB_%$&odiba@-IfKH29f6=7jP5OL1(Sqn z1CuIh0h0y+k*)1bAvy-4nolD0%yL44U1sDR^4Oi@^oY%J&kKdMP&M&uxbAt00~a`Bao+=MeXg3eH}e=G2C zy~1X}6kzjdi5hdsVG$(N$&>r>1RMrMh4%nNe<^H`goYMDU`gB>k-Ri(VFc-ee#|BJC!+8nNWD`WH z60{iJyY2|-B!okVJ)kSH&=4VOxt^pNqM;ZyP!_Vp5mf|jn)-3q`QV}u3fc-oJ0Mic zBnLu9>;@cA`Mu#^G=e!m&?RL5KT@$+q5otR@8SED^Offei&2Un`gF*rA?lB!!x zr)eb(MS=;1kYG{~^B(Ve2iVd8ibetyp6(nDBm1%3wg4vuVWu~s}X zZ_F~-&deqV;K84-Jz&T$gWj+O5zpTliqVq^K>{~H#Thtaka_FvK^WO7M$MBR;59A* z9$de*Xk>bKe`W~Bl^Ng?Ad0pnl!6%fEj(XC4|Xsf8jd}(La*DC=$Py_dy>R<%ONNX zg(&QeB_0_elcQnzCBlkHAmH$oJtY7xzlYE*l)n6Z<{GucCF z-?v`%wns$E3(vzA;+`o&cSiTbrE-@!+_EV#Xv^`l9}}ZUl3?;yXPR4&{gkU3XxKpP zC<;OBC<$yO9!QWqYIgW;iKT{ofIapCJ)$N@O=7{&s6pr`>ELXZ5Mdc&c+)sO>jHWh z7jJ)Nkh@O+i@Ww`h~>t?#=${jW%B(-Z1KIp4${BgmSkUXjxO>vIZ(Q#99M|uqCFEr zGy;XBLo|K`g|I`6!O-U4L6Lu%0%V@Obszdh5a$*<+N{3xO$b|l-$W;?MB`>^MN;*J zpj!}%7yqUJ7D={)$b(31kCE0fFhd5H1^90~NiF2P1S`Ks1lW*yYb{BtYqb=kP!$O? zZ=^$L3Kw-YQ2S zVJjXMHRsrZqJywC%1a%POxFN+%Xemosd?W{o zOiZy2l6giJuncJtJw!v}1bTxChzW!X6OG_49kF)@qbG981u22B7%XszaPrD}PRGg| zUOWJNQ+>-IA*yd}{`mR$(7OmEvp~BBTiUnlET9 zdL9N`eJKq2z9BO9V9LrR3PTlEDk#4ic0qZ^&>KTkNJay~`a3H=QCJCCef}@X7f#m< z3=}u|3T$g)H3lC^4l`W%jA3Z+r6T6xi}l6CjImIOom_->4~2#~kX!;xV7}ozf@Hc1 z!<9ygwT(5_pTm&Gz!-_)GAHPA_7dhnfs-&q%wvMl4pW-Khi3xkkq}V=jwMBYu!0Pc zYa4ick>IEhXue5Z7R?1u781~0kfGEQ%>~8!LUUs8FVWp*yf+u&01^3Y@qGvXA^4@I45{Kl%PXqF^2Z+qu}|hTpP~XV7FgJ5htPPzbdUJzK^|;9lsE9uHjh&>ltw zSGX@@Lr88((xRedJyBBY=Z=^nFQb^5FA_^gthc>AnyM z-9lMYAxI&F5XAb%p^Yr18AI?GEGLR$2#KYLSlEctq{9P=p9c~bCmbO31uW#~hfXy& zv__)%Vh|8TuK*oGpudXY3Er$C$dDX#5uUx0fG90cYDosJ5#~z7_e7KV;V!U~kIxAJ z3N9OfW1zr!Ty(D=4(}L(JkTx~CPHsZ6qdxa1WAs@eG~x_;D)-k z0~>E1D4&f1HRLf+nQV;a5AGcsqow=8#&BSV#Ez%1a2LJ_nX7M%AXuUP=a)ic%@&9d zX%>(%mNUQ&LR(vLli-{dGgg=fFNjk~vc?TlvoE8C+6^V=ZlCT=!IYOcB$j!T%96n_ zN6O+Of}j%-BWJ!&L`dHvka?d9KN~^I;efXY1n<}!DLL&#?`L8LN|ZK8)-Z`m^l6X; zf{8y!cpZjV;Xysv5yIk&ix?I;Gr(twBsp9Of~VE25tm`HEEnZUm1gjWGb24s62Y z0r+7jF2o;sQFYNGBPK3eQ}Cs zn>OKm@55CC6ojN4Lz5-g6qRlu#o;z6MEDR>+5ye7p%Y~aC&p5^=mD|)B(94h1By%= z;#?Rz!5gUJamQXPl0+P4lIZDGaSZ|QV$da?TrxM0;@fc&+e4I2M2{+>`is;HtZ2H# z>#c%Gg`EG7Qh1`!uWoqPaH_KFuq(rql+1>n98xf3%wQMNWzy(@mk0JA-~&+s=0hOF;*|+dXVf0-L*6< zk!f4)Q~Q#Z9W*|r1P&m0Fgl#Hc^qVrNU{><3i~GG)PC_&uz|v}8f+19s&=4x5HL)3#&>c)f9X#4tGiYz2N_0U8xhLKu^S&_wuV>&Mf!8yJWI_;Z zlslzJFD$SWkG*(t5*ENikd3Ke7La)}sK6C~k`z~n0pf>9CTgZVqecWtHj4Pt`T3yRX5F zhcHsG3EC%_BJJPOumlN&jrYV)Aj`y|aiyZ%5=u95dBLtxT$S8VO5wBvA-;yarL#0N8Q21yg}`Mj&kO@uONI#buUImKjZyGa$1EAWhAO*9 za@7^M`lL@%b%NPwDvN{I4mfL$lz0|);wOA3Fw+`k9YR2|@y4}E=IQCdSv}y1zIsUn zUWf^@%@IA{AroJNp7E6fP&VF#mB_sBb!E6?3d{F$mg@HcZK+doIoM*#f&|o&DFMM7 z-iXTy@Pp@Gh4Tq70WpB|P#k5iYJ|`vvBW@>Edkj}WIfSmjBVs=W+kRwT|Bk1GxKYp z%kNnaGjyRgN!1->yq(kx#UM&Jxv&c-rmPOWQ<$lQDMScRen0RtHcDYJ$Vvl<`60j? zM>~HnAD0kta`y0EBU(@~I+bu&i0c4zDV)NfmiZ+;b678%km?e)nO+vGU)M5z@Gk{{jC*S6-O#K|G&QL{|CSS-%x-z zZUy+{FC_5E7abDFQP4$YoMj0aQ3**X5;IX$jxYNKE&*iTG;df&K^RKiYT!T2gBoij zA!Eb{w%BS}C}Imq!jixeTNH=rw_dV5lK77^6Rx3rV5@o(Z4&i~5R$!Ij`5Un;ey|G z4>}0HjJI5>5kZiFA#PI;rRo8j@BR=e7IiDdEEh~KZ}&BB?ryf;?m^%<4St3~>WQ2( zU0hwc>o@^436E6i;ILjjQTHnwljxgtgD@2G4T3n6J<)#I&mAa30?JI5T1W|yC02BV zgu=`OX;eM&BP)LdfnVAA{{hpfeVmu z3Xo9xUM(RBoF(iJyGQQ&7ux#|(~ykp{}mOY6yX2$|2zA~ zxX_9G7?>GS52EvRc?`k%zbB(qmbwqX&+U`%7R0o)?TQ7@YV$A?QQFOb8b>u+e z0>NS|2pEGr$%rrj1N>9X;Ql(qz-B-gY$QIW9nM{aACd*BW{d=R>`e_O)!am8E$If} z>2ru$VvY@(Dyq$oh9w`>c|={5k1MuHZ;-{7{~FZhe?4m0D!oAsTmEZM)BJkWuvL14 z8n*n`ptj)aQNvd04Qkl(UxV7huSX4Ar8lTy%YO}Oi@qK;Y?a=ihAsa!sA+vYYS=2h zK@D3TE+kAIFq2OcRz9$3Vr5V44jHS*Rn)fTb& zO1zZ-FEqmHZi~GXB%Dgv1(3!xVpv$3Q}Hphzlw@jaH5JmEZNvPy`mzvf~b++=o5rX zY7f|9>+}jcYz3^b7BhSTeGxKc-B}{Yft#5S`PI;duh&7;Y+O}3_}TQ<644R-T6~Az2%jN!a#$dDU^5gw-|WRmWu#R$m~mIxds2 z`a*ftahZhG7s;!R%OtF>C9gU*Q*ya6qRy-235m}o9Ef?i*(3!3o6FZtkOf5aq>=b9 zEF?g{0$+!W9+>H`7h8{UG$-H6H;dU*AT$wKDs@N3>ErR#<#+ZxHd@{a2 zJ=B-5x1lgZrb=?u(rNt5z8m{X-Z~UmdnID6V$dPXpCQYdMOeBo)-BLB4UwnVkP|ei zH?lD9U6NOr_+byeMxDaxO4wFG;24>S?)||9&J|Nhsu~)Kn>~q1DMh>K@`=?K>KM|j zSxcqJLSxb_EE(2#6&<;7Hm$B4`Hg>wY5N^(~7Qwhug{B&_hdGP=t+8pc_F@qwCaDz!Rp<=mD^z1371V~L; zZG%veVsr37wFMo}8a)=1er6IwU&IsL3}gkSwXb6l$+#ZE#$~<}DA71ZCJCrcVJ;Oi zk`R`-BUpA4=JK6-f&D~l3rsg*9{h_&n*-p6m<+-<0Wqi{)&wCMqDCR2&*1Ojl?bY0 z^e6(JMdX4w5+0s&@p(VU;a_~ozHl+jUL?E+W@rhQAjuJA(kYhAr3@DTnK1Eo4^K)E z@ck$ANwH;!6e*C{GpOyAZzIE!LNafPOj1ae>M%46H|q`UC9_xzsT8qr6)p48x|8@(88)jBask?FNd`cZ zzn44q!n4;t1RD^75D%o_ShuGsf!YnEPE Date: Sun, 27 Sep 2026 12:39:11 +0700 Subject: [PATCH 011/113] feat(platform)!: a contender's fund doubles for every 50 contenders a contest holds past 250 (PV14) (#5034) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/contested-documents.md | 21 ++ .../mod.rs | 126 +++++++++++ .../advanced_structure_v1/mod.rs | 24 +- .../state_v2/mod.rs | 32 ++- .../batch/tests/document/creation.rs | 207 +++++++++++++++++- .../state_transition/state_transitions/mod.rs | 89 ++++++-- .../drive_abci_validation_versions/v10.rs | 7 +- .../fee/vote_resolution_fund_fees/mod.rs | 18 ++ .../fee/vote_resolution_fund_fees/v1.rs | 3 + .../fee/vote_resolution_fund_fees/v2.rs | 5 + .../rs-platform-version/src/version/v14.rs | 34 ++- 11 files changed, 532 insertions(+), 34 deletions(-) diff --git a/book/src/data-model/contested-documents.md b/book/src/data-model/contested-documents.md index 2f4b7537ab2..8e677443375 100644 --- a/book/src/data-model/contested-documents.md +++ b/book/src/data-model/contested-documents.md @@ -25,6 +25,27 @@ whole. Before 14 a contest accepted any number of contenders and its end tallied contest that grew past 10,000 contenders before 14 is tallied and cleaned up for its first 10,000 only. +From protocol version 14, the fund a contender pays doubles once the contest holds 250 contenders +(`contested_document_contenders_before_fund_doubling`) and again for every 50 more +(`contested_document_contenders_per_fund_doubling`), so a contest stops growing long before its +cap: + +| Contenders the contest holds | DPNS fund to join | Moderation election fund to join | +| --- | --- | --- | +| 0 to 249 | 0.1 Dash | 0.5 Dash | +| 250 to 299 | 0.2 Dash | 1 Dash | +| 300 to 349 | 0.4 Dash | 2 Dash | +| ... | doubles every 50 | doubles every 50 | +| 700 to 749 | 102.4 Dash | 512 Dash | +| ... | doubles every 50 | doubles every 50 | +| 950 to 999 | 3,276.8 Dash | 16,384 Dash | + +Filling a DPNS contest to 1,000 contenders costs 327,695 Dash (100 at a flat 0.1 Dash). A contender +states its fund in its prefunded voting balance, and one stating less than the fund of the contest +it joins is refused, paid, with `DocumentContestNotPaidForError`, which carries the fund it has to +pay. A contender may state more; everything it states goes to the contest's fund. Before 14 every +contender stated exactly the fund, however many had joined. + The index's `contested.resolution` says how the contest is decided. From protocol version 14, an identifier property among the index values is written as an diff --git a/packages/rs-dpp/src/voting/vote_polls/contested_document_resource_vote_poll/mod.rs b/packages/rs-dpp/src/voting/vote_polls/contested_document_resource_vote_poll/mod.rs index 0397144c406..3fd9a6fdc5c 100644 --- a/packages/rs-dpp/src/voting/vote_polls/contested_document_resource_vote_poll/mod.rs +++ b/packages/rs-dpp/src/voting/vote_polls/contested_document_resource_vote_poll/mod.rs @@ -100,6 +100,21 @@ impl ContestedDocumentResourceVotePoll { platform_version, ) } + + /// The prefunded voting balance a contender pays to join this contest while it holds + /// `contenders` contenders, see [`required_vote_resolution_fund_to_join`]. + pub fn required_vote_resolution_fund_to_join( + &self, + contenders: u16, + platform_version: &PlatformVersion, + ) -> Credits { + required_vote_resolution_fund_to_join( + &self.contract_id, + &self.document_type_name, + contenders, + platform_version, + ) + } } /// The prefunded voting balance a contender pays into a contest on the contested index of @@ -120,6 +135,117 @@ pub fn required_vote_resolution_fund( } } +/// The prefunded voting balance a contender pays to join a contest on the contested index of +/// `document_type_name` in the contract `contract_id` while the contest holds `contenders` +/// contenders: the contest's fund ([`required_vote_resolution_fund`]), doubled once the contest +/// holds `contested_document_contenders_before_fund_doubling` contenders and again for every +/// `contested_document_contenders_per_fund_doubling` more. From protocol version 14 that is 250 +/// and 50: the first 250 contenders pay the fund, the 251st to the 300th twice it, and the 951st +/// to the 1,000th, the last a contest accepts, 32,768 times it (3,276.8 Dash for a DPNS name). +/// Before 14 every contender pays the fund. +/// +/// This is the least a contender may pay; everything it pays goes to the contest's fund. +pub fn required_vote_resolution_fund_to_join( + contract_id: &Identifier, + document_type_name: &str, + contenders: u16, + platform_version: &PlatformVersion, +) -> Credits { + let fund = required_vote_resolution_fund(contract_id, document_type_name, platform_version); + let fund_fees = &platform_version.fee_version.vote_resolution_fund_fees; + let contenders_per_doubling = fund_fees.contested_document_contenders_per_fund_doubling; + if contenders_per_doubling == 0 { + return fund; + } + let Some(contenders_past_flat_fund) = + contenders.checked_sub(fund_fees.contested_document_contenders_before_fund_doubling) + else { + return fund; + }; + let doublings = 1 + u32::from(contenders_past_flat_fund / contenders_per_doubling); + 2u64.checked_pow(doublings) + .map_or(Credits::MAX, |multiplier| fund.saturating_mul(multiplier)) +} + +#[cfg(test)] +mod fund_to_join_tests { + use super::*; + use crate::moderation_charter::{ + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, MODERATION_CHARTERS_CONTRACT_ID, + }; + + const DASH: Credits = 100_000_000_000; + + fn dpns_fund_to_join(contenders: u16, platform_version: &PlatformVersion) -> Credits { + required_vote_resolution_fund_to_join( + &Identifier::new([0xC1; 32]), + "domain", + contenders, + platform_version, + ) + } + + /// From protocol version 14 the fund a contender pays doubles once the contest holds 250 + /// contenders and again for every 50 more, so filling a contest to its 1,000 contenders + /// costs 327,695 Dash + #[test] + fn should_double_the_fund_for_every_50_contenders_a_contest_holds_past_250() { + let platform_version = PlatformVersion::latest(); + + for (contenders, fund) in [ + (0, DASH / 10), + (249, DASH / 10), + (250, DASH / 5), + (299, DASH / 5), + (300, 2 * DASH / 5), + (699, 512 * DASH / 10), + (700, 1_024 * DASH / 10), + (950, 32_768 * DASH / 10), + (999, 32_768 * DASH / 10), + ] { + assert_eq!( + dpns_fund_to_join(contenders, platform_version), + fund, + "joining a contest holding {contenders} contenders" + ); + } + + let fill = (0..1_000u16) + .map(|contenders| dpns_fund_to_join(contenders, platform_version)) + .sum::(); + assert_eq!(fill, 327_695 * DASH); + + // A moderation election doubles its own fund + assert_eq!( + required_vote_resolution_fund_to_join( + &MODERATION_CHARTERS_CONTRACT_ID, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + 999, + platform_version, + ), + 16_384 * DASH + ); + + // Past what 64 bits hold the fund saturates instead of overflowing + assert_eq!(dpns_fund_to_join(u16::MAX, platform_version), Credits::MAX); + } + + /// PROTOCOL_VERSION_13: every contender pays the same fund + #[test] + fn should_double_the_fund_for_every_50_contenders_a_contest_holds_past_250_protocol_version_13() + { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + + for contenders in [0, 250, 300, 999, u16::MAX] { + assert_eq!( + dpns_fund_to_join(contenders, platform_version), + DASH / 5, + "joining a contest holding {contenders} contenders" + ); + } + } +} + #[cfg(all( test, feature = "json-conversion", diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs index 2d3d2bd589c..a9cdf3389fb 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs @@ -62,9 +62,12 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans Some((provided, paid_amount)), ) => { // A moderation election is prefunded with the moderation fund, every other - // contest with the contested document fund + // contest with the contested document fund. -->> Changed in V1 <<-- A + // contender pays at least that: joining a contest holding 250 contenders or + // more costs a multiple of it, which state validation checks once it has + // counted them, and everything paid goes to the contest's fund. let expected_amount = expected.required_vote_resolution_fund(platform_version); - if expected_amount != *paid_amount { + if *paid_amount < expected_amount { return Ok(SimpleConsensusValidationResult::new_with_error( DocumentContestNotPaidForError::new( self.base().id(), @@ -312,8 +315,10 @@ mod tests { .collect() } + /// A contender pays the contested document fund: exactly it before protocol version 14, at + /// least it from 14, where joining a contest of 250 contenders or more costs a multiple of it #[test] - fn should_require_the_exact_contested_dpns_fee_for_each_protocol_version() { + fn should_require_the_contested_dpns_fee_for_each_protocol_version() { for (protocol_version, expected_amount) in [(13, 20_000_000_000), (14, 10_000_000_000)] { let platform_version = PlatformVersion::get(protocol_version).expect("known version"); for paid_amount in [ @@ -337,7 +342,12 @@ mod tests { let errors = validate(&action, platform_version); let contest_errors = contest_errors(&errors); - if paid_amount == expected_amount { + let accepted = if protocol_version < 14 { + paid_amount == expected_amount + } else { + paid_amount >= expected_amount + }; + if accepted { assert!( contest_errors.is_empty(), "protocol {protocol_version}: {errors:?}" @@ -535,8 +545,8 @@ mod tests { }) } - /// An application in a moderation election prefunds the moderation fund, 0.5 Dash; the - /// contested document fund every other contest takes is refused. + /// An application in a moderation election prefunds at least the moderation fund, 0.5 Dash; + /// the contested document fund every other contest takes is refused. #[test] fn should_require_the_moderation_fund_of_a_charter_application() { let platform_version = PlatformVersion::latest(); @@ -556,7 +566,7 @@ mod tests { let action = charter_application_action(paid_amount, platform_version); let errors = validate(&action, platform_version); let contest_errors = contest_errors(&errors); - if paid_amount == Some(moderation_fund) { + if paid_amount >= Some(moderation_fund) { assert!(contest_errors.is_empty(), "{errors:?}"); } else { let [StateError::DocumentContestNotPaidForError(error)] = contest_errors.as_slice() diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs index 4e939f0970e..fe260b9c768 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs @@ -1,12 +1,14 @@ use dpp::block::block_info::BlockInfo; use dpp::consensus::state::document::document_contest_document_with_same_id_already_present_error::DocumentContestDocumentWithSameIdAlreadyPresentError; use dpp::consensus::state::document::document_contest_maximum_contenders_reached_error::DocumentContestMaximumContendersReachedError; +use dpp::consensus::state::document::document_contest_not_paid_for_error::DocumentContestNotPaidForError; use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::identifier::Identifier; use dpp::validation::{ConsensusValidationResult, SimpleConsensusValidationResult}; use dpp::version::PlatformVersion; +use dpp::voting::vote_polls::contested_document_resource_vote_poll::required_vote_resolution_fund_to_join; use drive::query::TransactionArg; use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{ @@ -59,9 +61,15 @@ impl DocumentCreateTransitionActionStateValidationV2 for DocumentCreateTransitio } // A contest accepts at most `max_contenders_per_contest` contenders, so the end of the - // poll can tally and clean up every one in a block. v1 has let the document join the - // contest when it exists; a new contest has no contenders to count. - if let Some((contested_document_resource_vote_poll, _)) = self.prefunded_voting_balance() { + // poll can tally and clean up every one in a block, and the fund a contender pays to + // join doubles once it holds `contested_document_contenders_before_fund_doubling` + // contenders and again for every `contested_document_contenders_per_fund_doubling` more, + // so filling it costs far more than the fund times the contenders. v1 has let the + // document join the contest when it exists; a new contest has no contenders to count, + // and structure validation has checked its fund. + if let Some((contested_document_resource_vote_poll, paid_amount)) = + self.prefunded_voting_balance() + { if self.current_store_contest_info().is_some() { let max_contenders = platform_version.system_limits.max_contenders_per_contest; let (fee_result, contenders) = platform @@ -89,6 +97,24 @@ impl DocumentCreateTransitionActionStateValidationV2 for DocumentCreateTransitio ), )); } + + let expected_amount = required_vote_resolution_fund_to_join( + &contested_document_resource_vote_poll.contract.id(), + &contested_document_resource_vote_poll.document_type_name, + contenders, + platform_version, + ); + if *paid_amount < expected_amount { + return Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::DocumentContestNotPaidForError( + DocumentContestNotPaidForError::new( + self.base().id(), + expected_amount, + *paid_amount, + ), + )), + )); + } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index 86483a438df..b93c1059db8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -32,7 +32,7 @@ mod creation_tests { use drive::util::test_helpers::setup_contract; use crate::test::helpers::setup::TempPlatform; use crate::rpc::core::MockCoreRPCLike; - use crate::execution::validation::state_transition::state_transitions::tests::{add_contender_to_dpns_name_contest, create_dpns_identity_name_contest, create_dpns_name_contest_give_key_info, dpns_name_vote_poll, perform_votes_multi}; + use crate::execution::validation::state_transition::state_transitions::tests::{add_contender_to_dpns_name_contest, add_contender_to_dpns_name_contest_paying, create_dpns_identity_name_contest, create_dpns_name_contest_give_key_info, dpns_name_vote_poll, perform_votes_multi}; use drive::drive::votes::paths::VotePollPaths; use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::resolve::ContestedDocumentResourceVotePollResolver; use drive::fees::op::LowLevelDriveOperation; @@ -3624,6 +3624,205 @@ mod creation_tests { .expect("expected to write the bare contenders"); } + /// The fund a contest's prefunded specialized balance holds + fn dpns_name_contest_fund( + platform: &TempPlatform, + dpns_contract: &DataContract, + name: &str, + platform_version: &PlatformVersion, + ) -> Credits { + let specialized_balance_id = dpns_name_vote_poll(dpns_contract, name) + .specialized_balance_id() + .expect("expected the specialized balance id"); + platform + .drive + .fetch_prefunded_specialized_balance( + specialized_balance_id.to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch the contest's fund") + .expect("expected the contest to have a fund") + } + + /// The contested document fund at `platform_version` + fn contested_document_fund(platform_version: &PlatformVersion) -> Credits { + platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_vote_resolution_fund_required_amount + } + + /// The fund a contender pays doubles once the contest holds 250 contenders: from then, a + /// contender stating the contested document fund is refused, paid, and one stating twice it + /// joins and pays all of it into the contest's fund + #[tokio::test] + async fn should_double_the_fund_a_contender_pays_once_a_contest_holds_250_contenders() { + let platform_version = PlatformVersion::latest(); + let fund = contested_document_fund(platform_version); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_state = platform.state.load(); + let (_, _, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + "quantum", + platform_version, + ) + .await; + + fill_contest_with_bare_contenders( + &platform, + &dpns_contract, + "quantum", + 250, + platform_version, + ); + let contest_fund = + dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version); + + let (_, result) = add_contender_to_dpns_name_contest_paying( + &mut platform, + &platform_state, + 4, + "quantum", + Some(fund), + platform_version, + ) + .await; + let PaidConsensusError { + error: ConsensusError::StateError(StateError::DocumentContestNotPaidForError(error)), + .. + } = result + else { + panic!("expected the contest not to be paid for, got {result:?}"); + }; + assert_eq!(error.expected_amount(), 2 * fund); + assert_eq!(error.paid_amount(), fund); + assert_eq!( + dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version), + contest_fund + ); + + let (contender, result) = add_contender_to_dpns_name_contest_paying( + &mut platform, + &platform_state, + 9, + "quantum", + Some(2 * fund), + platform_version, + ) + .await; + let SuccessfulExecution { fee_result, .. } = result else { + panic!("expected the contender to join, got {result:?}"); + }; + assert_eq!( + dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version), + contest_fund + 2 * fund + ); + let balance = platform + .drive + .fetch_identity_balance(contender.id().to_buffer(), None, platform_version) + .expect("expected to fetch the contender's balance") + .expect("expected the contender to have a balance"); + // The contender paid the fund, the fees of its document and those of its preorder + let paid = contender.balance() - balance; + assert!( + paid > 2 * fund + fee_result.total_base_fee() && paid < 2 * fund + fund / 100, + "paid {paid}" + ); + } + + /// PROTOCOL_VERSION_13: every contender pays the same fund, however many the contest holds + #[tokio::test] + async fn should_double_the_fund_a_contender_pays_once_a_contest_holds_250_contenders_protocol_version_13( + ) { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + let fund = contested_document_fund(platform_version); + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_state = platform.state.load(); + let (_, _, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + "quantum", + platform_version, + ) + .await; + + fill_contest_with_bare_contenders( + &platform, + &dpns_contract, + "quantum", + 250, + platform_version, + ); + let contest_fund = + dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version); + + let (_, result) = add_contender_to_dpns_name_contest_paying( + &mut platform, + &platform_state, + 4, + "quantum", + Some(fund), + platform_version, + ) + .await; + assert_matches!(result, SuccessfulExecution { .. }); + assert_eq!( + dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version), + contest_fund + fund + ); + } + + /// A contender may state more than the fund it has to pay; all of it goes to the contest's + /// fund + #[tokio::test] + async fn should_put_everything_a_contender_pays_into_the_contest_fund() { + let platform_version = PlatformVersion::latest(); + let fund = contested_document_fund(platform_version); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_state = platform.state.load(); + let (_, _, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + "quantum", + platform_version, + ) + .await; + let contest_fund = + dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version); + + let (_, result) = add_contender_to_dpns_name_contest_paying( + &mut platform, + &platform_state, + 4, + "quantum", + Some(fund + fund / 2), + platform_version, + ) + .await; + assert_matches!(result, SuccessfulExecution { .. }); + assert_eq!( + dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version), + contest_fund + fund + fund / 2 + ); + } + /// A contest accepts at most `max_contenders_per_contest` contenders (1,000): the one that /// would be the 1,001st is refused, paid #[tokio::test] @@ -3652,15 +3851,17 @@ mod creation_tests { max_contenders - 1, platform_version, ); - add_contender_to_dpns_name_contest( + // The 1,000th contender pays 32,768 times the fund + let (_, result) = add_contender_to_dpns_name_contest_paying( &mut platform, &platform_state, 4, "quantum", - None, + Some(32_768 * contested_document_fund(platform_version)), platform_version, ) .await; + assert_matches!(result, SuccessfulExecution { .. }); add_contender_to_dpns_name_contest( &mut platform, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 25724080ee1..b5663b58637 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -167,6 +167,10 @@ pub(in crate::execution) mod tests { use dpp::platform_value::{Bytes32, Value}; use dpp::serialization::PlatformSerializable; use dpp::state_transition::batch_transition::BatchTransition; + use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; + use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionMutRef; + use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; + use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; use dpp::state_transition::masternode_vote_transition::methods::MasternodeVoteTransitionMethodsV0; @@ -1822,10 +1826,50 @@ pub(in crate::execution) mod tests { expect_err: Option<&str>, platform_version: &PlatformVersion, ) -> Identity { + let (identity, result) = add_contender_to_dpns_name_contest_paying( + platform, + platform_state, + seed, + name, + None, + platform_version, + ) + .await; + + if let Some(expected_err) = expect_err { + let StateTransitionExecutionResult::PaidConsensusError { + error: consensus_error, + .. + } = result + else { + panic!("expected a paid consensus error, got {result:?}"); + }; + assert_eq!(consensus_error.to_string(), expected_err); + } else { + assert_matches!(result, SuccessfulExecution { .. }); + } + identity + } + + /// Adds a contender to the DPNS name contest on `name` like + /// [`add_contender_to_dpns_name_contest`], stating `prefunded_voting_balance` as its fund, and + /// holding that much beside 0.5 Dash for fees, instead of the fund the transition is built + /// with. Returns the contender and how its document create executed. + pub(in crate::execution) async fn add_contender_to_dpns_name_contest_paying( + platform: &mut TempPlatform, + platform_state: &PlatformState, + seed: u64, + name: &str, + prefunded_voting_balance: Option, + platform_version: &PlatformVersion, + ) -> (Identity, StateTransitionExecutionResult) { let mut rng = StdRng::seed_from_u64(seed); - let (identity_1, signer_1, key_1) = - setup_identity(platform, rng.gen(), dash_to_credits!(0.5)); + let (identity_1, signer_1, key_1) = setup_identity( + platform, + rng.gen(), + dash_to_credits!(0.5) + prefunded_voting_balance.unwrap_or_default(), + ); let dpns = platform .drive @@ -1916,7 +1960,7 @@ pub(in crate::execution) mod tests { .serialize_to_bytes() .expect("expected documents batch serialized state transition"); - let documents_batch_create_transition_1 = + let mut documents_batch_create_transition_1 = BatchTransition::new_document_creation_transition_from_document( document_1, domain, @@ -1932,6 +1976,26 @@ pub(in crate::execution) mod tests { .await .expect("expect to create documents batch transition"); + if let Some(prefunded_voting_balance) = prefunded_voting_balance { + let StateTransition::Batch(batch) = &mut documents_batch_create_transition_1 else { + panic!("expected a batch transition"); + }; + let Some(BatchedTransitionMutRef::Document(DocumentTransition::Create(create))) = + batch.first_transition_mut() + else { + panic!("expected a document create"); + }; + create + .prefunded_voting_balances_mut() + .as_mut() + .expect("expected a contested document") + .1 = prefunded_voting_balance; + documents_batch_create_transition_1 + .sign_external(&key_1, &signer_1, Some(|_, _| Ok(SecurityLevel::HIGH))) + .await + .expect("expected to sign"); + } + let documents_batch_create_serialized_transition_1 = documents_batch_create_transition_1 .serialize_to_bytes() .expect("expected documents batch serialized state transition"); @@ -1992,21 +2056,10 @@ pub(in crate::execution) mod tests { .unwrap() .expect("expected to commit transaction"); - if let Some(expected_err) = expect_err { - let result = processing_result.into_execution_results().remove(0); - - let StateTransitionExecutionResult::PaidConsensusError { - error: consensus_error, - .. - } = result - else { - panic!("expected a paid consensus error, got {result:?}"); - }; - assert_eq!(consensus_error.to_string(), expected_err); - } else { - assert_eq!(processing_result.valid_count(), 1); - } - identity_1 + ( + identity_1, + processing_result.into_execution_results().remove(0), + ) } pub(in crate::execution) fn verify_dpns_name_contest( diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 2bfdf272eb1..9f0a5f27ba3 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -37,6 +37,11 @@ use crate::version::drive_abci_versions::drive_abci_validation_versions::{ // `maximum_contenders_to_consider` rises from 100 to 10,000 so the end of a poll // tallies and cleans up every contender of a poll within the 1,000 a contest // accepts, and up to 10,000 of one that grew past it before this version. +// Document create state validation 2 also refuses a contender whose prefunded voting +// balance is less than the fund doubled once the contest holds +// `contested_document_contenders_before_fund_doubling` (250) contenders and again for every +// `contested_document_contenders_per_fund_doubling` (50) more (DocumentContestNotPaidForError), and structure validation 1 accepts a prefunded voting +// balance of at least the contest's fund where 0 wanted exactly it. // v9 remains unchanged for PROTOCOL_VERSION_13 chain replay. pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = DriveAbciValidationVersions { @@ -238,7 +243,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = // PROTOCOL_VERSION_14: a batch that asks the contract owner to pay its gas // only has to fund its principal (purchases, contest collateral) itself. identity_minimum_balance_pre_check: 1, - document_create_transition_structure_validation: 1, // changed: v1 also cross-checks the prefunded voting balance against the contested index and refuses a `distinctFrom` identifier property equal to the value it must differ from + document_create_transition_structure_validation: 1, // changed: v1 also cross-checks the prefunded voting balance against the contested index, accepts one of at least the contest's fund, and refuses a `distinctFrom` identifier property equal to the value it must differ from // Reject deletes on legacy keep-history types as paid consensus errors. // Protocols through 13 retain the original internal-error outcome. document_delete_transition_structure_validation: 1, diff --git a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs index d11fe374805..df6b2560a27 100644 --- a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs +++ b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs @@ -17,6 +17,17 @@ pub struct VoteResolutionFundFees { /// from protocol version 14; every earlier schedule carries the contested document /// amount here, so choosing between the two changes nothing before 14. pub moderation_vote_resolution_fund_required_amount: u64, + /// How many contenders a contest holds before the fund a contender joining it pays first + /// doubles: joining a contest holding fewer costs the contest's fund. Read with + /// `contested_document_contenders_per_fund_doubling`, by contested document create state + /// validation 2 only. + pub contested_document_contenders_before_fund_doubling: u16, + /// How many more contenders a contest holds for each further doubling of the fund a + /// contender joining it pays: joining a contest holding `n` contenders, at least + /// `contested_document_contenders_before_fund_doubling` (`b`), costs the contest's fund + /// times `2^(1 + (n - b) / this)`. 0 means the fund never doubles, what every schedule + /// before protocol version 14 carries. + pub contested_document_contenders_per_fund_doubling: u16, } /// The vote resolution fund fees exactly as every pre-1.4 release serialized them inside @@ -43,6 +54,9 @@ impl From for VoteResolutionFundFees // amount wherever they could be reached before protocol version 14. moderation_vote_resolution_fund_required_amount: value .contested_document_vote_resolution_fund_required_amount, + // Pre-4.2 tables predate the fund doubling with the contenders a contest holds + contested_document_contenders_before_fund_doubling: 0, + contested_document_contenders_per_fund_doubling: 0, } } } @@ -60,6 +74,8 @@ mod tests { contested_document_vote_resolution_unlock_fund_required_amount: 2, contested_document_single_vote_cost: 3, moderation_vote_resolution_fund_required_amount: 4, + contested_document_contenders_before_fund_doubling: 5, + contested_document_contenders_per_fund_doubling: 6, }; let version2 = VoteResolutionFundFees { @@ -67,6 +83,8 @@ mod tests { contested_document_vote_resolution_unlock_fund_required_amount: 2, contested_document_single_vote_cost: 3, moderation_vote_resolution_fund_required_amount: 4, + contested_document_contenders_before_fund_doubling: 5, + contested_document_contenders_per_fund_doubling: 6, }; // This assertion will check if all fields are considered in the equality comparison diff --git a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v1.rs b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v1.rs index 5d3e3004d22..c25fcf085bf 100644 --- a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v1.rs +++ b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v1.rs @@ -7,4 +7,7 @@ pub const VOTE_RESOLUTION_FUND_FEES_VERSION1: VoteResolutionFundFees = VoteResol // Moderation elections exist from protocol version 14; before it they would pay what // every other contest pays. moderation_vote_resolution_fund_required_amount: 20000000000, // 0.2 Dash + // Before protocol version 14 every contender paid the same fund, however many had joined + contested_document_contenders_before_fund_doubling: 0, + contested_document_contenders_per_fund_doubling: 0, }; diff --git a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v2.rs b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v2.rs index a49da4189ca..af09258299b 100644 --- a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v2.rs +++ b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v2.rs @@ -8,5 +8,10 @@ pub const VOTE_RESOLUTION_FUND_FEES_VERSION2: VoteResolutionFundFees = VoteResol contested_document_single_vote_cost: 2_000_000, // 0.00002 DASH // An application in a moderation election prefunds 25,000 masternode votes moderation_vote_resolution_fund_required_amount: 50_000_000_000, // 0.5 DASH + // The first 250 contenders of a contest pay its fund, and then the fund doubles for every + // 50 more: twice it for the 251st to the 300th, up to 32,768 times it for the 951st to the + // 1,000th (3,276.8 DASH for a DPNS name) + contested_document_contenders_before_fund_doubling: 250, + contested_document_contenders_per_fund_doubling: 50, ..VOTE_RESOLUTION_FUND_FEES_VERSION1 }; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 06507996803..fff97b8f6be 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1268,6 +1268,20 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// before this version. `check_for_ended_vote_polls` 1 compares every tied /// contender; version 0 compared at most 100. /// +/// 51. **The fund a contender pays doubles for every 50 contenders a contest +/// holds past 250**: document create state validation 2 refuses, paid, a +/// contender whose prefunded voting balance is less than the contest's fund +/// doubled once the contest holds +/// `contested_document_contenders_before_fund_doubling` (`FEE_VERSION3`, +/// 250) contenders and again for every +/// `contested_document_contenders_per_fund_doubling` (50) more +/// (`DocumentContestNotPaidForError`): 0.1 DASH for the first 250 DPNS +/// contenders, 0.2 for the next 50, up to 3,276.8 for the 951st to the +/// 1,000th, so filling a contest costs 327,695 DASH where it cost 100. +/// Document create structure validation 1 accepts a prefunded voting +/// balance of at least the contest's fund; version 0 wants exactly it. +/// Everything a contender pays goes to the contest's fund. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's @@ -1336,7 +1350,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, // changed: saved platform state structure 1 keeps masternodes and validator sets as one aux entry each methods: DRIVE_ABCI_METHOD_VERSIONS_V10, // changed: records the per-block total credits history for the daily withdrawal limit - validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation; the ContractUserModeration gates and the batch transformer's contract_moderation_gate; a contest accepts at most max_contenders_per_contest contenders and maximum_contenders_to_consider rises to 10,000 + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation; the ContractUserModeration gates and the batch transformer's contract_moderation_gate; a contest accepts at most max_contenders_per_contest contenders and maximum_contenders_to_consider rises to 10,000; a contender's fund doubles past 250 contenders and for every 50 more withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, // changed: prune bound for the total credits history query: DRIVE_ABCI_QUERY_VERSIONS_V3, // changed: ranked + boolean-HAVING routing gate; the v1 handler also resolves IN_TIME_RANGE from committed block time checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, @@ -1361,7 +1375,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { // The TTL ephemeral-bytes rate (270 credits/byte to processing) rides // the shared storage table; it is dead below v14 (the `ttl` grammar // does not parse), so no table fork is needed. - fee_version: FEE_VERSION3, // changed: contested document contribution reduced to 0.1 DASH; masternode vote cost reduced to 0.00002 DASH; moderation election fund of 0.5 DASH; registration surcharge for once-per-identity token distributions + fee_version: FEE_VERSION3, // changed: contested document contribution reduced to 0.1 DASH; masternode vote cost reduced to 0.00002 DASH; moderation election fund of 0.5 DASH; a contender's fund doubles past 250 contenders and for every 50 more; registration surcharge for once-per-identity token distributions system_limits: SYSTEM_LIMITS_V4, // changed: daily withdrawal limit becomes 15% of the total credits a day ago + time-range overlap-factor cap (24) + time-range TTL cap (1 week) and per-write drop cap (32) + GroveDB proof envelope floor (V1); max_contract_moderators, max_contract_suspension_until, max_contract_moderation_reason_length, max_contract_warnings_per_identity, max_contract_moderation_reason_documents and contract_document_restore_window_ms (a week); max_contenders_per_contest (1,000) consensus: ConsensusVersions { tenderdash_consensus_version: 1, @@ -1397,6 +1411,14 @@ mod tests { fund_fees.contested_document_vote_resolution_fund_required_amount, "protocol {protocol_version}" ); + assert_eq!( + ( + fund_fees.contested_document_contenders_before_fund_doubling, + fund_fees.contested_document_contenders_per_fund_doubling + ), + (0, 0), + "protocol {protocol_version}: every contender paid the same fund" + ); } let mut expected_fees = PLATFORM_V13.fee_version.clone(); @@ -1411,6 +1433,14 @@ mod tests { expected_fees .vote_resolution_fund_fees .moderation_vote_resolution_fund_required_amount = 50_000_000_000; + // The fund a contender pays doubles once the contest holds 250 contenders, and again for + // every 50 more + expected_fees + .vote_resolution_fund_fees + .contested_document_contenders_before_fund_doubling = 250; + expected_fees + .vote_resolution_fund_fees + .contested_document_contenders_per_fund_doubling = 50; // The once-per-identity token distribution exists from protocol version 14 on, and a // token that uses it pays the surcharge of the other distribution kinds. assert_eq!( From 8936d447aaaddaca975c6576d1f138db02658e40 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 13:31:10 +0700 Subject: [PATCH 012/113] feat(platform)!: anyOf, allOf and not in propertyConstraints rules (PV14) (#5036) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/documents.md | 22 +- packages/js-evo-sdk/README.md | 7 +- .../document/v3/document-meta.json | 39 +- .../class_methods/try_from_schema/mod.rs | 8 +- .../v3/property_constraints_tests.rs | 173 +++++++- .../document_type/methods/mod.rs | 6 +- .../src/data_contract/document_type/mod.rs | 7 +- .../document_type/property_constraints/mod.rs | 388 +++++++++++++---- .../property_constraints/tests.rs | 401 +++++++++++++++++- .../src/data_contract/document_type/v2/mod.rs | 5 +- ...ment_property_constraint_violated_error.rs | 11 +- .../tests/document/property_constraints.rs | 49 ++- .../src/version/system_limits/mod.rs | 6 +- .../rs-platform-version/src/version/v14.rs | 29 +- packages/wasm-dpp2/src/consensus_error.rs | 6 +- 15 files changed, 1022 insertions(+), 135 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 18d815cf5ce..221603c5b4b 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -680,7 +680,7 @@ The check runs where the JSON schema validation of a document's properties runs, ## Property Constraints (`propertyConstraints`) -Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named rules the integer properties of every created or replaced document must meet, where JSON Schema can only bound one property at a time. Each rule compares two integer expressions. +Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named rules the integer properties of every created or replaced document must meet, where JSON Schema can only bound one property at a time. Each rule is a condition: a comparison of two integer expressions, or `anyOf`, `allOf` or `not` over conditions. ```json "propertyConstraints": { @@ -693,11 +693,21 @@ Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named "wholeLots": { "equal": [{ "modulo": ["quantity", 10] }, 0] }, "minimumOrder": { "greaterThanOrEqual": [{ "multiply": ["price", { "ifAbsent": ["quantity", 1] }] }, 100] + }, + "feeWaivedOrAtLeastTen": { + "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] } } ``` -The first rule reads `((price + fee) * quantity) <= deposit`. A rule's name is 1 to 64 letters, digits or underscores, and the rule is an object with one key, its comparison: `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` or `greaterThanOrEqual`, listing the left and the right expression. An expression is one of: +The first rule reads `((price + fee) * quantity) <= deposit`, the last `fee == 0 || fee >= 10`. A rule's name is 1 to 64 letters, digits or underscores, and the rule is a condition, an object with one key: + +- a comparison, `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` or `greaterThanOrEqual`, listing the left and the right expression; +- `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; +- `{ "allOf": [...] }`, holding if every one of two or more conditions holds; +- `{ "not": condition }`, holding if its one condition does not. + +Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan": ["quantity", 10] }] } }` refuses a free order of more than 10. An `anyOf` or `allOf` may not list two alike conditions, nor hold one of its own kind directly (it says what one flat list says), and a `not` may not hold a `not` directly. An expression is one of: - an integer value (`100`; a float with no fractional part, `100.0`, reads as that integer, as the meta-schema's `integer` type admits it); - a string, the dotted path of an integer property of the document type (`"price"`, `"meta.total"`), whose value it takes, 0 when the document leaves the property out; @@ -709,11 +719,13 @@ A JSON number is always a value and a string always a path, so a property named The arithmetic is exact over `i128`. Operands are evaluated left to right, and every intermediate result must fit: an overflow, a divisor of 0, a negative exponent or a property value that is not an integer (a float with no fractional part passes the schema's `integer` type) breaks the rule instead of wrapping or truncating. `divide` and `modulo` are Euclidean, so the remainder is never negative and the quotient is the one that goes with it (`-7` by `2` is `-4` remainder `1`); for operands that are not negative this is ordinary integer division. `0` to the power `0` is `1`. There are no floats: a `number` property cannot be read, which keeps every node's result bit-identical. -The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path names an integer property of the type (a nested one by its dotted path) that is neither `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every rule reads at least one property, that no literal divisor is 0 and no literal exponent negative, and that no operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting the comparison, every operator and every operand. The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. +Conditions are checked in declared order and no further than the outcome needs: a comparison evaluates its left side, then its right; `anyOf` stops at the first condition that holds and `allOf` at the first that fails. A fault in a condition that is checked breaks the rule whatever the others would say, and `not` does not turn it into a pass. So an earlier condition guards a later one: `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` holds for a `b` of 0 without dividing by it, while the same two conditions the other way round divide by zero and break the rule. + +The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path names an integer property of the type (a nested one by its dotted path) that is neither `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. -Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the comparison does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. Transfers, purchases and price updates change no property and are not judged. +Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. Transfers, purchases and price updates change no property and are not judged. -In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`, empty on types that predate the keyword), each rule's `violation` evaluates it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. +In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`, a comparison or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. ## Rules and Guidelines diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 0d58f661fa0..85bc7e07571 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -399,7 +399,7 @@ try { ## Property constraints (`propertyConstraints`) -From protocol version 14 a document type can declare rules its documents' integer properties must meet, each a comparison of two integer expressions built from property paths and integer values: +From protocol version 14 a document type can declare rules its documents' integer properties must meet, each a comparison of two integer expressions built from property paths and integer values, or `anyOf`, `allOf` or `not` over such conditions: ```json "propertyConstraints": { @@ -411,11 +411,14 @@ From protocol version 14 a document type can declare rules its documents' intege }, "minimumOrder": { "greaterThanOrEqual": [{ "multiply": ["price", { "ifAbsent": ["quantity", 1] }] }, 100] + }, + "feeWaivedOrAtLeastTen": { + "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] } } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 31807057c01..8fdc957f045 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named comparisons between integer expressions over the document's integer properties, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's integer properties, comparisons between integer expressions combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "referenceOperands": { @@ -40,7 +40,7 @@ } }, "propertyConstraint": { - "description": "One rule of propertyConstraints: an object with one key, the comparison, listing the two integer expressions it compares, left then right", + "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right, or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", "type": "object", "properties": { "equal": { @@ -60,12 +60,45 @@ }, "greaterThanOrEqual": { "$ref": "#/$defs/propertyConstraintOperandPair" + }, + "anyOf": { + "description": "Holds if at least one of its conditions holds, checked in declared order and stopping at the first that holds: two or more conditions, no two alike, none of them directly an anyOf (it says what one flat list says)", + "$ref": "#/$defs/propertyConstraintConditions", + "items": { + "properties": { + "anyOf": false + } + } + }, + "allOf": { + "description": "Holds if every one of its conditions holds, checked in declared order and stopping at the first that fails: two or more conditions, no two alike, none of them directly an allOf (it says what one flat list says)", + "$ref": "#/$defs/propertyConstraintConditions", + "items": { + "properties": { + "allOf": false + } + } + }, + "not": { + "description": "Holds if its one condition does not hold; a fault evaluating the condition still breaks the rule. The condition may not be directly another not", + "$ref": "#/$defs/propertyConstraint", + "properties": { + "not": false + } } }, "minProperties": 1, "maxProperties": 1, "additionalProperties": false }, + "propertyConstraintConditions": { + "type": "array", + "items": { + "$ref": "#/$defs/propertyConstraint" + }, + "minItems": 2, + "uniqueItems": true + }, "propertyConstraintExpression": { "description": "An integer expression of a propertyConstraints rule: an integer value; the dotted path of an integer property of the document type, whose value it takes, 0 when the document leaves it out; or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two", "type": [ @@ -1991,7 +2024,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is an object with one key, its comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual), listing the two integer expressions it compares, left then right. An expression is an integer value, the dotted path of an integer property of the document type, whose value it takes, 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Every property a rule reads must be an integer property that is neither transient nor inside a transient object, every rule must read at least one property, a literal 0 divisor or negative exponent is refused, and no operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting the comparison, every operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer property of the document type, whose value it takes, 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property a rule reads must be an integer property that is neither transient nor inside a transient object, every comparison must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index e1230dc77eb..8c0d0383217 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -1877,7 +1877,8 @@ pub(super) fn validate_encrypted_for_declarations( /// declaration's shape ([`parse_property_constraints`]) and these reads are /// checked on every parse; under full validation, the limits too: at most /// `SystemLimits::max_property_constraints` rules, each of at most -/// `max_property_constraint_nodes` nodes. +/// `max_property_constraint_nodes` nodes, and no `anyOf` or `allOf` listing +/// the same condition twice. /// /// Only parser generation 3 calls it, once the core parse has run the /// meta-schema, so under full validation a malformed declaration is the @@ -1979,6 +1980,11 @@ fn apply_property_constraints_v0( "rule \"{name}\" has {nodes} nodes, above the maximum of {max_nodes}" ))); } + if let Some((repeat, earlier)) = constraint.repeated_condition() { + return Err(structure_error(format!( + "rule \"{name}\" at {repeat} repeats the condition at {earlier}" + ))); + } } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index da201782106..0be5b4b9164 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -132,6 +132,63 @@ fn should_parse_the_rules_onto_the_document_type_on_both_paths() { assert!(document_type.property_constraints().is_empty()); } +/// `anyOf`, `allOf` and `not` register and parse on both paths, and every property +/// a condition reads, however deep, is held to the same checks as a comparison's. +#[test] +fn should_parse_combined_conditions_and_check_every_property_they_read() { + let rules = json!({ + "feeWaivedOrAtLeastTen": { + "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] + }, + "noFreeLargeOrder": { + "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan": ["quantity", 10] }] } + }, + "depositOrSmallOrder": { + "allOf": [ + { + "anyOf": [ + { "greaterThan": ["deposit", 0] }, + { "not": { "greaterThan": ["quantity", 1] } } + ] + }, + { "lessThanOrEqual": [{ "ifAbsent": ["meta.total", 0] }, "deposit"] } + ] + } + }); + for full_validation in [true, false] { + let document_type = parse_order(rules.clone(), full_validation) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let constraints = document_type.property_constraints(); + assert_eq!( + constraints["feeWaivedOrAtLeastTen"].property_paths(), + ["fee", "fee"] + ); + assert_eq!( + constraints["noFreeLargeOrder"].property_paths(), + ["price", "quantity"] + ); + assert_eq!( + constraints["depositOrSmallOrder"].property_paths(), + ["deposit", "quantity", "meta.total", "deposit"] + ); + } + + let nested_string = json!({ + "rule": { + "anyOf": [ + { "equal": ["fee", 0] }, + { "not": { "lessThan": [{ "add": ["price", "note"] }, 10] } } + ] + } + }); + for full_validation in [true, false] { + expect_structure_error( + parse_order(nested_string.clone(), full_validation), + "rule \"rule\" reads \"note\", which has type string, not integer", + ); + } +} + /// Only an integer property's value is a number the rule can compute with: a /// string, a float, an array, an object and a system property are refused on /// both paths, as is a path naming nothing. @@ -217,11 +274,23 @@ fn should_refuse_a_rule_reading_anything_but_an_integer_property() { /// a transient object. #[test] fn should_refuse_a_rule_reading_a_transient_value() { - for (transient, operand) in [("code", "code"), ("meta", "meta.total")] { - let schema = order_schema( - Some(json!({ "rule": { "lessThan": [operand, "price"] } })), - Some(transient), - ); + for (transient, operand, nested) in [ + ("code", "code", false), + ("meta", "meta.total", false), + ("code", "code", true), + ] { + // Also when the property is read deep inside a condition + let rule = if nested { + json!({ + "anyOf": [ + { "equal": ["price", 1] }, + { "not": { "lessThan": [{ "add": ["fee", operand] }, "price"] } } + ] + }) + } else { + json!({ "lessThan": [operand, "price"] }) + }; + let schema = order_schema(Some(json!({ "rule": rule })), Some(transient)); for full_validation in [true, false] { expect_structure_error( parse_dispatched( @@ -291,6 +360,73 @@ fn should_hold_the_limits_under_full_validation_only() { ), ); parse_order(rule_of(max_nodes + 1), false).expect("a stored contract stays readable"); + + // Every logical operator and every comparison counts too: allOf, the equal with + // its add, "price", ones and 0, and not over an equal of "fee" and 0 + let logical_rule_of = |nodes: usize| { + let mut operands = vec![json!("price")]; + operands.resize(nodes - 8, json!(1)); + json!({ + "rule": { + "allOf": [ + { "equal": [{ "add": operands }, 0] }, + { "not": { "equal": ["fee", 0] } } + ] + } + }) + }; + let document_type = parse_order(logical_rule_of(max_nodes), true) + .expect("the most nodes a rule may have, logical ones included"); + assert_eq!( + document_type.property_constraints()["rule"].node_count(), + max_nodes + ); + expect_structure_error( + parse_order(logical_rule_of(max_nodes + 1), true), + &format!( + "rule \"rule\" has {} nodes, above the maximum of {max_nodes}", + max_nodes + 1 + ), + ); + parse_order(logical_rule_of(max_nodes + 1), false).expect("a stored contract stays readable"); +} + +/// No `anyOf` or `allOf` may list the same condition twice, checked when a contract +/// registers: the meta-schema refuses two identical JSON conditions, and the parser +/// two that parse alike. A stored contract stays readable. +#[test] +fn should_refuse_a_repeated_condition_under_full_validation_only() { + let identical = json!({ + "rule": { "anyOf": [{ "equal": ["price", 1] }, { "equal": ["price", 1] }] } + }); + let registered = parse_order(identical.clone(), true); + assert!( + registered.as_ref().is_err_and(is_json_schema_error), + "the meta-schema should refuse it, got {registered:?}" + ); + parse_order(identical, false).expect("a stored contract stays readable"); + + // A path on its own reads as ifAbsent 0, so these two are the same condition + let alike = json!({ + "rule": { + "allOf": [ + { "equal": ["fee", 1] }, + { + "not": { + "anyOf": [ + { "equal": ["price", 1] }, + { "equal": [{ "ifAbsent": ["price", 0] }, 1] } + ] + } + } + ] + } + }); + expect_structure_error( + parse_order(alike.clone(), true), + "rule \"rule\" at allOf[1].not.anyOf[1] repeats the condition at allOf[1].not.anyOf[0]", + ); + parse_order(alike, false).expect("a stored contract stays readable"); } /// When a contract registers, the meta-schema checks the grammar, the @@ -316,6 +452,29 @@ fn should_check_the_grammar_with_the_meta_schema_and_the_parser() { json!({ "rule": { "equal": [{ "ifAbsent": ["price", "fee"] }, 1] } }), json!({ "bad-name": { "equal": ["price", 1] } }), json!(["price"]), + json!({ "rule": { "or": [{ "equal": ["price", 1] }, { "equal": ["fee", 1] }] } }), + json!({ "rule": { "anyOf": [{ "equal": ["price", 1] }] } }), + json!({ "rule": { "allOf": { "equal": ["price", 1] } } }), + json!({ "rule": { "not": [{ "equal": ["price", 1] }] } }), + json!({ "rule": { "not": { "equal": ["price", 1] }, "equal": ["fee", 1] } }), + json!({ + "rule": { + "anyOf": [ + { "anyOf": [{ "equal": ["price", 1] }, { "equal": ["price", 2] }] }, + { "equal": ["fee", 1] } + ] + } + }), + json!({ + "rule": { + "allOf": [ + { "equal": ["fee", 1] }, + { "allOf": [{ "equal": ["price", 1] }, { "equal": ["price", 2] }] } + ] + } + }), + json!({ "rule": { "not": { "not": { "equal": ["price", 1] } } } }), + json!({ "rule": { "anyOf": [{ "equal": ["price", 1] }, { "equal": ["price"] }] } }), ] { let registered = parse_order(rules.clone(), true); assert!( @@ -343,6 +502,10 @@ fn should_check_the_grammar_with_the_meta_schema_and_the_parser() { json!({ "rule": { "equal": [{ "add": [1, 2] }, 3] } }), "rule \"rule\" reads no property", ), + ( + json!({ "rule": { "anyOf": [{ "equal": ["price", 1] }, { "equal": [1, 1] }] } }), + "rule \"rule\" at anyOf[1] reads no property", + ), ] { for full_validation in [true, false] { expect_structure_error(parse_order(rules.clone(), full_validation), needle); diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index ff0288ddcf3..4a8adb7f858 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -683,9 +683,9 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe /// Judges a document's properties, `data` (a map), against every rule of the document /// type's `propertyConstraints`, in name order: the first rule it breaks fails with - /// `DocumentPropertyConstraintViolatedError` (10422), naming the rule and why (the - /// comparison does not hold, or evaluating it overflowed, divided by zero, raised to a - /// negative power or read a value that is not an integer). A property the document + /// `DocumentPropertyConstraintViolatedError` (10422), naming the rule and why (the rule + /// does not hold, or evaluating it overflowed, divided by zero, raised to a negative + /// power or read a value that is not an integer). A property the document /// leaves out counts as 0, or as its `ifAbsent` value. Reads the properties alone: /// `DataContract::validate_document_properties` runs it after the schema validation, /// so document create and replace, and every client validating a document, apply it. diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 719ece5d81d..08670000d26 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -116,9 +116,10 @@ pub(crate) mod property_names { /// transferable or tradeable one). Meta-schema v3+ (protocol version 14). /// See `parse_doctype_reference` in `try_from_schema`. pub const CREATOR_REFERS_TO: &str = "creatorRefersTo"; - /// Doctype-level object of named rules, each a comparison of two integer - /// expressions over the document's integer properties that every created or - /// replaced document must meet. Meta-schema v3+ (protocol version 14). See + /// Doctype-level object of named rules, each a condition on the document's + /// integer properties (a comparison of two integer expressions, or an + /// `anyOf`, `allOf` or `not` of conditions) that every created or replaced + /// document must meet. Meta-schema v3+ (protocol version 14). See /// `parse_property_constraints` in `property_constraints`. pub const PROPERTY_CONSTRAINTS: &str = "propertyConstraints"; pub const DISTINCT_FROM: &str = "distinctFrom"; diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index 83d8da79521..ceb489787dc 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -1,7 +1,7 @@ //! The doctype-level `propertyConstraints` keyword (meta-schema v3, protocol //! version 14): named rules every document of the type must meet, each a -//! comparison of two integer expressions over the document's integer -//! properties. +//! condition on the document's integer properties: a comparison of two integer +//! expressions, or `anyOf`, `allOf` or `not` over conditions. //! //! ```json //! "propertyConstraints": { @@ -14,6 +14,9 @@ //! "wholeLots": { "equal": [{ "modulo": ["quantity", 10] }, 0] }, //! "minimumOrder": { //! "greaterThanOrEqual": [{ "multiply": ["price", { "ifAbsent": ["quantity", 1] }] }, 100] +//! }, +//! "feeWaivedOrAtLeastTen": { +//! "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] //! } //! } //! ``` @@ -23,12 +26,14 @@ //! `ifAbsent`, a property with the value it takes when the document leaves it //! out. A property named on its own takes 0 when absent. How the arithmetic //! treats overflow, division and powers is set out on -//! [`ConstraintExpression::evaluate`]. +//! [`ConstraintExpression::evaluate`], and how conditions combine on +//! [`PropertyConstraint::holds`]. //! //! [`parse_property_constraints`] checks the declaration's shape on every //! parse, [`MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH`] included. Which properties a //! rule may read is checked against the parsed document type by parser -//! generation 3, and the limits on the rules under full validation only. +//! generation 3, and the limits on the rules, and that no `anyOf` or `allOf` +//! repeats a condition, under full validation only. //! Nothing here is serialized: a document type rebuilds its rules from its //! stored schema whenever the contract is loaded. @@ -51,22 +56,27 @@ const MULTIPLY: &str = "multiply"; const DIVIDE: &str = "divide"; const MODULO: &str = "modulo"; const POWER: &str = "power"; +const ANY_OF: &str = "anyOf"; +const ALL_OF: &str = "allOf"; +const NOT: &str = "not"; /// Every key an operand object may hold, for the errors. const OPERAND_KEYS: &str = "add, subtract, multiply, divide, modulo, power or ifAbsent"; -/// The deepest an operand may sit in its rule, the two sides of the comparison -/// at depth 1. Checked on every parse, stored contracts included, so that a -/// declaration handed to a parse without full validation cannot drive the -/// parser, or the evaluation of what it builds, into unbounded recursion. A -/// registrable rule stays far below it: it has at most +/// The deepest a condition or an operand may sit in its rule: the rule's own +/// condition at depth 0, and each operand of a comparison, and each condition +/// under `anyOf`, `allOf` or `not`, one level deeper than what holds it. +/// Checked on every parse, stored contracts included, so that a declaration +/// handed to a parse without full validation cannot drive the parser, or the +/// evaluation of what it builds, into unbounded recursion. A registrable rule +/// stays far below it: it has at most /// `SystemLimits::max_property_constraint_nodes` nodes, so it is never deeper /// than that (a test holds every protocol version's limit to it). A constant /// rather than a limit, like `MAX_REFERENCE_EXPRESSION_DECODE_DEPTH`, so that /// no change to a limit can make a stored contract unparseable. pub const MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH: usize = 64; -/// How the two sides of a rule must compare. +/// How the two sides of a comparison must compare. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConstraintComparison { /// `equal`: the two sides are the same number. @@ -234,6 +244,23 @@ impl ConstraintExpression { } } + /// Whether the expression reads at least one property. + fn reads_property(&self) -> bool { + match self { + ConstraintExpression::Value(_) => false, + ConstraintExpression::Property { .. } => true, + ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { + operands.iter().any(ConstraintExpression::reads_property) + } + ConstraintExpression::Subtract(left, right) + | ConstraintExpression::Divide(left, right) + | ConstraintExpression::Modulo(left, right) + | ConstraintExpression::Power(left, right) => { + left.reads_property() || right.reads_property() + } + } + } + /// Appends the dotted paths of the properties the expression reads to /// `paths`, in the order it reads them. fn collect_property_paths<'a>(&'a self, paths: &mut Vec<&'a str>) { @@ -256,47 +283,162 @@ impl ConstraintExpression { } } -/// One rule of `propertyConstraints`: its two sides must compare as -/// `comparison` says. +/// A rule of `propertyConstraints`, or a condition inside one: a comparison of +/// two integer expressions, or `anyOf`, `allOf` or `not` over conditions. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct PropertyConstraint { - pub comparison: ConstraintComparison, - pub left: ConstraintExpression, - pub right: ConstraintExpression, +pub enum PropertyConstraint { + /// A comparison: the two sides must compare as `comparison` says. + Compare { + comparison: ConstraintComparison, + left: ConstraintExpression, + right: ConstraintExpression, + }, + /// `anyOf`: at least one of two or more conditions holds. + AnyOf(Vec), + /// `allOf`: every one of two or more conditions holds. + AllOf(Vec), + /// `not`: the condition does not hold. + Not(Box), } impl PropertyConstraint { + /// Whether a document whose properties are `data` meets the condition. + /// + /// Evaluated left to right, and no further than the outcome needs: a + /// comparison evaluates its left side, then its right one; `anyOf` checks + /// its conditions in declared order and holds at the first that holds; + /// `allOf` fails at the first that fails; `not` inverts its condition. The + /// first fault an evaluated expression meets ([`ConstraintExpression::evaluate`]) + /// is returned whatever the conditions left unevaluated would say, and `not` + /// never turns a fault into a pass. So an earlier condition guards a later + /// one: `anyOf: [{ equal: ["b", 0] }, { equal: [{ divide: ["a", "b"] }, 2] }]` + /// holds for a `b` of 0 without dividing by it, while the same two + /// conditions the other way round divide by zero. + pub fn holds(&self, data: &Value) -> Result { + match self { + PropertyConstraint::Compare { + comparison, + left, + right, + } => { + let (left, right) = (left.evaluate(data)?, right.evaluate(data)?); + Ok(comparison.holds(left, right)) + } + PropertyConstraint::AnyOf(conditions) => { + for condition in conditions { + if condition.holds(data)? { + return Ok(true); + } + } + Ok(false) + } + PropertyConstraint::AllOf(conditions) => { + for condition in conditions { + if !condition.holds(data)? { + return Ok(false); + } + } + Ok(true) + } + PropertyConstraint::Not(condition) => Ok(!condition.holds(data)?), + } + } + /// Why a document whose properties are `data` breaks the rule, `None` when - /// it meets it. The left side is evaluated before the right one, so a fault - /// on both sides is reported from the left. + /// it meets it: the first fault met on the way ([`Self::holds`]), or + /// [`PropertyConstraintViolation::NotMet`] when the rule evaluates to false. pub fn violation(&self, data: &Value) -> Option { - let left = match self.left.evaluate(data) { - Ok(left) => left, - Err(violation) => return Some(violation), - }; - let right = match self.right.evaluate(data) { - Ok(right) => right, - Err(violation) => return Some(violation), - }; - (!self.comparison.holds(left, right)).then_some(PropertyConstraintViolation::NotMet) + match self.holds(data) { + Ok(true) => None, + Ok(false) => Some(PropertyConstraintViolation::NotMet), + Err(violation) => Some(violation), + } } /// The nodes of the rule, counted against - /// `SystemLimits::max_property_constraint_nodes`: its comparison, every - /// operator and every operand (an integer value, or a property with or - /// without `ifAbsent`). + /// `SystemLimits::max_property_constraint_nodes`: every comparison and + /// logical operator, every arithmetic operator and every operand (an + /// integer value, or a property with or without `ifAbsent`). pub fn node_count(&self) -> usize { - 1 + self.left.node_count() + self.right.node_count() + 1 + match self { + PropertyConstraint::Compare { left, right, .. } => { + left.node_count() + right.node_count() + } + PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { + conditions.iter().map(PropertyConstraint::node_count).sum() + } + PropertyConstraint::Not(condition) => condition.node_count(), + } } - /// The dotted paths of the properties the rule reads, in the order it reads - /// them, a path read twice listed twice. + /// The dotted paths of the properties the rule reads, in declared order, a + /// path read twice listed twice. pub fn property_paths(&self) -> Vec<&str> { let mut paths = Vec::new(); - self.left.collect_property_paths(&mut paths); - self.right.collect_property_paths(&mut paths); + self.collect_property_paths(&mut paths); paths } + + /// Where an `anyOf` or `allOf` of the rule lists the same condition twice: + /// the repeat's place and the earlier one's (`anyOf[2]` and `anyOf[0]`), the + /// first found in declared order, `None` when no list does. Conditions are + /// alike when they parse alike, so `1` and `1.0` are the same value, and so + /// are `"price"` and `{ "ifAbsent": ["price", 0] }`. Checked under full + /// validation with the limits, which bound the lists it compares; a stored + /// rule was checked when its contract registered. + pub fn repeated_condition(&self) -> Option<(String, String)> { + self.find_repeated_condition(&mut String::new()) + } + + /// [`Self::repeated_condition`] for the condition at `at` (empty for the + /// rule's own), which is extended as the walk descends and trimmed back + /// when it returns `None`. + fn find_repeated_condition(&self, at: &mut String) -> Option<(String, String)> { + let (key, conditions) = match self { + PropertyConstraint::Compare { .. } => return None, + PropertyConstraint::AnyOf(conditions) => (ANY_OF, conditions), + PropertyConstraint::AllOf(conditions) => (ALL_OF, conditions), + PropertyConstraint::Not(condition) => { + let parent = enter(at, NOT); + let found = condition.find_repeated_condition(at); + at.truncate(parent); + return found; + } + }; + let parent = enter(at, key); + let base = at.len(); + for (index, condition) in conditions.iter().enumerate() { + if let Some(earlier) = conditions[..index] + .iter() + .position(|earlier| earlier == condition) + { + return Some((format!("{at}[{index}]"), format!("{at}[{earlier}]"))); + } + // Writing to a `String` cannot fail + let _ = write!(at, "[{index}]"); + if let Some(found) = condition.find_repeated_condition(at) { + return Some(found); + } + at.truncate(base); + } + at.truncate(parent); + None + } + + fn collect_property_paths<'a>(&'a self, paths: &mut Vec<&'a str>) { + match self { + PropertyConstraint::Compare { left, right, .. } => { + left.collect_property_paths(paths); + right.collect_property_paths(paths); + } + PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { + for condition in conditions { + condition.collect_property_paths(paths); + } + } + PropertyConstraint::Not(condition) => condition.collect_property_paths(paths), + } + } } /// Reads the `propertyConstraints` keyword of a document type's `schema`: @@ -305,17 +447,21 @@ impl PropertyConstraint { /// /// The rules of the declaration's shape are checked here, on every parse: an /// object of one or more rules, each named with 1 to 64 letters, digits or -/// underscores and holding one comparison of exactly two operands. An operand -/// is an integer value, a property path, or an object with one key: `ifAbsent` -/// with a path and an integer value, `add` or `multiply` with two or more -/// operands, or `subtract`, `divide`, `modulo` or `power` with exactly two. -/// An integer value may be spelled as a float with no fractional part, as the -/// meta-schema's `integer` type admits one. A literal 0 divisor, a literal -/// negative exponent, a rule that reads no property, which would hold for every -/// document or for none, and an operand deeper than -/// [`MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH`] are refused. +/// underscores and holding one condition. A condition is an object with one +/// key: a comparison of exactly two operands, `anyOf` or `allOf` with two or +/// more conditions, none of them directly the same operator (it says what one +/// flat list says), or `not` with one condition that is not directly another +/// `not`. An operand is an integer value, a property path, or +/// an object with one key: `ifAbsent` with a path and an integer value, `add` +/// or `multiply` with two or more operands, or `subtract`, `divide`, `modulo` +/// or `power` with exactly two. An integer value may be spelled as a float with +/// no fractional part, as the meta-schema's `integer` type admits one. A +/// literal 0 divisor, a literal negative exponent, a comparison that reads no +/// property, which would hold for every document or for none, and a condition +/// or operand deeper than [`MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH`] are refused. /// What the paths name is checked against the parsed document type, and the -/// limits under full validation, by parser generation 3. +/// limits and that no list repeats a condition under full validation, by +/// parser generation 3. pub fn parse_property_constraints( schema: &Value, document_type_name: &str, @@ -353,14 +499,10 @@ pub fn parse_property_constraints( name.non_qualified_string_representation() ))); }; - let constraint = parse_rule(rule) + // Where a condition or an operand sits in the rule (`anyOf[1].lessThan[0]`), + // grown and trimmed in place as the parse descends and only read into an error + let constraint = parse_condition(rule, &mut String::new(), 0) .map_err(|message| structure_error(format!("rule \"{name}\" {message}")))?; - if constraint.property_paths().is_empty() { - return Err(structure_error(format!( - "rule \"{name}\" reads no property, so it would hold for every document or for \ - none" - ))); - } if constraints.insert(name.to_string(), constraint).is_some() { return Err(structure_error(format!("declares rule \"{name}\" twice"))); } @@ -389,38 +531,132 @@ fn single_entry(value: &Value) -> Option<(&str, &Value)> { Some((key.as_text()?, value)) } -/// One rule: an object whose one key names the comparison and lists its two -/// sides. The error is the rest of a message naming the rule. -fn parse_rule(rule: &Value) -> Result { - let comparison_names = || { +/// Every key a condition object may hold, for the errors. +fn condition_keys() -> String { + format!( + "a comparison ({}), anyOf, allOf or not", ConstraintComparison::ALL .map(ConstraintComparison::wire_name) .join(", ") - }; - let Some((key, sides)) = single_entry(rule) else { + ) +} + +/// `at ` followed by where something sits in its rule, nothing for the rule's +/// own condition, to open the rest of an error naming the rule. +fn located(at: &str) -> String { + if at.is_empty() { + String::new() + } else { + format!("at {at} ") + } +} + +/// Extends `at`, where a condition sits in its rule (empty for the rule's +/// own), to the place of its `key`, returning the length to trim it back to. +fn enter(at: &mut String, key: &str) -> usize { + let parent = at.len(); + if parent > 0 { + at.push('.'); + } + at.push_str(key); + parent +} + +/// A condition at `at` (`anyOf[1]`, empty for the rule's own), where the +/// errors place it, `depth` levels into its rule: an object whose one key is a +/// comparison listing its two sides, or `anyOf`, `allOf` or `not`. The error is +/// the rest of a message naming the rule. `at` is extended for what the +/// condition holds and trimmed back before a successful return. +fn parse_condition( + value: &Value, + at: &mut String, + depth: usize, +) -> Result { + if depth > MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH { return Err(format!( - "must be an object with one key, its comparison: {}", - comparison_names() + "{}nests deeper than {MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH} levels", + located(at) )); - }; - let Some(comparison) = ConstraintComparison::ALL - .into_iter() - .find(|comparison| comparison.wire_name() == key) - else { + } + let Some((key, body)) = single_entry(value) else { return Err(format!( - "compares with \"{key}\", which is not a comparison: {}", - comparison_names() + "{}must be an object with one key: {}", + located(at), + condition_keys() )); }; - // Where an operand sits in the rule (`lessThan[0].add[1]`), grown and trimmed - // in place as the parse descends and only read into an error - let mut at = key.to_string(); - let (left, right) = operand_pair(sides, &mut at, 1)?; - Ok(PropertyConstraint { - comparison, - left, - right, - }) + let parent = enter(at, key); + let condition = match key { + ANY_OF => PropertyConstraint::AnyOf(condition_list(body, key, at, depth + 1)?), + ALL_OF => PropertyConstraint::AllOf(condition_list(body, key, at, depth + 1)?), + NOT => { + if single_entry(body).is_some_and(|(inner, _)| inner == NOT) { + return Err(format!( + "at {at}.{NOT} is a not directly inside a not, which says what the \ + condition inside it says: declare that condition" + )); + } + PropertyConstraint::Not(Box::new(parse_condition(body, at, depth + 1)?)) + } + _ => { + let Some(comparison) = ConstraintComparison::ALL + .into_iter() + .find(|comparison| comparison.wire_name() == key) + else { + at.truncate(parent); + return Err(format!( + "{}names \"{key}\", which is not {}", + located(at), + condition_keys() + )); + }; + let (left, right) = operand_pair(body, at, depth + 1)?; + if !left.reads_property() && !right.reads_property() { + at.truncate(parent); + return Err(format!( + "{}reads no property, so it would hold for every document or for none", + located(at) + )); + } + PropertyConstraint::Compare { + comparison, + left, + right, + } + } + }; + at.truncate(parent); + Ok(condition) +} + +/// The two or more conditions the `anyOf` or `allOf` named `key` lists at +/// `at`, `depth` levels into their rule, none of them directly another `key`, +/// which says what one flat list says. That no two are alike is checked under +/// full validation ([`PropertyConstraint::repeated_condition`]). +fn condition_list( + conditions: &Value, + key: &str, + at: &mut String, + depth: usize, +) -> Result, String> { + let Some(values) = conditions.as_array().filter(|values| values.len() >= 2) else { + return Err(format!("at {at} must list two or more conditions")); + }; + let base = at.len(); + let mut parsed = Vec::with_capacity(values.len()); + for (index, value) in values.iter().enumerate() { + // Writing to a `String` cannot fail + let _ = write!(at, "[{index}]"); + if single_entry(value).is_some_and(|(inner, _)| inner == key) { + return Err(format!( + "at {at} is an {key} directly inside an {key}, which says what one flat list \ + says: list its conditions in the outer {key}" + )); + } + parsed.push(parse_condition(value, at, depth)?); + at.truncate(base); + } + Ok(parsed) } /// An operand at `at` (`lessThan[0].add[1]`), where the errors place it, diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index 09c63e5dff3..70fc920d0f3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -45,9 +45,18 @@ fn data(entries: &[(&str, Value)]) -> Value { /// The value of `expression`, parsed as the left side of an `equal`, for `data`. fn evaluate(expression: Value, data: &Value) -> Result { - parse_rule_value(platform_value!({ "equal": [expression, "anchor"] })) - .left - .evaluate(data) + match parse_rule_value(platform_value!({ "equal": [expression, "anchor"] })) { + PropertyConstraint::Compare { left, .. } => left.evaluate(data), + other => panic!("an equal parses to a comparison, got {other:?}"), + } +} + +/// The comparison `rule` is, which it must be. +fn comparison_of(rule: &PropertyConstraint) -> ConstraintComparison { + match rule { + PropertyConstraint::Compare { comparison, .. } => *comparison, + other => panic!("expected a comparison, got {other:?}"), + } } // ── parsing ───────────────────────────────────────────────────────────── @@ -93,7 +102,7 @@ fn should_parse_every_operator_comparison_and_the_if_absent_operand() { ); assert_eq!( rules["depositCoversOrder"], - PropertyConstraint { + PropertyConstraint::Compare { comparison: ConstraintComparison::LessThanOrEqual, left: ConstraintExpression::Multiply(vec![ ConstraintExpression::Add(vec![property("price"), property("fee")]), @@ -104,7 +113,7 @@ fn should_parse_every_operator_comparison_and_the_if_absent_operand() { ); assert_eq!( rules["wholeLots"], - PropertyConstraint { + PropertyConstraint::Compare { comparison: ConstraintComparison::Equal, left: ConstraintExpression::Modulo( Box::new(property("quantity")), @@ -115,7 +124,7 @@ fn should_parse_every_operator_comparison_and_the_if_absent_operand() { ); assert_eq!( rules["minimumOrder"], - PropertyConstraint { + PropertyConstraint::Compare { comparison: ConstraintComparison::GreaterThanOrEqual, left: ConstraintExpression::Multiply(vec![ property("price"), @@ -129,7 +138,7 @@ fn should_parse_every_operator_comparison_and_the_if_absent_operand() { ); assert_eq!( rules["rest"], - PropertyConstraint { + PropertyConstraint::Compare { comparison: ConstraintComparison::NotEqual, left: ConstraintExpression::Subtract( Box::new(ConstraintExpression::Divide( @@ -144,8 +153,14 @@ fn should_parse_every_operator_comparison_and_the_if_absent_operand() { right: ConstraintExpression::Value(-5), } ); - assert_eq!(rules["less"].comparison, ConstraintComparison::LessThan); - assert_eq!(rules["more"].comparison, ConstraintComparison::GreaterThan); + assert_eq!( + comparison_of(&rules["less"]), + ConstraintComparison::LessThan + ); + assert_eq!( + comparison_of(&rules["more"]), + ConstraintComparison::GreaterThan + ); } #[test] @@ -173,15 +188,15 @@ fn should_refuse_a_malformed_declaration() { ), ( platform_value!({ "rule": ["price", 1] }), - "rule \"rule\" must be an object with one key, its comparison", + "rule \"rule\" must be an object with one key: a comparison (equal, notEqual", ), ( platform_value!({ "rule": { "equal": ["price", 1], "lessThan": ["price", 1] } }), - "rule \"rule\" must be an object with one key, its comparison", + "rule \"rule\" must be an object with one key: a comparison (equal, notEqual", ), ( platform_value!({ "rule": { "atMost": ["price", 1] } }), - "compares with \"atMost\", which is not a comparison", + "rule \"rule\" names \"atMost\", which is not a comparison (equal", ), ( platform_value!({ "rule": { "equal": ["price"] } }), @@ -275,7 +290,7 @@ fn should_read_a_float_literal_without_a_fractional_part_as_an_integer() { })); assert_eq!( rule, - PropertyConstraint { + PropertyConstraint::Compare { comparison: ConstraintComparison::Equal, left: ConstraintExpression::Property { path: "price".to_string(), @@ -335,6 +350,261 @@ fn should_count_nodes_and_list_the_paths_a_rule_reads() { assert_eq!(rule.property_paths(), ["price", "fee", "price"]); } +// ── anyOf, allOf and not ──────────────────────────────────────────────── + +fn compare( + comparison: ConstraintComparison, + left: ConstraintExpression, + right: ConstraintExpression, +) -> PropertyConstraint { + PropertyConstraint::Compare { + comparison, + left, + right, + } +} + +#[test] +fn should_parse_any_of_all_of_and_not() { + let rules = parse(platform_value!({ + "aIsZeroOrBIsFour": { "anyOf": [{ "equal": ["a", 0] }, { "equal": ["b", 4] }] }, + "notBoth": { + "not": { "allOf": [{ "greaterThan": ["a", 0] }, { "greaterThan": ["b", 0] }] } + }, + "nested": { + "allOf": [ + { "anyOf": [{ "equal": ["a", 0] }, { "lessThan": ["a", "b"] }] }, + { "not": { "equal": [{ "ifAbsent": ["b", 1] }, 3] } } + ] + } + })) + .expect("the declaration parses"); + + let a_is = |value| { + compare( + ConstraintComparison::Equal, + property("a"), + ConstraintExpression::Value(value), + ) + }; + assert_eq!( + rules["aIsZeroOrBIsFour"], + PropertyConstraint::AnyOf(vec![ + a_is(0), + compare( + ConstraintComparison::Equal, + property("b"), + ConstraintExpression::Value(4) + ), + ]) + ); + assert_eq!( + rules["notBoth"], + PropertyConstraint::Not(Box::new(PropertyConstraint::AllOf(vec![ + compare( + ConstraintComparison::GreaterThan, + property("a"), + ConstraintExpression::Value(0) + ), + compare( + ConstraintComparison::GreaterThan, + property("b"), + ConstraintExpression::Value(0) + ), + ]))) + ); + assert_eq!( + rules["nested"], + PropertyConstraint::AllOf(vec![ + PropertyConstraint::AnyOf(vec![ + a_is(0), + compare(ConstraintComparison::LessThan, property("a"), property("b")), + ]), + PropertyConstraint::Not(Box::new(compare( + ConstraintComparison::Equal, + ConstraintExpression::Property { + path: "b".to_string(), + if_absent: 1 + }, + ConstraintExpression::Value(3) + ))), + ]) + ); +} + +/// The errors place a fault by its path through the conditions, then through the +/// operands of the comparison it sits in. +#[test] +fn should_refuse_a_malformed_condition() { + let one = platform_value!({ "equal": ["price", 1] }); + let two = platform_value!({ "equal": ["price", 2] }); + let fee = platform_value!({ "equal": ["fee", 1] }); + let cases = [ + ( + platform_value!({ "anyOf": [one.clone()] }), + "rule \"rule\" at anyOf must list two or more conditions", + ), + ( + platform_value!({ "allOf": one.clone() }), + "rule \"rule\" at allOf must list two or more conditions", + ), + ( + platform_value!({ "allOf": [] }), + "rule \"rule\" at allOf must list two or more conditions", + ), + ( + platform_value!({ "not": [one.clone()] }), + "rule \"rule\" at not must be an object with one key: a comparison", + ), + ( + platform_value!({ "anyOf": [one.clone(), two.clone()], "equal": ["price", 3] }), + "rule \"rule\" must be an object with one key: a comparison", + ), + ( + platform_value!({ "anyOf": [one.clone(), ["price", 1]] }), + "rule \"rule\" at anyOf[1] must be an object with one key: a comparison", + ), + ( + platform_value!({ "anyOf": [one.clone(), { "or": [one.clone(), two.clone()] }] }), + "rule \"rule\" at anyOf[1] names \"or\", which is not a comparison", + ), + // A flat list says the same + ( + platform_value!({ "anyOf": [{ "anyOf": [one.clone(), two.clone()] }, fee.clone()] }), + "rule \"rule\" at anyOf[0] is an anyOf directly inside an anyOf", + ), + ( + platform_value!({ "allOf": [fee.clone(), { "allOf": [one.clone(), two.clone()] }] }), + "rule \"rule\" at allOf[1] is an allOf directly inside an allOf", + ), + // A double negation says what the condition inside it says + ( + platform_value!({ "not": { "not": one.clone() } }), + "rule \"rule\" at not.not is a not directly inside a not", + ), + // Every comparison reads a property, not only the rule as a whole: a constant + // one would make the anyOf hold for every document + ( + platform_value!({ "anyOf": [one.clone(), { "equal": [1, 1] }] }), + "rule \"rule\" at anyOf[1] reads no property", + ), + ( + platform_value!({ "not": { "equal": [2, { "add": [1, 1] }] } }), + "rule \"rule\" at not reads no property", + ), + ( + platform_value!({ + "allOf": [one.clone(), { "not": { "lessThan": [{ "divide": ["price", 0] }, 1] } }] + }), + "rule \"rule\" at allOf[1].not.lessThan[0].divide divides by 0", + ), + ( + platform_value!({ "anyOf": [one.clone(), { "equal": ["price"] }] }), + "rule \"rule\" at anyOf[1].equal must list exactly two operands", + ), + ]; + for (condition, needle) in cases { + expect_refusal(platform_value!({ "rule": condition }), needle); + } +} + +/// Conditions nest within the same cap as operands: the rule's own condition is at +/// depth 0, and whatever a condition holds one level deeper. +#[test] +fn should_refuse_conditions_nested_deeper_than_the_parse_depth_cap() { + let nested = |levels: usize| { + let mut condition = platform_value!({ "equal": ["price", 0] }); + for level in 0..levels { + // Alternating, since an anyOf directly inside an anyOf is refused + let key = if level % 2 == 0 { ANY_OF } else { ALL_OF }; + let sibling = platform_value!({ "equal": ["price", level as u64 + 1] }); + condition = Value::Map(vec![( + Value::Text(key.to_string()), + Value::Array(vec![condition, sibling]), + )]); + } + platform_value!({ "rule": condition }) + }; + // The innermost comparison sits `levels` deep, its operands one deeper + parse(nested(MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH - 1)).expect("at the cap"); + expect_refusal( + nested(MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH), + &format!("equal[0] nests deeper than {MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH} levels"), + ); + // One level more puts the comparison itself past the cap, inside the anyOf of + // the first level, and the condition parse refuses it before its operands + expect_refusal( + nested(MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH + 1), + &format!("anyOf[0] nests deeper than {MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH} levels"), + ); +} + +/// A list that repeats a condition is found where it sits, the first in declared +/// order, comparing conditions as they parse. The parse itself accepts it: the check +/// runs under full validation. +#[test] +fn should_find_a_condition_an_any_of_or_all_of_repeats() { + let one = platform_value!({ "equal": ["price", 1] }); + let two = platform_value!({ "equal": ["price", 2] }); + let fee = platform_value!({ "equal": ["fee", 1] }); + for (condition, expected) in [ + ( + platform_value!({ "anyOf": [one.clone(), two.clone()] }), + None, + ), + // The same condition in two different lists is no repeat + ( + platform_value!({ + "allOf": [ + { "anyOf": [one.clone(), fee.clone()] }, + { "anyOf": [one.clone(), two.clone()] } + ] + }), + None, + ), + ( + platform_value!({ "anyOf": [one.clone(), two.clone(), one.clone()] }), + Some(("anyOf[2]", "anyOf[0]")), + ), + // Alike once parsed: JSON does not tell `1` from `1.0`, and a path on its own + // reads as `ifAbsent` 0 + ( + platform_value!({ "allOf": [one.clone(), { "equal": ["price", 1.0] }] }), + Some(("allOf[1]", "allOf[0]")), + ), + ( + platform_value!({ + "anyOf": [one.clone(), { "equal": [{ "ifAbsent": ["price", 0] }, 1] }] + }), + Some(("anyOf[1]", "anyOf[0]")), + ), + // Found through a not, inside a nested list + ( + platform_value!({ + "allOf": [ + fee.clone(), + { "not": { "anyOf": [two.clone(), one.clone(), two.clone()] } } + ] + }), + Some(("allOf[1].not.anyOf[2]", "allOf[1].not.anyOf[0]")), + ), + // The first repeat in declared order + ( + platform_value!({ + "anyOf": [{ "allOf": [fee.clone(), fee.clone()] }, one.clone(), one.clone()] + }), + Some(("anyOf[0].allOf[1]", "anyOf[0].allOf[0]")), + ), + ] { + let rule = parse_rule_value(condition.clone()); + assert_eq!( + rule.repeated_condition(), + expected.map(|(repeat, earlier)| (repeat.to_string(), earlier.to_string())), + "{condition:?}" + ); + } +} + // ── evaluation ────────────────────────────────────────────────────────── #[test] @@ -593,3 +863,108 @@ fn should_report_whether_a_rule_holds_and_the_left_fault_first() { ); } } + +/// `a == 0 || b == 4`, and `allOf` and `not` over the same comparisons. +#[test] +fn should_combine_conditions_with_any_of_all_of_and_not() { + let any_of = parse_rule_value(platform_value!({ + "anyOf": [{ "equal": ["a", 0] }, { "equal": ["b", 4] }] + })); + let all_of = parse_rule_value(platform_value!({ + "allOf": [{ "equal": ["a", 0] }, { "equal": ["b", 4] }] + })); + let not = parse_rule_value(platform_value!({ + "not": { "anyOf": [{ "equal": ["a", 0] }, { "equal": ["b", 4] }] } + })); + for (a, b, either, both) in [ + (0, 4, true, true), + (0, 5, true, false), + (1, 4, true, false), + (1, 5, false, false), + ] { + let values = data(&[("a", Value::U64(a)), ("b", Value::U64(b))]); + assert_eq!(any_of.holds(&values), Ok(either), "a {a}, b {b}: anyOf"); + assert_eq!(all_of.holds(&values), Ok(both), "a {a}, b {b}: allOf"); + assert_eq!(not.holds(&values), Ok(!either), "a {a}, b {b}: not"); + assert_eq!( + any_of.violation(&values), + (!either).then_some(PropertyConstraintViolation::NotMet), + "a {a}, b {b}" + ); + } + // An absent property still counts as 0 + assert_eq!(any_of.holds(&data(&[("b", Value::U64(5))])), Ok(true)); +} + +/// Conditions are checked in declared order, no further than the outcome needs, so an +/// earlier one guards a later one; a fault in a condition that is checked breaks the rule +/// whatever the others would say, and `not` does not turn it into a pass. +#[test] +fn should_stop_at_the_outcome_and_break_the_rule_on_the_first_fault() { + let quotient_is_two = platform_value!({ "equal": [{ "divide": ["a", "b"] }, 2] }); + let guarded_any_of = parse_rule_value(platform_value!({ + "anyOf": [{ "equal": ["b", 0] }, quotient_is_two.clone()] + })); + let unguarded_any_of = parse_rule_value(platform_value!({ + "anyOf": [quotient_is_two.clone(), { "equal": ["b", 0] }] + })); + let guarded_all_of = parse_rule_value(platform_value!({ + "allOf": [{ "notEqual": ["b", 0] }, quotient_is_two.clone()] + })); + let negated = parse_rule_value(platform_value!({ "not": quotient_is_two })); + + let values = |a: u64, b: u64| data(&[("a", Value::U64(a)), ("b", Value::U64(b))]); + let zero_divisor = values(6, 0); + assert_eq!(guarded_any_of.violation(&zero_divisor), None); + assert_eq!( + unguarded_any_of.violation(&zero_divisor), + Some(PropertyConstraintViolation::DivisionByZero) + ); + assert_eq!( + guarded_all_of.violation(&zero_divisor), + Some(PropertyConstraintViolation::NotMet) + ); + assert_eq!( + negated.violation(&zero_divisor), + Some(PropertyConstraintViolation::DivisionByZero) + ); + + // 4 / 2 = 2, 6 / 2 = 3 + for rule in [&guarded_any_of, &unguarded_any_of, &guarded_all_of] { + assert_eq!(rule.violation(&values(4, 2)), None, "{rule:?}"); + assert_eq!( + rule.violation(&values(6, 2)), + Some(PropertyConstraintViolation::NotMet), + "{rule:?}" + ); + } + assert_eq!( + negated.violation(&values(4, 2)), + Some(PropertyConstraintViolation::NotMet) + ); + assert_eq!(negated.violation(&values(6, 2)), None); + + // An allOf stops at the first condition that fails, before a later fault + let fails_before_the_fault = parse_rule_value(platform_value!({ + "allOf": [{ "equal": ["a", 1] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] + })); + assert_eq!( + fails_before_the_fault.violation(&zero_divisor), + Some(PropertyConstraintViolation::NotMet) + ); +} + +/// Every comparison and logical operator is a node, and the paths are listed in declared +/// order. +#[test] +fn should_count_the_nodes_and_list_the_paths_of_combined_conditions() { + let rule = parse_rule_value(platform_value!({ + "anyOf": [ + { "equal": ["a", 0] }, + { "not": { "equal": [{ "ifAbsent": ["b", 1] }, "a"] } } + ] + })); + // anyOf, equal, a, 0, not, equal, ifAbsent b, a + assert_eq!(rule.node_count(), 8); + assert_eq!(rule.property_paths(), ["a", "b", "a"]); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 3e43678a3b2..db4f2dc5637 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -168,8 +168,9 @@ pub struct DocumentTypeV2 { pub(in crate::data_contract) creator_reference: Option, /// The rules every created or replaced document must meet, by name, in the /// order they are checked (`propertyConstraints` keyword, protocol version - /// 14): each a comparison of two integer expressions over the document's - /// integer properties. Empty on document types that declare none. The + /// 14): each a condition on the document's integer properties, a comparison + /// of two integer expressions or an `anyOf`, `allOf` or `not` of conditions. + /// Empty on document types that declare none. The /// parser (`apply_property_constraints`) holds every property a rule reads /// to be an integer that is neither transient nor inside a transient object. pub(in crate::data_contract) property_constraints: BTreeMap, diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/document_property_constraint_violated_error.rs b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_constraint_violated_error.rs index f372c00e23f..87706335cce 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/document/document_property_constraint_violated_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_constraint_violated_error.rs @@ -13,7 +13,8 @@ use thiserror::Error; /// Encoded by position in consensus errors: a new reason goes at the end. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] pub enum PropertyConstraintViolation { - /// Both sides of the rule evaluate, but they do not compare as it requires. + /// The rule evaluates without a fault but does not hold: its comparison + /// does not, or its `anyOf`, `allOf` or `not` comes out false. NotMet, /// A value the rule reads, or a result it computes on the way, does not fit /// a 128-bit signed integer. @@ -32,7 +33,7 @@ pub enum PropertyConstraintViolation { impl fmt::Display for PropertyConstraintViolation { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { - Self::NotMet => "its two sides do not compare as it requires", + Self::NotMet => "it does not hold", Self::Overflow => "a value it reads or computes does not fit a 128-bit signed integer", Self::DivisionByZero => "it divides by zero", Self::NegativeExponent => "it raises to a negative power", @@ -42,9 +43,9 @@ impl fmt::Display for PropertyConstraintViolation { } /// A created or replaced document breaks a rule of its document type's -/// `propertyConstraints`: the comparison does not hold, or evaluating it -/// overflowed, divided by zero, raised to a negative power or read a value that -/// is not an integer. +/// `propertyConstraints`: the rule does not hold, or evaluating it overflowed, +/// divided by zero, raised to a negative power or read a value that is not an +/// integer. /// /// A pure structure check on document create and replace (protocol version 14): /// it reads the transition alone, so it is a basic error, not a state one. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index 98af6a90cb9..602681ed2e2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -1,6 +1,7 @@ //! End-to-end coverage for the `propertyConstraints` doctype keyword (protocol //! version 14): a document type names rules its documents' integer properties -//! must meet, each a comparison of two integer expressions. A create or replace +//! must meet, each a comparison of two integer expressions or an `anyOf`, +//! `allOf` or `not` of such conditions. A create or replace //! that breaks one is consensus-rejected with //! `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the rule //! and why, and leaves the stored document untouched. A property the document @@ -37,6 +38,8 @@ mod property_constraints_tests { /// * `boostPower`: `ifAbsent(boost, 1) ^ 20 >= 1`, which overflows for a large boost /// * `depositCoversOrder`: `(price + fee) * quantity <= deposit` /// * `discountBelowPrice`: `discount < price`, an absent discount counting as 0 + /// * `feeWaivedOnlyWithDiscount`: `!(fee == 0 && discount == 0)` + /// * `feeWaivedOrAtLeastTen`: `fee == 0 || fee >= 10` /// * `perUnitDeposit`: `deposit / quantity >= 1`, which divides by zero for no quantity fn offer_schema() -> Value { platform_value!({ @@ -68,6 +71,12 @@ mod property_constraints_tests { ] }, "discountBelowPrice": { "lessThan": ["discount", "price"] }, + "feeWaivedOnlyWithDiscount": { + "not": { "allOf": [{ "equal": ["fee", 0] }, { "equal": ["discount", 0] }] } + }, + "feeWaivedOrAtLeastTen": { + "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] + }, "perUnitDeposit": { "greaterThanOrEqual": [{ "divide": ["deposit", "quantity"] }, 1] } @@ -439,6 +448,44 @@ mod property_constraints_tests { assert!(fixture.stored_offers().is_empty()); } + /// A fee of 5 is neither waived nor at least 10, and a waived fee needs a + /// discount; a waived fee on a discounted offer meets both rules. + #[tokio::test] + async fn should_judge_any_of_all_of_and_not() { + let mut fixture = OfferFixture::new(); + + let result = fixture + .create(|document| document.set("fee", Value::U64(5))) + .await; + expect_violated( + result, + "feeWaivedOrAtLeastTen", + PropertyConstraintViolation::NotMet, + ); + + let result = fixture + .create(|document| document.set("fee", Value::U64(0))) + .await; + expect_violated( + result, + "feeWaivedOnlyWithDiscount", + PropertyConstraintViolation::NotMet, + ); + assert!(fixture.stored_offers().is_empty()); + + // (100 + 0) * 2 = 200 <= 220, and 10 < 100 + assert_matches!( + fixture + .create(|document| { + document.set("fee", Value::U64(0)); + document.set("discount", Value::U64(10)); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 1); + } + #[tokio::test] async fn should_judge_a_replace_against_the_rules() { let mut fixture = OfferFixture::new(); 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 a2a3766c207..cba4b6a73e1 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -53,9 +53,9 @@ pub struct SystemLimits { /// version 14), the only generation that parses `propertyConstraints`, and never /// reached before. pub max_property_constraints: u16, - /// Maximum number of nodes in one `propertyConstraints` rule: its comparison, every - /// arithmetic operator and every operand, an integer value or a property. An `ifAbsent` - /// operand is one node, the default it gives included. Refused under full validation + /// Maximum number of nodes in one `propertyConstraints` rule: every comparison and every + /// `anyOf`, `allOf` or `not`, every arithmetic operator and every operand, an integer + /// value or a property. An `ifAbsent` operand is one node, the default it gives included. Refused under full validation /// only, like `max_property_constraints`. Read by document type parser generation 3 /// (protocol version 14) and never reached before. pub max_property_constraint_nodes: u16, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index fff97b8f6be..0cb85e20854 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1044,21 +1044,30 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// /// 39. **Property constraints**: the doctype-level `propertyConstraints` /// keyword (meta-schema v3, `parse_property_constraints` 0) names rules a -/// document's integer properties must meet, each a comparison (`equal`, -/// `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan`, +/// document's integer properties must meet, each a condition: a comparison +/// (`equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan`, /// `greaterThanOrEqual`) of two integer expressions built from integer /// literals, property paths and `add`, `subtract`, `multiply`, `divide`, -/// `modulo` and `power`. A property the document leaves out counts as 0, -/// or as the value of an `ifAbsent` operand naming it. Arithmetic is exact +/// `modulo` and `power`, or `anyOf` or `allOf` over two or more conditions, +/// or `not` over one. A property the document leaves out counts as 0, or +/// as the value of an `ifAbsent` operand naming it. Arithmetic is exact /// `i128`: `divide` and `modulo` are Euclidean (the remainder is never /// negative), and an overflow, a zero divisor, a negative exponent or a /// value that is not an integer refuses the document rather than wrapping. -/// The parser checks that every path names an integer property that is -/// neither transient nor inside a transient object, and that no operand -/// nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every -/// parse, and under full validation the limits -/// `SystemLimits::max_property_constraints` (16 rules) and -/// `max_property_constraint_nodes` (32 per rule). +/// Conditions are checked in declared order and no further than the +/// outcome needs (`anyOf` stops at the first that holds, `allOf` at the +/// first that fails), a fault in one that is checked refuses the document +/// whatever the others say, and `not` never turns a fault into a pass, so +/// an earlier condition guards a later one. The parser checks that every +/// path names an integer property that is neither transient nor inside a +/// transient object, that every comparison reads a property, that an +/// `anyOf` or `allOf` holds none directly of its own kind, that a `not` +/// holds no `not` directly, and that no condition or operand nests deeper +/// than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every parse, and +/// under full validation the limits `SystemLimits::max_property_constraints` +/// (16 rules) and `max_property_constraint_nodes` (32 per rule, every +/// comparison and logical operator counting as one) and that no `anyOf` or +/// `allOf` lists the same condition twice. /// `DataContract::validate_document_properties` 0 (extended in place, inert /// before this version) calls `validate_property_constraints` /// (`validate_property_constraints` 0) after the schema validation, so diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 6059dd2cda2..16e16f3bcc0 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -255,9 +255,9 @@ impl DocumentMaxBytesErrorCodeWasm { #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum DocumentPropertyConstraintErrorCodeWasm { /// The written document breaks a rule of its document type's - /// `propertyConstraints`: the comparison does not hold, or evaluating it - /// overflowed, divided by zero, raised to a negative power or read a value - /// that is not an integer. + /// `propertyConstraints`: the rule (a comparison, or an `anyOf`, `allOf` or + /// `not` of them) does not hold, or evaluating it overflowed, divided by + /// zero, raised to a negative power or read a value that is not an integer. DocumentPropertyConstraintViolated = 10422, } From e8c4d1b29e6970e38af47825c62852c3b7c130c1 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 14:56:33 +0700 Subject: [PATCH 013/113] feat(platform)!: present and absent tests in propertyConstraints rules (PV14) (#5037) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/documents.md | 12 +- packages/js-evo-sdk/README.md | 7 +- .../document/v3/document-meta.json | 16 ++- .../class_methods/try_from_schema/mod.rs | 103 ++++++++++----- .../v3/property_constraints_tests.rs | 81 ++++++++++++ .../src/data_contract/document_type/mod.rs | 6 +- .../document_type/property_constraints/mod.rs | 122 +++++++++++++----- .../property_constraints/tests.rs | 115 +++++++++++++++++ .../src/data_contract/document_type/v2/mod.rs | 11 +- .../tests/document/property_constraints.rs | 38 +++++- .../src/version/system_limits/mod.rs | 6 +- .../rs-platform-version/src/version/v14.rs | 46 ++++--- packages/wasm-dpp2/src/consensus_error.rs | 6 +- 13 files changed, 459 insertions(+), 110 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 221603c5b4b..9a845b22f04 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -680,7 +680,7 @@ The check runs where the JSON schema validation of a document's properties runs, ## Property Constraints (`propertyConstraints`) -Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named rules the integer properties of every created or replaced document must meet, where JSON Schema can only bound one property at a time. Each rule is a condition: a comparison of two integer expressions, or `anyOf`, `allOf` or `not` over conditions. +Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named rules the properties of every created or replaced document must meet, where JSON Schema can only bound one property at a time. Each rule is a condition: a comparison of two integer expressions, a test of whether the document holds a property, or `anyOf`, `allOf` or `not` over conditions. ```json "propertyConstraints": { @@ -696,13 +696,17 @@ Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named }, "feeWaivedOrAtLeastTen": { "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] + }, + "discountGivenAboveZero": { + "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] } } ``` -The first rule reads `((price + fee) * quantity) <= deposit`, the last `fee == 0 || fee >= 10`. A rule's name is 1 to 64 letters, digits or underscores, and the rule is a condition, an object with one key: +The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeastTen` reads `fee == 0 || fee >= 10`, and the last lets an offer leave its discount out but not give a discount of 0. A rule's name is 1 to 64 letters, digits or underscores, and the rule is a condition, an object with one key: - a comparison, `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` or `greaterThanOrEqual`, listing the left and the right expression; +- `{ "present": path }`, holding if the document holds the property, and `{ "absent": path }`, holding if it leaves it out (a property set to null counts as left out). An operand reads a property the document leaves out as 0, so only these tell "not given" from "given as 0". They may name a property of any type, an object or a member of one included, since they read no value; - `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; - `{ "allOf": [...] }`, holding if every one of two or more conditions holds; - `{ "not": condition }`, holding if its one condition does not. @@ -721,11 +725,11 @@ The arithmetic is exact over `i128`. Operands are evaluated left to right, and e Conditions are checked in declared order and no further than the outcome needs: a comparison evaluates its left side, then its right; `anyOf` stops at the first condition that holds and `allOf` at the first that fails. A fault in a condition that is checked breaks the rule whatever the others would say, and `not` does not turn it into a pass. So an earlier condition guards a later one: `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` holds for a `b` of 0 without dividing by it, while the same two conditions the other way round divide by zero and break the rule. -The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path names an integer property of the type (a nested one by its dotted path) that is neither `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. +The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer property of the type (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `present` or `absent`, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. Transfers, purchases and price updates change no property and are not judged. -In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`, a comparison or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. +In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and whether by value or by presence), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. ## Rules and Guidelines diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 85bc7e07571..dc2dc9fa9a0 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -399,7 +399,7 @@ try { ## Property constraints (`propertyConstraints`) -From protocol version 14 a document type can declare rules its documents' integer properties must meet, each a comparison of two integer expressions built from property paths and integer values, or `anyOf`, `allOf` or `not` over such conditions: +From protocol version 14 a document type can declare rules its documents' properties must meet, each a comparison of two integer expressions built from property paths and integer values, a `present` or `absent` test, or `anyOf`, `allOf` or `not` over such conditions: ```json "propertyConstraints": { @@ -414,11 +414,14 @@ From protocol version 14 a document type can declare rules its documents' intege }, "feeWaivedOrAtLeastTen": { "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] + }, + "discountGivenAboveZero": { + "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] } } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 8fdc957f045..572394a9ba3 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's integer properties, comparisons between integer expressions combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's properties, comparisons between integer expressions and present or absent tests combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "referenceOperands": { @@ -40,7 +40,7 @@ } }, "propertyConstraint": { - "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right, or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", + "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", "type": "object", "properties": { "equal": { @@ -61,6 +61,14 @@ "greaterThanOrEqual": { "$ref": "#/$defs/propertyConstraintOperandPair" }, + "present": { + "description": "Holds if the document holds the property at this path, of any type, an object included, with a value other than null. Unlike an operand, which reads a property the document leaves out as 0, it tells a property left out from one set to 0", + "$ref": "#/$defs/propertyConstraintPath" + }, + "absent": { + "description": "Holds if the document leaves the property at this path out, or sets it to null", + "$ref": "#/$defs/propertyConstraintPath" + }, "anyOf": { "description": "Holds if at least one of its conditions holds, checked in declared order and stopping at the first that holds: two or more conditions, no two alike, none of them directly an anyOf (it says what one flat list says)", "$ref": "#/$defs/propertyConstraintConditions", @@ -157,7 +165,7 @@ } }, "propertyConstraintPath": { - "description": "The dotted path of an integer property of the document type, a nested one through the objects around it", + "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer property when an operand reads its value, any property when present or absent tests it", "type": "string", "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" }, @@ -2024,7 +2032,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer property of the document type, whose value it takes, 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property a rule reads must be an integer property that is neither transient nor inside a transient object, every comparison must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer property of the document type, whose value it takes, 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 8c0d0383217..7d882b21a83 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -1,7 +1,9 @@ use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::class_methods::apply_required_since::apply_required_since; use crate::data_contract::document_type::class_methods::parse_typed_array::parse_typed_array; -use crate::data_contract::document_type::property_constraints::parse_property_constraints; +use crate::data_contract::document_type::property_constraints::{ + parse_property_constraints, PropertyRead, +}; use crate::data_contract::document_type::reference_lookup::{ MAX_LOOKUP_INDEX_NAME_LENGTH, MAX_LOOKUP_KEYS, MAX_LOOKUP_PATH_LENGTH, }; @@ -1870,11 +1872,12 @@ pub(super) fn validate_encrypted_for_declarations( } /// Reads the `propertyConstraints` keyword onto the document type and checks -/// every property its rules read: an integer property of the type (a nested -/// one named by its dotted path, as the flattened map names it) that is -/// neither transient nor inside a transient object. A transient value is never -/// stored, so a stored document could not be held to a rule reading one. The -/// declaration's shape ([`parse_property_constraints`]) and these reads are +/// every property its rules read: by its value, an integer property of the +/// type (a nested one named by its dotted path, as the flattened map names +/// it); by its presence, a property of any type, an object included; either +/// way one that is neither transient nor inside a transient object. A +/// transient value is never stored, so a stored document could not be held to +/// a rule reading one. The declaration's shape ([`parse_property_constraints`]) and these reads are /// checked on every parse; under full validation, the limits too: at most /// `SystemLimits::max_property_constraints` rules, each of at most /// `max_property_constraint_nodes` nodes, and no `anyOf` or `allOf` listing @@ -1912,6 +1915,23 @@ pub(super) fn apply_property_constraints( } } +/// The property at the dotted `path` of `properties`, an object or a member of +/// one included, `None` when the path names none. +fn property_at_path<'a>( + properties: &'a IndexMap, + path: &str, +) -> Option<&'a DocumentProperty> { + let mut segments = path.split('.'); + let mut property = properties.get(segments.next()?)?; + for segment in segments { + let DocumentPropertyType::Object(members) = &property.property_type else { + return None; + }; + property = members.get(segment)?; + } + Some(property) +} + fn apply_property_constraints_v0( document_type: &mut DocumentTypeV2, document_type_name: &str, @@ -1926,38 +1946,53 @@ fn apply_property_constraints_v0( }; for (name, constraint) in &constraints { - for path in constraint.property_paths() { - match document_type - .flattened_properties - .get(path) - .map(|property| &property.property_type) - { - // `is_integer` leaves out the 128-bit types, which the arithmetic holds too - Some(property_type) - if property_type.is_integer() - || matches!( - property_type, - DocumentPropertyType::U128 | DocumentPropertyType::I128 - ) => {} - Some(other) => { - return Err(structure_error(format!( - "rule \"{name}\" reads \"{path}\", which has type {}, not integer", - other.name() - ))); - } - // An object is not in the flattened map either: only its members hold values - None => { - return Err(structure_error(format!( - "rule \"{name}\" reads \"{path}\", which is not an integer property of \ - the document type (a nested one is named by its dotted path)" - ))); + for (path, read) in constraint.property_reads() { + let reads = match read { + PropertyRead::Value => "reads", + PropertyRead::Presence => "tests the presence of", + }; + match read { + PropertyRead::Value => match document_type + .flattened_properties + .get(path) + .map(|property| &property.property_type) + { + // `is_integer` leaves out the 128-bit types, which the arithmetic holds too + Some(property_type) + if property_type.is_integer() + || matches!( + property_type, + DocumentPropertyType::U128 | DocumentPropertyType::I128 + ) => {} + Some(other) => { + return Err(structure_error(format!( + "rule \"{name}\" reads \"{path}\", which has type {}, not integer", + other.name() + ))); + } + // An object is not in the flattened map either: only its members hold values + None => { + return Err(structure_error(format!( + "rule \"{name}\" reads \"{path}\", which is not an integer property \ + of the document type (a nested one is named by its dotted path)" + ))); + } + }, + PropertyRead::Presence => { + if property_at_path(&document_type.properties, path).is_none() { + return Err(structure_error(format!( + "rule \"{name}\" tests the presence of \"{path}\", which is not a \ + property of the document type (a nested one is named by its dotted \ + path)" + ))); + } } } if is_transient(DocumentTypeRef::V2(document_type), path) { return Err(structure_error(format!( - "rule \"{name}\" reads \"{path}\", which is transient or inside a transient \ - object: a transient value is never stored, so a stored document could not \ - be held to the rule" + "rule \"{name}\" {reads} \"{path}\", which is transient or inside a \ + transient object: a transient value is never stored, so a stored document \ + could not be held to the rule" ))); } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index 0be5b4b9164..cb2863ee717 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -189,6 +189,83 @@ fn should_parse_combined_conditions_and_check_every_property_they_read() { } } +/// A system property is not a property of the type: the meta-schema refuses +/// its `$` when registering, and the parser the path when reading. +#[test] +fn should_refuse_a_presence_test_of_a_system_property() { + let rules = json!({ "rule": { "present": "$ownerId" } }); + let registered = parse_order(rules.clone(), true); + assert!( + registered.as_ref().is_err_and(is_json_schema_error), + "the meta-schema should refuse it, got {registered:?}" + ); + expect_structure_error( + parse_order(rules, false), + "tests the presence of \"$ownerId\", which is not a property of the document type", + ); +} + +/// `present` and `absent` test a property of any type, an object included, on +/// both paths; the path must name a property of the type, and not a transient +/// one. +#[test] +fn should_test_the_presence_of_any_property_the_type_declares() { + for path in [ + "note", + "ratio", + "counts", + "meta", + "meta.tag", + "meta.total", + "price", + ] { + let rules = json!({ + "rule": { "anyOf": [{ "present": path }, { "absent": "fee" }] } + }); + for full_validation in [true, false] { + let document_type = parse_order(rules.clone(), full_validation).unwrap_or_else(|e| { + panic!("{path}, full_validation {full_validation}: should parse: {e}") + }); + assert_eq!( + document_type.property_constraints()["rule"].property_paths(), + [path, "fee"] + ); + } + } + + for path in ["missing", "meta.missing", "note.length", "price.value"] { + for full_validation in [true, false] { + expect_structure_error( + parse_order(json!({ "rule": { "present": path } }), full_validation), + &format!( + "rule \"rule\" tests the presence of \"{path}\", which is not a property of \ + the document type" + ), + ); + } + } + + for (transient, path) in [("note", "note"), ("meta", "meta"), ("meta", "meta.tag")] { + let schema = order_schema( + Some(json!({ "rule": { "not": { "absent": path } } })), + Some(transient), + ); + for full_validation in [true, false] { + expect_structure_error( + parse_dispatched( + schema_value(schema.clone()), + PlatformVersion::latest(), + full_validation, + ), + &format!( + "rule \"rule\" tests the presence of \"{path}\", which is transient or \ + inside a transient object" + ), + ); + } + } +} + /// Only an integer property's value is a number the rule can compute with: a /// string, a float, an array, an object and a system property are refused on /// both paths, as is a path naming nothing. @@ -475,6 +552,10 @@ fn should_check_the_grammar_with_the_meta_schema_and_the_parser() { }), json!({ "rule": { "not": { "not": { "equal": ["price", 1] } } } }), json!({ "rule": { "anyOf": [{ "equal": ["price", 1] }, { "equal": ["price"] }] } }), + json!({ "rule": { "present": 1 } }), + json!({ "rule": { "absent": ["note"] } }), + json!({ "rule": { "present": "note", "absent": "fee" } }), + json!({ "rule": { "not": { "present": { "add": ["price", 1] } } } }), ] { let registered = parse_order(rules.clone(), true); assert!( diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 08670000d26..5047d38c327 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -117,9 +117,9 @@ pub(crate) mod property_names { /// See `parse_doctype_reference` in `try_from_schema`. pub const CREATOR_REFERS_TO: &str = "creatorRefersTo"; /// Doctype-level object of named rules, each a condition on the document's - /// integer properties (a comparison of two integer expressions, or an - /// `anyOf`, `allOf` or `not` of conditions) that every created or replaced - /// document must meet. Meta-schema v3+ (protocol version 14). See + /// properties (a comparison of two integer expressions, a `present` or + /// `absent` test, or an `anyOf`, `allOf` or `not` of conditions) that every + /// created or replaced document must meet. Meta-schema v3+ (protocol version 14). See /// `parse_property_constraints` in `property_constraints`. pub const PROPERTY_CONSTRAINTS: &str = "propertyConstraints"; pub const DISTINCT_FROM: &str = "distinctFrom"; diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index ceb489787dc..20517ba9d30 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -1,7 +1,8 @@ //! The doctype-level `propertyConstraints` keyword (meta-schema v3, protocol //! version 14): named rules every document of the type must meet, each a -//! condition on the document's integer properties: a comparison of two integer -//! expressions, or `anyOf`, `allOf` or `not` over conditions. +//! condition on the document's properties: a comparison of two integer +//! expressions, a test of whether the document holds a property (`present`, +//! `absent`), or `anyOf`, `allOf` or `not` over conditions. //! //! ```json //! "propertyConstraints": { @@ -17,6 +18,9 @@ //! }, //! "feeWaivedOrAtLeastTen": { //! "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] +//! }, +//! "discountGivenAboveZero": { +//! "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] //! } //! } //! ``` @@ -59,6 +63,8 @@ const POWER: &str = "power"; const ANY_OF: &str = "anyOf"; const ALL_OF: &str = "allOf"; const NOT: &str = "not"; +const PRESENT: &str = "present"; +const ABSENT: &str = "absent"; /// Every key an operand object may hold, for the errors. const OPERAND_KEYS: &str = "add, subtract, multiply, divide, modulo, power or ifAbsent"; @@ -261,30 +267,41 @@ impl ConstraintExpression { } } - /// Appends the dotted paths of the properties the expression reads to - /// `paths`, in the order it reads them. - fn collect_property_paths<'a>(&'a self, paths: &mut Vec<&'a str>) { + /// Appends the properties the expression reads, each by its value, to + /// `reads`, in the order it reads them. + fn collect_property_reads<'a>(&'a self, reads: &mut Vec<(&'a str, PropertyRead)>) { match self { ConstraintExpression::Value(_) => {} - ConstraintExpression::Property { path, .. } => paths.push(path), + ConstraintExpression::Property { path, .. } => reads.push((path, PropertyRead::Value)), ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { for operand in operands { - operand.collect_property_paths(paths); + operand.collect_property_reads(reads); } } ConstraintExpression::Subtract(left, right) | ConstraintExpression::Divide(left, right) | ConstraintExpression::Modulo(left, right) | ConstraintExpression::Power(left, right) => { - left.collect_property_paths(paths); - right.collect_property_paths(paths); + left.collect_property_reads(reads); + right.collect_property_reads(reads); } } } } +/// How a rule reads a property, which decides the properties it may name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PropertyRead { + /// By its value, as an operand: an integer property. + Value, + /// Only whether the document holds it, in a `present` or `absent`: a + /// property of any type, an object included. + Presence, +} + /// A rule of `propertyConstraints`, or a condition inside one: a comparison of -/// two integer expressions, or `anyOf`, `allOf` or `not` over conditions. +/// two integer expressions, a test of whether the document holds a property, +/// or `anyOf`, `allOf` or `not` over conditions. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PropertyConstraint { /// A comparison: the two sides must compare as `comparison` says. @@ -293,6 +310,13 @@ pub enum PropertyConstraint { left: ConstraintExpression, right: ConstraintExpression, }, + /// `present`: the document holds the property at the dotted path. One it + /// leaves out, or sets to null, is absent, as it is for an operand. Unlike + /// an operand, it tells a property left out from one set to 0, and it may + /// name a property of any type. + Present(String), + /// `absent`: the document leaves the property at the dotted path out. + Absent(String), /// `anyOf`: at least one of two or more conditions holds. AnyOf(Vec), /// `allOf`: every one of two or more conditions holds. @@ -307,10 +331,11 @@ impl PropertyConstraint { /// Evaluated left to right, and no further than the outcome needs: a /// comparison evaluates its left side, then its right one; `anyOf` checks /// its conditions in declared order and holds at the first that holds; - /// `allOf` fails at the first that fails; `not` inverts its condition. The - /// first fault an evaluated expression meets ([`ConstraintExpression::evaluate`]) - /// is returned whatever the conditions left unevaluated would say, and `not` - /// never turns a fault into a pass. So an earlier condition guards a later + /// `allOf` fails at the first that fails; `not` inverts its condition; a + /// `present` or `absent` never faults. The first fault an evaluated + /// expression meets ([`ConstraintExpression::evaluate`]) is returned + /// whatever the conditions left unevaluated would say, and `not` never + /// turns a fault into a pass. So an earlier condition guards a later /// one: `anyOf: [{ equal: ["b", 0] }, { equal: [{ divide: ["a", "b"] }, 2] }]` /// holds for a `b` of 0 without dividing by it, while the same two /// conditions the other way round divide by zero. @@ -324,6 +349,8 @@ impl PropertyConstraint { let (left, right) = (left.evaluate(data)?, right.evaluate(data)?); Ok(comparison.holds(left, right)) } + PropertyConstraint::Present(path) => Ok(is_present(data, path)), + PropertyConstraint::Absent(path) => Ok(!is_present(data, path)), PropertyConstraint::AnyOf(conditions) => { for condition in conditions { if condition.holds(data)? { @@ -357,13 +384,15 @@ impl PropertyConstraint { /// The nodes of the rule, counted against /// `SystemLimits::max_property_constraint_nodes`: every comparison and - /// logical operator, every arithmetic operator and every operand (an - /// integer value, or a property with or without `ifAbsent`). + /// logical operator, every `present` or `absent` with the property it + /// names, every arithmetic operator and every operand (an integer value, or + /// a property with or without `ifAbsent`). pub fn node_count(&self) -> usize { 1 + match self { PropertyConstraint::Compare { left, right, .. } => { left.node_count() + right.node_count() } + PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => 0, PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { conditions.iter().map(PropertyConstraint::node_count).sum() } @@ -374,9 +403,18 @@ impl PropertyConstraint { /// The dotted paths of the properties the rule reads, in declared order, a /// path read twice listed twice. pub fn property_paths(&self) -> Vec<&str> { - let mut paths = Vec::new(); - self.collect_property_paths(&mut paths); - paths + self.property_reads() + .into_iter() + .map(|(path, _)| path) + .collect() + } + + /// The properties the rule reads, each with how it reads it, in declared + /// order, a property read twice listed twice. + pub fn property_reads(&self) -> Vec<(&str, PropertyRead)> { + let mut reads = Vec::new(); + self.collect_property_reads(&mut reads); + reads } /// Where an `anyOf` or `allOf` of the rule lists the same condition twice: @@ -395,7 +433,9 @@ impl PropertyConstraint { /// when it returns `None`. fn find_repeated_condition(&self, at: &mut String) -> Option<(String, String)> { let (key, conditions) = match self { - PropertyConstraint::Compare { .. } => return None, + PropertyConstraint::Compare { .. } + | PropertyConstraint::Present(_) + | PropertyConstraint::Absent(_) => return None, PropertyConstraint::AnyOf(conditions) => (ANY_OF, conditions), PropertyConstraint::AllOf(conditions) => (ALL_OF, conditions), PropertyConstraint::Not(condition) => { @@ -425,18 +465,21 @@ impl PropertyConstraint { None } - fn collect_property_paths<'a>(&'a self, paths: &mut Vec<&'a str>) { + fn collect_property_reads<'a>(&'a self, reads: &mut Vec<(&'a str, PropertyRead)>) { match self { PropertyConstraint::Compare { left, right, .. } => { - left.collect_property_paths(paths); - right.collect_property_paths(paths); + left.collect_property_reads(reads); + right.collect_property_reads(reads); + } + PropertyConstraint::Present(path) | PropertyConstraint::Absent(path) => { + reads.push((path, PropertyRead::Presence)) } PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { for condition in conditions { - condition.collect_property_paths(paths); + condition.collect_property_reads(reads); } } - PropertyConstraint::Not(condition) => condition.collect_property_paths(paths), + PropertyConstraint::Not(condition) => condition.collect_property_reads(reads), } } } @@ -448,8 +491,8 @@ impl PropertyConstraint { /// The rules of the declaration's shape are checked here, on every parse: an /// object of one or more rules, each named with 1 to 64 letters, digits or /// underscores and holding one condition. A condition is an object with one -/// key: a comparison of exactly two operands, `anyOf` or `allOf` with two or -/// more conditions, none of them directly the same operator (it says what one +/// key: a comparison of exactly two operands, `present` or `absent` with a +/// property path, `anyOf` or `allOf` with two or more conditions, none of them directly the same operator (it says what one /// flat list says), or `not` with one condition that is not directly another /// `not`. An operand is an integer value, a property path, or /// an object with one key: `ifAbsent` with a path and an integer value, `add` @@ -534,7 +577,7 @@ fn single_entry(value: &Value) -> Option<(&str, &Value)> { /// Every key a condition object may hold, for the errors. fn condition_keys() -> String { format!( - "a comparison ({}), anyOf, allOf or not", + "a comparison ({}), present, absent, anyOf, allOf or not", ConstraintComparison::ALL .map(ConstraintComparison::wire_name) .join(", ") @@ -564,7 +607,8 @@ fn enter(at: &mut String, key: &str) -> usize { /// A condition at `at` (`anyOf[1]`, empty for the rule's own), where the /// errors place it, `depth` levels into its rule: an object whose one key is a -/// comparison listing its two sides, or `anyOf`, `allOf` or `not`. The error is +/// comparison listing its two sides, `present` or `absent` naming a property, +/// or `anyOf`, `allOf` or `not`. The error is /// the rest of a message naming the rule. `at` is extended for what the /// condition holds and trimmed back before a successful return. fn parse_condition( @@ -598,6 +642,17 @@ fn parse_condition( } PropertyConstraint::Not(Box::new(parse_condition(body, at, depth + 1)?)) } + // What the path names is checked against the parsed document type + PRESENT | ABSENT => { + let Some(path) = body.as_text() else { + return Err(format!("at {at} must name a property path")); + }; + if key == PRESENT { + PropertyConstraint::Present(path.to_string()) + } else { + PropertyConstraint::Absent(path.to_string()) + } + } _ => { let Some(comparison) = ConstraintComparison::ALL .into_iter() @@ -818,6 +873,15 @@ fn integer_value(value: &Value, at: &str) -> Result { }) } +/// Whether `data` holds the property at `path`: absent exactly where +/// [`property_value`] would take the `if_absent` value. +fn is_present(data: &Value, path: &str) -> bool { + matches!( + data.get_optional_value_at_path(path), + Ok(Some(value)) if !matches!(value, Value::Null) + ) +} + /// The value of the property at `path` in `data`, or `if_absent` when the /// document leaves it out. An intermediate that is not an object reads as /// absent: the schema validation that runs first refuses such a document. diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index 70fc920d0f3..c5f1cf31b0b 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -595,6 +595,15 @@ fn should_find_a_condition_an_any_of_or_all_of_repeats() { }), Some(("anyOf[0].allOf[1]", "anyOf[0].allOf[0]")), ), + ( + platform_value!({ "anyOf": [{ "present": "fee" }, one.clone(), { "present": "fee" }] }), + Some(("anyOf[2]", "anyOf[0]")), + ), + // Testing the presence of a property and its absence are different conditions + ( + platform_value!({ "anyOf": [{ "present": "fee" }, { "absent": "fee" }] }), + None, + ), ] { let rule = parse_rule_value(condition.clone()); assert_eq!( @@ -605,6 +614,71 @@ fn should_find_a_condition_an_any_of_or_all_of_repeats() { } } +// ── present and absent ────────────────────────────────────────────────── + +#[test] +fn should_parse_present_and_absent() { + assert_eq!( + parse_rule_value(platform_value!({ "present": "meta.total" })), + PropertyConstraint::Present("meta.total".to_string()) + ); + assert_eq!( + parse_rule_value(platform_value!({ + "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] + })), + PropertyConstraint::AnyOf(vec![ + PropertyConstraint::Absent("discount".to_string()), + compare( + ConstraintComparison::GreaterThan, + property("discount"), + ConstraintExpression::Value(0) + ), + ]) + ); + + for (condition, needle) in [ + ( + platform_value!({ "present": 1 }), + "rule \"rule\" at present must name a property path", + ), + ( + platform_value!({ "absent": ["discount"] }), + "rule \"rule\" at absent must name a property path", + ), + ( + platform_value!({ "not": { "present": { "add": ["price", 1] } } }), + "rule \"rule\" at not.present must name a property path", + ), + ( + platform_value!({ "exists": "discount" }), + "rule \"rule\" names \"exists\", which is not a comparison (equal, notEqual, \ + lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual), present, absent, anyOf, \ + allOf or not", + ), + ] { + expect_refusal(platform_value!({ "rule": condition }), needle); + } +} + +/// A presence test is one node, and reads its property by presence, where an operand +/// reads one by value. +#[test] +fn should_count_a_presence_test_as_one_node_reading_by_presence() { + let rule = parse_rule_value(platform_value!({ + "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] + })); + // anyOf, absent discount, greaterThan, discount, 0 + assert_eq!(rule.node_count(), 5); + assert_eq!( + rule.property_reads(), + [ + ("discount", PropertyRead::Presence), + ("discount", PropertyRead::Value) + ] + ); + assert_eq!(rule.property_paths(), ["discount", "discount"]); +} + // ── evaluation ────────────────────────────────────────────────────────── #[test] @@ -968,3 +1042,44 @@ fn should_count_the_nodes_and_list_the_paths_of_combined_conditions() { assert_eq!(rule.node_count(), 8); assert_eq!(rule.property_paths(), ["a", "b", "a"]); } + +/// A property the document leaves out, or sets to null, is absent, as it is for an +/// operand; one it sets to anything else, 0 and objects included, is present. +#[test] +fn should_tell_a_property_left_out_from_one_set_to_zero() { + let values = data(&[ + ("zero", Value::U64(0)), + ("empty", Value::Null), + ("note", Value::Text("hi".to_string())), + ("meta", platform_value!({ "count": 9 })), + ("flat", Value::U8(1)), + ]); + for (path, present) in [ + ("zero", true), + ("note", true), + ("meta", true), + ("meta.count", true), + ("missing", false), + ("empty", false), + ("meta.missing", false), + // An intermediate that is not an object reads as absent + ("flat.count", false), + ] { + let present_rule = parse_rule_value(platform_value!({ "present": path })); + let absent_rule = parse_rule_value(platform_value!({ "absent": path })); + assert_eq!(present_rule.holds(&values), Ok(present), "present {path}"); + assert_eq!(absent_rule.holds(&values), Ok(!present), "absent {path}"); + } + + // Optional, but above zero when given: an operand alone reads a discount left out + // as 0, so it cannot say this + let rule = parse_rule_value(platform_value!({ + "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] + })); + assert_eq!(rule.violation(&data(&[])), None); + assert_eq!(rule.violation(&data(&[("discount", Value::U64(5))])), None); + assert_eq!( + rule.violation(&data(&[("discount", Value::U64(0))])), + Some(PropertyConstraintViolation::NotMet) + ); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index db4f2dc5637..9658d59b149 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -168,11 +168,12 @@ pub struct DocumentTypeV2 { pub(in crate::data_contract) creator_reference: Option, /// The rules every created or replaced document must meet, by name, in the /// order they are checked (`propertyConstraints` keyword, protocol version - /// 14): each a condition on the document's integer properties, a comparison - /// of two integer expressions or an `anyOf`, `allOf` or `not` of conditions. - /// Empty on document types that declare none. The - /// parser (`apply_property_constraints`) holds every property a rule reads - /// to be an integer that is neither transient nor inside a transient object. + /// 14): each a condition on the document's properties, a comparison of two + /// integer expressions, a `present` or `absent` test, or an `anyOf`, `allOf` + /// or `not` of conditions. Empty on document types that declare none. The + /// parser (`apply_property_constraints`) holds every property an operand + /// reads to be an integer, and every property a rule reads to be neither + /// transient nor inside a transient object. pub(in crate::data_contract) property_constraints: BTreeMap, /// How many seconds after its creation (`$createdAt`) the platform deletes each /// document of the type (`ttl` keyword, protocol version 14), `None` when the diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index 602681ed2e2..2a470b1d69f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -1,7 +1,7 @@ //! End-to-end coverage for the `propertyConstraints` doctype keyword (protocol //! version 14): a document type names rules its documents' integer properties -//! must meet, each a comparison of two integer expressions or an `anyOf`, -//! `allOf` or `not` of such conditions. A create or replace +//! must meet, each a comparison of two integer expressions, a `present` or +//! `absent` test, or an `anyOf`, `allOf` or `not` of such conditions. A create or replace //! that breaks one is consensus-rejected with //! `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the rule //! and why, and leaves the stored document untouched. A property the document @@ -38,6 +38,7 @@ mod property_constraints_tests { /// * `boostPower`: `ifAbsent(boost, 1) ^ 20 >= 1`, which overflows for a large boost /// * `depositCoversOrder`: `(price + fee) * quantity <= deposit` /// * `discountBelowPrice`: `discount < price`, an absent discount counting as 0 + /// * `discountGivenAboveZero`: `discount` is absent or above 0 /// * `feeWaivedOnlyWithDiscount`: `!(fee == 0 && discount == 0)` /// * `feeWaivedOrAtLeastTen`: `fee == 0 || fee >= 10` /// * `perUnitDeposit`: `deposit / quantity >= 1`, which divides by zero for no quantity @@ -71,6 +72,9 @@ mod property_constraints_tests { ] }, "discountBelowPrice": { "lessThan": ["discount", "price"] }, + "discountGivenAboveZero": { + "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] + }, "feeWaivedOnlyWithDiscount": { "not": { "allOf": [{ "equal": ["fee", 0] }, { "equal": ["discount", 0] }] } }, @@ -486,6 +490,36 @@ mod property_constraints_tests { assert_eq!(fixture.stored_offers().len(), 1); } + /// A discount may be left out, but one the offer gives must be above 0: only + /// a presence test tells the two apart, since an operand reads a discount left + /// out as 0. + #[tokio::test] + async fn should_tell_a_property_left_out_from_one_set_to_zero() { + let mut fixture = OfferFixture::new(); + + let result = fixture + .create(|document| document.set("discount", Value::U64(0))) + .await; + expect_violated( + result, + "discountGivenAboveZero", + PropertyConstraintViolation::NotMet, + ); + assert!(fixture.stored_offers().is_empty()); + + assert_matches!( + fixture.create(|_| {}).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_matches!( + fixture + .create(|document| document.set("discount", Value::U64(10))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 2); + } + #[tokio::test] async fn should_judge_a_replace_against_the_rules() { let mut fixture = OfferFixture::new(); 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 cba4b6a73e1..02b0af116de 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -53,9 +53,9 @@ pub struct SystemLimits { /// version 14), the only generation that parses `propertyConstraints`, and never /// reached before. pub max_property_constraints: u16, - /// Maximum number of nodes in one `propertyConstraints` rule: every comparison and every - /// `anyOf`, `allOf` or `not`, every arithmetic operator and every operand, an integer - /// value or a property. An `ifAbsent` operand is one node, the default it gives included. Refused under full validation + /// Maximum number of nodes in one `propertyConstraints` rule: every comparison, every + /// `present` or `absent` and every `anyOf`, `allOf` or `not`, every arithmetic operator + /// and every operand, an integer value or a property. An `ifAbsent` operand is one node, the default it gives included. Refused under full validation /// only, like `max_property_constraints`. Read by document type parser generation 3 /// (protocol version 14) and never reached before. pub max_property_constraint_nodes: u16, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 0cb85e20854..8a7e4ec916d 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1044,30 +1044,34 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// /// 39. **Property constraints**: the doctype-level `propertyConstraints` /// keyword (meta-schema v3, `parse_property_constraints` 0) names rules a -/// document's integer properties must meet, each a condition: a comparison +/// document's properties must meet, each a condition: a comparison /// (`equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan`, /// `greaterThanOrEqual`) of two integer expressions built from integer /// literals, property paths and `add`, `subtract`, `multiply`, `divide`, -/// `modulo` and `power`, or `anyOf` or `allOf` over two or more conditions, -/// or `not` over one. A property the document leaves out counts as 0, or -/// as the value of an `ifAbsent` operand naming it. Arithmetic is exact -/// `i128`: `divide` and `modulo` are Euclidean (the remainder is never -/// negative), and an overflow, a zero divisor, a negative exponent or a -/// value that is not an integer refuses the document rather than wrapping. -/// Conditions are checked in declared order and no further than the -/// outcome needs (`anyOf` stops at the first that holds, `allOf` at the -/// first that fails), a fault in one that is checked refuses the document -/// whatever the others say, and `not` never turns a fault into a pass, so -/// an earlier condition guards a later one. The parser checks that every -/// path names an integer property that is neither transient nor inside a -/// transient object, that every comparison reads a property, that an -/// `anyOf` or `allOf` holds none directly of its own kind, that a `not` -/// holds no `not` directly, and that no condition or operand nests deeper -/// than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every parse, and -/// under full validation the limits `SystemLimits::max_property_constraints` -/// (16 rules) and `max_property_constraint_nodes` (32 per rule, every -/// comparison and logical operator counting as one) and that no `anyOf` or -/// `allOf` lists the same condition twice. +/// `modulo` and `power`; `present` or `absent` naming a property of any +/// type, whether the document holds it (the one way to tell a property +/// left out from one set to 0); `anyOf` or `allOf` over two or more +/// conditions; or `not` over one. In an operand, a property the document +/// leaves out counts as 0, or as the value of an `ifAbsent` operand naming +/// it. Arithmetic is exact `i128`: `divide` and `modulo` are Euclidean (the +/// remainder is never negative), and an overflow, a zero divisor, a +/// negative exponent or a value that is not an integer refuses the +/// document rather than wrapping. Conditions are checked in declared order +/// and no further than the outcome needs (`anyOf` stops at the first that +/// holds, `allOf` at the first that fails), a fault in one that is checked +/// refuses the document whatever the others say, and `not` never turns a +/// fault into a pass, so an earlier condition guards a later one. The +/// parser checks that every path an operand reads names an integer +/// property and every path `present` or `absent` tests names a property of +/// any type, neither transient nor inside a transient object; that every +/// comparison reads a property; that an `anyOf` or `allOf` holds none +/// directly of its own kind and a `not` no `not`; and that no condition or +/// operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on +/// every parse. Under full validation it holds the limits +/// `SystemLimits::max_property_constraints` (16 rules) and +/// `max_property_constraint_nodes` (32 per rule, every comparison, +/// presence test and logical operator counting as one), and that no +/// `anyOf` or `allOf` lists the same condition twice. /// `DataContract::validate_document_properties` 0 (extended in place, inert /// before this version) calls `validate_property_constraints` /// (`validate_property_constraints` 0) after the schema validation, so diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 16e16f3bcc0..dd0278a42e3 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -255,9 +255,9 @@ impl DocumentMaxBytesErrorCodeWasm { #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum DocumentPropertyConstraintErrorCodeWasm { /// The written document breaks a rule of its document type's - /// `propertyConstraints`: the rule (a comparison, or an `anyOf`, `allOf` or - /// `not` of them) does not hold, or evaluating it overflowed, divided by - /// zero, raised to a negative power or read a value that is not an integer. + /// `propertyConstraints`: the rule does not hold, or evaluating it + /// overflowed, divided by zero, raised to a negative power or read a value + /// that is not an integer. DocumentPropertyConstraintViolated = 10422, } From b48edfe9f68a7afd3eb612103e205bf0b92238cc Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 15:04:06 +0700 Subject: [PATCH 014/113] feat(platform)!: in, value membership in propertyConstraints rules (PV14) (#5038) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/documents.md | 12 +- packages/js-evo-sdk/README.md | 7 +- .../document/v3/document-meta.json | 25 +++- .../v3/property_constraints_tests.rs | 51 +++++++ .../src/data_contract/document_type/mod.rs | 6 +- .../document_type/property_constraints/mod.rs | 133 +++++++++++++----- .../property_constraints/tests.rs | 128 ++++++++++++++++- .../src/data_contract/document_type/v2/mod.rs | 4 +- .../tests/document/property_constraints.rs | 35 ++++- .../src/version/system_limits/mod.rs | 4 +- .../rs-platform-version/src/version/v14.rs | 48 ++++--- 11 files changed, 375 insertions(+), 78 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 9a845b22f04..1d3db9797e8 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -680,7 +680,7 @@ The check runs where the JSON schema validation of a document's properties runs, ## Property Constraints (`propertyConstraints`) -Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named rules the properties of every created or replaced document must meet, where JSON Schema can only bound one property at a time. Each rule is a condition: a comparison of two integer expressions, a test of whether the document holds a property, or `anyOf`, `allOf` or `not` over conditions. +Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named rules the properties of every created or replaced document must meet, where JSON Schema can only bound one property at a time. Each rule is a condition: a comparison of two integer expressions, a test of whether an integer expression takes one of listed values, a test of whether the document holds a property, or `anyOf`, `allOf` or `not` over conditions. ```json "propertyConstraints": { @@ -699,13 +699,15 @@ Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named }, "discountGivenAboveZero": { "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] - } + }, + "tieredFee": { "in": ["fee", [0, 10, 25, 50]] } } ``` -The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeastTen` reads `fee == 0 || fee >= 10`, and the last lets an offer leave its discount out but not give a discount of 0. A rule's name is 1 to 64 letters, digits or underscores, and the rule is a condition, an object with one key: +The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeastTen` reads `fee == 0 || fee >= 10`, `discountGivenAboveZero` lets an offer leave its discount out but not give a discount of 0, and `tieredFee` holds the fee to four tiers. A rule's name is 1 to 64 letters, digits or underscores, and the rule is a condition, an object with one key: - a comparison, `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` or `greaterThanOrEqual`, listing the left and the right expression; +- `{ "in": [expression, [values]] }`, holding if the integer expression takes one of two or more distinct integer values. It says what an `anyOf` of `equal`s says, in one node per value instead of three, so a set of up to 30 values fits the node limit where the `anyOf` fits 10. A value is a literal, never a path or an expression; - `{ "present": path }`, holding if the document holds the property, and `{ "absent": path }`, holding if it leaves it out (a property set to null counts as left out). An operand reads a property the document leaves out as 0, so only these tell "not given" from "given as 0". They may name a property of any type, an object or a member of one included, since they read no value; - `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; - `{ "allOf": [...] }`, holding if every one of two or more conditions holds; @@ -725,11 +727,11 @@ The arithmetic is exact over `i128`. Operands are evaluated left to right, and e Conditions are checked in declared order and no further than the outcome needs: a comparison evaluates its left side, then its right; `anyOf` stops at the first condition that holds and `allOf` at the first that fails. A fault in a condition that is checked breaks the rule whatever the others would say, and `not` does not turn it into a pass. So an earlier condition guards a later one: `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` holds for a `b` of 0 without dividing by it, while the same two conditions the other way round divide by zero and break the rule. -The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer property of the type (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `present` or `absent`, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. +The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer property of the type (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `present` or `absent`, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. Transfers, purchases and price updates change no property and are not judged. -In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and whether by value or by presence), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. +In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, an `in`, a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and whether by value or by presence), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. ## Rules and Guidelines diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index dc2dc9fa9a0..2c3681d51f4 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -399,7 +399,7 @@ try { ## Property constraints (`propertyConstraints`) -From protocol version 14 a document type can declare rules its documents' properties must meet, each a comparison of two integer expressions built from property paths and integer values, a `present` or `absent` test, or `anyOf`, `allOf` or `not` over such conditions: +From protocol version 14 a document type can declare rules its documents' properties must meet, each a comparison of two integer expressions built from property paths and integer values, an `in` list of values, a `present` or `absent` test, or `anyOf`, `allOf` or `not` over such conditions: ```json "propertyConstraints": { @@ -417,11 +417,12 @@ From protocol version 14 a document type can declare rules its documents' proper }, "discountGivenAboveZero": { "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] - } + }, + "tieredFee": { "in": ["fee", [0, 10, 25, 50]] } } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 572394a9ba3..8cd5cc52006 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's properties, comparisons between integer expressions and present or absent tests combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's properties, comparisons between integer expressions, in (value membership) and present or absent tests combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "referenceOperands": { @@ -40,7 +40,7 @@ } }, "propertyConstraint": { - "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", + "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right, in listing an integer expression and the values it may take, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", "type": "object", "properties": { "equal": { @@ -61,6 +61,25 @@ "greaterThanOrEqual": { "$ref": "#/$defs/propertyConstraintOperandPair" }, + "in": { + "description": "Holds if the integer expression listed first takes one of the integer values listed second: two or more, no two alike. It says what an anyOf of equal comparisons says, in one node per value rather than three", + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/propertyConstraintExpression" + }, + { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "uniqueItems": true + } + ], + "items": false, + "minItems": 2 + }, "present": { "description": "Holds if the document holds the property at this path, of any type, an object included, with a value other than null. Unlike an operand, which reads a property the document leaves out as 0, it tells a property left out from one set to 0", "$ref": "#/$defs/propertyConstraintPath" @@ -2032,7 +2051,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer property of the document type, whose value it takes, 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, in listing an integer expression and two or more distinct integer values it may take, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer property of the document type, whose value it takes, 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index cb2863ee717..5920a441086 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -189,6 +189,30 @@ fn should_parse_combined_conditions_and_check_every_property_they_read() { } } +/// An `in` registers on both paths, and its operand reads by value, so it must +/// read integer properties. +#[test] +fn should_parse_an_in_and_hold_its_operand_to_integer_properties() { + let rules = json!({ + "rule": { "in": [{ "add": ["fee", { "ifAbsent": ["meta.total", 0] }] }, [0, 10, 25]] } + }); + for full_validation in [true, false] { + let document_type = parse_order(rules.clone(), full_validation) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + assert_eq!( + document_type.property_constraints()["rule"].property_paths(), + ["fee", "meta.total"] + ); + expect_structure_error( + parse_order( + json!({ "rule": { "in": ["note", [1, 2]] } }), + full_validation, + ), + "rule \"rule\" reads \"note\", which has type string, not integer", + ); + } +} + /// A system property is not a property of the type: the meta-schema refuses /// its `$` when registering, and the parser the path when reading. #[test] @@ -466,6 +490,26 @@ fn should_hold_the_limits_under_full_validation_only() { ), ); parse_order(logical_rule_of(max_nodes + 1), false).expect("a stored contract stays readable"); + + // An in is one node, its operand one more, and each value it lists one + let in_rule_of = |nodes: usize| { + let values: Vec<_> = (0..nodes - 2).map(|value| json!(value)).collect(); + json!({ "rule": { "in": ["price", values] } }) + }; + let document_type = + parse_order(in_rule_of(max_nodes), true).expect("the most values an in may list"); + assert_eq!( + document_type.property_constraints()["rule"].node_count(), + max_nodes + ); + expect_structure_error( + parse_order(in_rule_of(max_nodes + 1), true), + &format!( + "rule \"rule\" has {} nodes, above the maximum of {max_nodes}", + max_nodes + 1 + ), + ); + parse_order(in_rule_of(max_nodes + 1), false).expect("a stored contract stays readable"); } /// No `anyOf` or `allOf` may list the same condition twice, checked when a contract @@ -556,6 +600,13 @@ fn should_check_the_grammar_with_the_meta_schema_and_the_parser() { json!({ "rule": { "absent": ["note"] } }), json!({ "rule": { "present": "note", "absent": "fee" } }), json!({ "rule": { "not": { "present": { "add": ["price", 1] } } } }), + json!({ "rule": { "in": ["price"] } }), + json!({ "rule": { "in": ["price", [1]] } }), + json!({ "rule": { "in": ["price", [1, 1]] } }), + json!({ "rule": { "in": ["price", [1, 1.5]] } }), + json!({ "rule": { "in": ["price", [1, "fee"]] } }), + json!({ "rule": { "in": ["price", [1, 2], 3] } }), + json!({ "rule": { "in": ["price", 1] } }), ] { let registered = parse_order(rules.clone(), true); assert!( diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 5047d38c327..16fbd993bc1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -117,9 +117,9 @@ pub(crate) mod property_names { /// See `parse_doctype_reference` in `try_from_schema`. pub const CREATOR_REFERS_TO: &str = "creatorRefersTo"; /// Doctype-level object of named rules, each a condition on the document's - /// properties (a comparison of two integer expressions, a `present` or - /// `absent` test, or an `anyOf`, `allOf` or `not` of conditions) that every - /// created or replaced document must meet. Meta-schema v3+ (protocol version 14). See + /// properties (a comparison of two integer expressions, an `in` list of + /// values, a `present` or `absent` test, or an `anyOf`, `allOf` or `not` of + /// conditions) that every created or replaced document must meet. Meta-schema v3+ (protocol version 14). See /// `parse_property_constraints` in `property_constraints`. pub const PROPERTY_CONSTRAINTS: &str = "propertyConstraints"; pub const DISTINCT_FROM: &str = "distinctFrom"; diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index 20517ba9d30..031fee7d954 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -1,7 +1,8 @@ //! The doctype-level `propertyConstraints` keyword (meta-schema v3, protocol //! version 14): named rules every document of the type must meet, each a //! condition on the document's properties: a comparison of two integer -//! expressions, a test of whether the document holds a property (`present`, +//! expressions, a test of whether an integer expression takes one of listed +//! values (`in`), a test of whether the document holds a property (`present`, //! `absent`), or `anyOf`, `allOf` or `not` over conditions. //! //! ```json @@ -21,7 +22,8 @@ //! }, //! "discountGivenAboveZero": { //! "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] -//! } +//! }, +//! "tieredFee": { "in": ["fee", [0, 10, 25, 50]] } //! } //! ``` //! @@ -48,7 +50,7 @@ use crate::consensus::basic::document::PropertyConstraintViolation; use crate::data_contract::document_type::property_names; use crate::data_contract::errors::DataContractError; use platform_value::{Value, ValueMapHelper}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; /// The operand key naming a property together with the value it takes when @@ -65,6 +67,7 @@ const ALL_OF: &str = "allOf"; const NOT: &str = "not"; const PRESENT: &str = "present"; const ABSENT: &str = "absent"; +const IN: &str = "in"; /// Every key an operand object may hold, for the errors. const OPERAND_KEYS: &str = "add, subtract, multiply, divide, modulo, power or ifAbsent"; @@ -300,8 +303,9 @@ pub enum PropertyRead { } /// A rule of `propertyConstraints`, or a condition inside one: a comparison of -/// two integer expressions, a test of whether the document holds a property, -/// or `anyOf`, `allOf` or `not` over conditions. +/// two integer expressions, a test of whether an integer expression takes one +/// of listed values, a test of whether the document holds a property, or +/// `anyOf`, `allOf` or `not` over conditions. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PropertyConstraint { /// A comparison: the two sides must compare as `comparison` says. @@ -310,6 +314,12 @@ pub enum PropertyConstraint { left: ConstraintExpression, right: ConstraintExpression, }, + /// `in`: the expression takes one of two or more distinct integer values, + /// what an `anyOf` of `equal`s says in far fewer nodes. + In { + operand: ConstraintExpression, + values: BTreeSet, + }, /// `present`: the document holds the property at the dotted path. One it /// leaves out, or sets to null, is absent, as it is for an operand. Unlike /// an operand, it tells a property left out from one set to 0, and it may @@ -349,6 +359,9 @@ impl PropertyConstraint { let (left, right) = (left.evaluate(data)?, right.evaluate(data)?); Ok(comparison.holds(left, right)) } + PropertyConstraint::In { operand, values } => { + Ok(values.contains(&operand.evaluate(data)?)) + } PropertyConstraint::Present(path) => Ok(is_present(data, path)), PropertyConstraint::Absent(path) => Ok(!is_present(data, path)), PropertyConstraint::AnyOf(conditions) => { @@ -384,14 +397,15 @@ impl PropertyConstraint { /// The nodes of the rule, counted against /// `SystemLimits::max_property_constraint_nodes`: every comparison and - /// logical operator, every `present` or `absent` with the property it - /// names, every arithmetic operator and every operand (an integer value, or - /// a property with or without `ifAbsent`). + /// logical operator, every `in` and each value it lists, every `present` or + /// `absent` with the property it names, every arithmetic operator and every + /// operand (an integer value, or a property with or without `ifAbsent`). pub fn node_count(&self) -> usize { 1 + match self { PropertyConstraint::Compare { left, right, .. } => { left.node_count() + right.node_count() } + PropertyConstraint::In { operand, values } => operand.node_count() + values.len(), PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => 0, PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { conditions.iter().map(PropertyConstraint::node_count).sum() @@ -418,12 +432,13 @@ impl PropertyConstraint { } /// Where an `anyOf` or `allOf` of the rule lists the same condition twice: - /// the repeat's place and the earlier one's (`anyOf[2]` and `anyOf[0]`), the - /// first found in declared order, `None` when no list does. Conditions are - /// alike when they parse alike, so `1` and `1.0` are the same value, and so - /// are `"price"` and `{ "ifAbsent": ["price", 0] }`. Checked under full - /// validation with the limits, which bound the lists it compares; a stored - /// rule was checked when its contract registered. + /// the repeat's place and the earlier one's (`anyOf[2]` and `anyOf[0]`), + /// the first found in declared order, `None` when no list does. Conditions + /// are alike when they parse alike, so `1` and `1.0` are the same value, + /// `"price"` and `{ "ifAbsent": ["price", 0] }` the same operand, and two + /// `in`s listing the same values in another order the same condition. + /// Checked under full validation with the limits, which bound the lists it + /// compares; a stored rule was checked when its contract registered. pub fn repeated_condition(&self) -> Option<(String, String)> { self.find_repeated_condition(&mut String::new()) } @@ -434,6 +449,7 @@ impl PropertyConstraint { fn find_repeated_condition(&self, at: &mut String) -> Option<(String, String)> { let (key, conditions) = match self { PropertyConstraint::Compare { .. } + | PropertyConstraint::In { .. } | PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => return None, PropertyConstraint::AnyOf(conditions) => (ANY_OF, conditions), @@ -471,6 +487,7 @@ impl PropertyConstraint { left.collect_property_reads(reads); right.collect_property_reads(reads); } + PropertyConstraint::In { operand, .. } => operand.collect_property_reads(reads), PropertyConstraint::Present(path) | PropertyConstraint::Absent(path) => { reads.push((path, PropertyRead::Presence)) } @@ -491,17 +508,19 @@ impl PropertyConstraint { /// The rules of the declaration's shape are checked here, on every parse: an /// object of one or more rules, each named with 1 to 64 letters, digits or /// underscores and holding one condition. A condition is an object with one -/// key: a comparison of exactly two operands, `present` or `absent` with a -/// property path, `anyOf` or `allOf` with two or more conditions, none of them directly the same operator (it says what one -/// flat list says), or `not` with one condition that is not directly another -/// `not`. An operand is an integer value, a property path, or -/// an object with one key: `ifAbsent` with a path and an integer value, `add` -/// or `multiply` with two or more operands, or `subtract`, `divide`, `modulo` -/// or `power` with exactly two. An integer value may be spelled as a float with -/// no fractional part, as the meta-schema's `integer` type admits one. A -/// literal 0 divisor, a literal negative exponent, a comparison that reads no -/// property, which would hold for every document or for none, and a condition -/// or operand deeper than [`MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH`] are refused. +/// key: a comparison of exactly two operands, `in` with an operand and a list +/// of two or more distinct integer values, `present` or `absent` with a +/// property path, `anyOf` or `allOf` with two or more conditions, none of them +/// directly the same operator (it says what one flat list says), or `not` with +/// one condition that is not directly another `not`. An operand is an integer +/// value, a property path, or an object with one key: `ifAbsent` with a path +/// and an integer value, `add` or `multiply` with two or more operands, or +/// `subtract`, `divide`, `modulo` or `power` with exactly two. An integer value +/// may be spelled as a float with no fractional part, as the meta-schema's +/// `integer` type admits one. A literal 0 divisor, a literal negative exponent, +/// a comparison or `in` that reads no property, which would hold for every +/// document or for none, and a condition or operand deeper than +/// [`MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH`] are refused. /// What the paths name is checked against the parsed document type, and the /// limits and that no list repeats a condition under full validation, by /// parser generation 3. @@ -577,7 +596,7 @@ fn single_entry(value: &Value) -> Option<(&str, &Value)> { /// Every key a condition object may hold, for the errors. fn condition_keys() -> String { format!( - "a comparison ({}), present, absent, anyOf, allOf or not", + "a comparison ({}), in, present, absent, anyOf, allOf or not", ConstraintComparison::ALL .map(ConstraintComparison::wire_name) .join(", ") @@ -605,12 +624,12 @@ fn enter(at: &mut String, key: &str) -> usize { parent } -/// A condition at `at` (`anyOf[1]`, empty for the rule's own), where the -/// errors place it, `depth` levels into its rule: an object whose one key is a -/// comparison listing its two sides, `present` or `absent` naming a property, -/// or `anyOf`, `allOf` or `not`. The error is -/// the rest of a message naming the rule. `at` is extended for what the -/// condition holds and trimmed back before a successful return. +/// A condition at `at` (`anyOf[1]`, empty for the rule's own), where the errors +/// place it, `depth` levels into its rule: an object whose one key is a +/// comparison listing its two sides, `in` listing an operand and its values, +/// `present` or `absent` naming a property, or `anyOf`, `allOf` or `not`. The +/// error is the rest of a message naming the rule. `at` is extended for what +/// the condition holds and trimmed back before a successful return. fn parse_condition( value: &Value, at: &mut String, @@ -642,6 +661,28 @@ fn parse_condition( } PropertyConstraint::Not(Box::new(parse_condition(body, at, depth + 1)?)) } + IN => { + let Some([operand, values]) = body.as_array().map(Vec::as_slice) else { + return Err(format!( + "at {at} must list an integer expression and the values it may take" + )); + }; + let base = at.len(); + at.push_str("[0]"); + let operand = parse_expression(operand, at, depth + 1)?; + at.truncate(base); + if !operand.reads_property() { + at.truncate(parent); + return Err(format!( + "{}reads no property, so it would hold for every document or for none", + located(at) + )); + } + at.push_str("[1]"); + let values = in_values(values, at)?; + at.truncate(base); + PropertyConstraint::In { operand, values } + } // What the path names is checked against the parsed document type PRESENT | ABSENT => { let Some(path) = body.as_text() else { @@ -714,6 +755,34 @@ fn condition_list( Ok(parsed) } +/// The values an `in` lists at `at` (`in[1]`): two or more integer literals, +/// no two alike, `1` and `1.0` being the same value. A duplicate is refused on +/// every parse: unlike a repeated condition it costs a set insertion to find. +fn in_values(values: &Value, at: &mut String) -> Result, String> { + let Some(values) = values.as_array().filter(|values| values.len() >= 2) else { + return Err(format!("at {at} must list two or more integer values")); + }; + let base = at.len(); + // Each value with the index it first appears at, for the errors + let mut seen = BTreeMap::new(); + for (index, value) in values.iter().enumerate() { + // Writing to a `String` cannot fail + let _ = write!(at, "[{index}]"); + if !is_number(value) { + return Err(format!("at {at} must be an integer value")); + } + let integer = integer_value(value, at)?; + if let Some(earlier) = seen.insert(integer, index) { + return Err(format!( + "at {at} repeats the value at {}[{earlier}]", + &at[..base] + )); + } + at.truncate(base); + } + Ok(seen.into_keys().collect()) +} + /// An operand at `at` (`lessThan[0].add[1]`), where the errors place it, /// `depth` levels into its rule. `at` is extended for the operands of an /// operator and trimmed back before a successful return. diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index c5f1cf31b0b..70bfc4b16be 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -599,6 +599,13 @@ fn should_find_a_condition_an_any_of_or_all_of_repeats() { platform_value!({ "anyOf": [{ "present": "fee" }, one.clone(), { "present": "fee" }] }), Some(("anyOf[2]", "anyOf[0]")), ), + // An in lists a set: the same values in another order are the same condition + ( + platform_value!({ + "anyOf": [{ "in": ["fee", [1, 2]] }, one.clone(), { "in": ["fee", [2, 1]] }] + }), + Some(("anyOf[2]", "anyOf[0]")), + ), // Testing the presence of a property and its absence are different conditions ( platform_value!({ "anyOf": [{ "present": "fee" }, { "absent": "fee" }] }), @@ -614,6 +621,123 @@ fn should_find_a_condition_an_any_of_or_all_of_repeats() { } } +// ── in ────────────────────────────────────────────────────────────────── + +#[test] +fn should_parse_in() { + assert_eq!( + parse_rule_value(platform_value!({ "in": ["kind", [7, 1, 3.0]] })), + PropertyConstraint::In { + operand: property("kind"), + values: BTreeSet::from([1, 3, 7]), + } + ); + assert_eq!( + parse_rule_value(platform_value!({ "in": [{ "modulo": ["quantity", 10] }, [0, 5]] })), + PropertyConstraint::In { + operand: ConstraintExpression::Modulo( + Box::new(property("quantity")), + Box::new(ConstraintExpression::Value(10)) + ), + values: BTreeSet::from([0, 5]), + } + ); + + for (condition, needle) in [ + ( + platform_value!({ "in": ["kind"] }), + "rule \"rule\" at in must list an integer expression and the values it may take", + ), + ( + platform_value!({ "in": "kind" }), + "rule \"rule\" at in must list an integer expression and the values it may take", + ), + ( + platform_value!({ "in": ["kind", [1, 2], [3]] }), + "rule \"rule\" at in must list an integer expression and the values it may take", + ), + ( + platform_value!({ "in": ["kind", [1]] }), + "rule \"rule\" at in[1] must list two or more integer values", + ), + ( + platform_value!({ "in": ["kind", 1] }), + "rule \"rule\" at in[1] must list two or more integer values", + ), + // A listed value is a literal, never a path or an expression + ( + platform_value!({ "in": ["kind", [1, "fee"]] }), + "rule \"rule\" at in[1][1] must be an integer value", + ), + ( + platform_value!({ "in": ["kind", [1, { "add": [1, 1] }]] }), + "rule \"rule\" at in[1][1] must be an integer value", + ), + ( + platform_value!({ "in": ["kind", [1, 2.5]] }), + "rule \"rule\" at in[1][1] holds 2.5, which is not an integer", + ), + // Alike once parsed: JSON does not tell `1` from `1.0` + ( + platform_value!({ "in": ["kind", [1, 2, 1.0]] }), + "rule \"rule\" at in[1][2] repeats the value at in[1][0]", + ), + ( + platform_value!({ "in": [5, [1, 5]] }), + "rule \"rule\" reads no property", + ), + ( + platform_value!({ + "anyOf": [{ "equal": ["fee", 1] }, { "in": [{ "add": [1, 2] }, [1, 3]] }] + }), + "rule \"rule\" at anyOf[1] reads no property", + ), + ( + platform_value!({ "in": [{ "divide": ["kind", 0] }, [1, 2]] }), + "rule \"rule\" at in[0].divide divides by 0", + ), + ( + platform_value!({ "not": { "in": [{ "sum": ["kind", 1] }, [1, 2]] } }), + "rule \"rule\" at not.in[0] names \"sum\", which is not one of add", + ), + ] { + expect_refusal(platform_value!({ "rule": condition }), needle); + } +} + +/// An `in` holds when its operand takes a listed value, a fault in the operand breaks the +/// rule, and it is one node plus one per value. +#[test] +fn should_hold_an_in_when_its_operand_takes_a_listed_value() { + let rule = parse_rule_value(platform_value!({ "in": ["kind", [1, 3, 7]] })); + for (kind, holds) in [(1, true), (3, true), (7, true), (2, false), (8, false)] { + assert_eq!( + rule.holds(&data(&[("kind", Value::U64(kind))])), + Ok(holds), + "kind {kind}" + ); + } + // An absent operand reads as 0 + assert_eq!(rule.holds(&data(&[])), Ok(false)); + let with_zero = parse_rule_value(platform_value!({ "in": ["kind", [0, 1]] })); + assert_eq!(with_zero.holds(&data(&[])), Ok(true)); + + let divided = parse_rule_value(platform_value!({ "in": [{ "divide": [10, "kind"] }, [2, 5]] })); + assert_eq!(divided.violation(&data(&[("kind", Value::U64(5))])), None); + assert_eq!( + divided.violation(&data(&[("kind", Value::U64(3))])), + Some(PropertyConstraintViolation::NotMet) + ); + assert_eq!( + divided.violation(&data(&[("kind", Value::U64(0))])), + Some(PropertyConstraintViolation::DivisionByZero) + ); + + // in, kind, and one per value + assert_eq!(rule.node_count(), 5); + assert_eq!(rule.property_reads(), [("kind", PropertyRead::Value)]); +} + // ── present and absent ────────────────────────────────────────────────── #[test] @@ -652,8 +776,8 @@ fn should_parse_present_and_absent() { ( platform_value!({ "exists": "discount" }), "rule \"rule\" names \"exists\", which is not a comparison (equal, notEqual, \ - lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual), present, absent, anyOf, \ - allOf or not", + lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual), in, present, absent, \ + anyOf, allOf or not", ), ] { expect_refusal(platform_value!({ "rule": condition }), needle); diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 9658d59b149..86a5b6c323d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -169,8 +169,8 @@ pub struct DocumentTypeV2 { /// The rules every created or replaced document must meet, by name, in the /// order they are checked (`propertyConstraints` keyword, protocol version /// 14): each a condition on the document's properties, a comparison of two - /// integer expressions, a `present` or `absent` test, or an `anyOf`, `allOf` - /// or `not` of conditions. Empty on document types that declare none. The + /// integer expressions, an `in` list of values, a `present` or `absent` + /// test, or an `anyOf`, `allOf` or `not` of conditions. Empty on document types that declare none. The /// parser (`apply_property_constraints`) holds every property an operand /// reads to be an integer, and every property a rule reads to be neither /// transient nor inside a transient object. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index 2a470b1d69f..969a5d47f83 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -1,7 +1,8 @@ //! End-to-end coverage for the `propertyConstraints` doctype keyword (protocol //! version 14): a document type names rules its documents' integer properties -//! must meet, each a comparison of two integer expressions, a `present` or -//! `absent` test, or an `anyOf`, `allOf` or `not` of such conditions. A create or replace +//! must meet, each a comparison of two integer expressions, an `in` list of +//! values, a `present` or `absent` test, or an `anyOf`, `allOf` or `not` of +//! such conditions. A create or replace //! that breaks one is consensus-rejected with //! `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the rule //! and why, and leaves the stored document untouched. A property the document @@ -42,6 +43,7 @@ mod property_constraints_tests { /// * `feeWaivedOnlyWithDiscount`: `!(fee == 0 && discount == 0)` /// * `feeWaivedOrAtLeastTen`: `fee == 0 || fee >= 10` /// * `perUnitDeposit`: `deposit / quantity >= 1`, which divides by zero for no quantity + /// * `tieredFee`: `fee` is one of 0, 10, 25 or 50 fn offer_schema() -> Value { platform_value!({ "type": "object", @@ -83,7 +85,8 @@ mod property_constraints_tests { }, "perUnitDeposit": { "greaterThanOrEqual": [{ "divide": ["deposit", "quantity"] }, 1] - } + }, + "tieredFee": { "in": ["fee", [0, 10, 25, 50]] } }, "additionalProperties": false }) @@ -520,6 +523,32 @@ mod property_constraints_tests { assert_eq!(fixture.stored_offers().len(), 2); } + /// A fee of 20 is not one of the tiers; 25 is. (100 + 25) * 2 = 250 <= 300. + #[tokio::test] + async fn should_judge_an_in_against_its_listed_values() { + let mut fixture = OfferFixture::new(); + + let result = fixture + .create(|document| { + document.set("fee", Value::U64(20)); + document.set("deposit", Value::U64(300)); + }) + .await; + expect_violated(result, "tieredFee", PropertyConstraintViolation::NotMet); + assert!(fixture.stored_offers().is_empty()); + + assert_matches!( + fixture + .create(|document| { + document.set("fee", Value::U64(25)); + document.set("deposit", Value::U64(300)); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 1); + } + #[tokio::test] async fn should_judge_a_replace_against_the_rules() { let mut fixture = OfferFixture::new(); 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 02b0af116de..323c34fe921 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -54,8 +54,8 @@ pub struct SystemLimits { /// reached before. pub max_property_constraints: u16, /// Maximum number of nodes in one `propertyConstraints` rule: every comparison, every - /// `present` or `absent` and every `anyOf`, `allOf` or `not`, every arithmetic operator - /// and every operand, an integer value or a property. An `ifAbsent` operand is one node, the default it gives included. Refused under full validation + /// `in` and each value it lists, every `present` or `absent` and every `anyOf`, `allOf` or + /// `not`, every arithmetic operator and every operand, an integer value or a property. An `ifAbsent` operand is one node, the default it gives included. Refused under full validation /// only, like `max_property_constraints`. Read by document type parser generation 3 /// (protocol version 14) and never reached before. pub max_property_constraint_nodes: u16, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 8a7e4ec916d..23cfdbae248 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1048,30 +1048,32 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// (`equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan`, /// `greaterThanOrEqual`) of two integer expressions built from integer /// literals, property paths and `add`, `subtract`, `multiply`, `divide`, -/// `modulo` and `power`; `present` or `absent` naming a property of any -/// type, whether the document holds it (the one way to tell a property -/// left out from one set to 0); `anyOf` or `allOf` over two or more -/// conditions; or `not` over one. In an operand, a property the document -/// leaves out counts as 0, or as the value of an `ifAbsent` operand naming -/// it. Arithmetic is exact `i128`: `divide` and `modulo` are Euclidean (the -/// remainder is never negative), and an overflow, a zero divisor, a -/// negative exponent or a value that is not an integer refuses the -/// document rather than wrapping. Conditions are checked in declared order -/// and no further than the outcome needs (`anyOf` stops at the first that -/// holds, `allOf` at the first that fails), a fault in one that is checked -/// refuses the document whatever the others say, and `not` never turns a -/// fault into a pass, so an earlier condition guards a later one. The -/// parser checks that every path an operand reads names an integer -/// property and every path `present` or `absent` tests names a property of -/// any type, neither transient nor inside a transient object; that every -/// comparison reads a property; that an `anyOf` or `allOf` holds none -/// directly of its own kind and a `not` no `not`; and that no condition or -/// operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on -/// every parse. Under full validation it holds the limits +/// `modulo` and `power`; `in`, whether an integer expression takes one of +/// two or more distinct integer values; `present` or `absent` naming a +/// property of any type, whether the document holds it (the one way to +/// tell a property left out from one set to 0); `anyOf` or `allOf` over +/// two or more conditions; or `not` over one. In an operand, a property +/// the document leaves out counts as 0, or as the value of an `ifAbsent` +/// operand naming it. Arithmetic is exact `i128`: `divide` and `modulo` +/// are Euclidean (the remainder is never negative), and an overflow, a +/// zero divisor, a negative exponent or a value that is not an integer +/// refuses the document rather than wrapping. Conditions are checked in +/// declared order and no further than the outcome needs (`anyOf` stops at +/// the first that holds, `allOf` at the first that fails), a fault in one +/// that is checked refuses the document whatever the others say, and `not` +/// never turns a fault into a pass, so an earlier condition guards a later +/// one. The parser checks that every path an operand reads names an +/// integer property and every path `present` or `absent` tests names a +/// property of any type, neither transient nor inside a transient object; +/// that every comparison and `in` reads a property; that no `in` lists a +/// value twice; that an `anyOf` or `allOf` holds none directly of its own +/// kind and a `not` no `not`; and that no condition or operand nests +/// deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every parse. +/// Under full validation it holds the limits /// `SystemLimits::max_property_constraints` (16 rules) and -/// `max_property_constraint_nodes` (32 per rule, every comparison, -/// presence test and logical operator counting as one), and that no -/// `anyOf` or `allOf` lists the same condition twice. +/// `max_property_constraint_nodes` (32 per rule, every comparison, `in`, +/// listed value, presence test and logical operator counting as one), and +/// that no `anyOf` or `allOf` lists the same condition twice. /// `DataContract::validate_document_properties` 0 (extended in place, inert /// before this version) calls `validate_property_constraints` /// (`validate_property_constraints` 0) after the schema validation, so From e1325f8403a8d66b3223e19e4ae399caa9b26b56 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 15:08:15 +0700 Subject: [PATCH 015/113] feat(platform)!: boolean operands in propertyConstraints rules (PV14) (#5040) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/documents.md | 4 +- packages/js-evo-sdk/README.md | 2 +- .../document/v3/document-meta.json | 6 +- .../class_methods/try_from_schema/mod.rs | 21 ++++--- .../v3/property_constraints_tests.rs | 60 ++++++++++++++----- .../src/data_contract/document_type/mod.rs | 5 +- .../document_type/property_constraints/mod.rs | 27 +++++---- .../property_constraints/tests.rs | 40 +++++++++++++ .../src/data_contract/document_type/v2/mod.rs | 9 +-- .../tests/document/property_constraints.rs | 43 ++++++++++++- .../src/version/system_limits/mod.rs | 5 +- .../rs-platform-version/src/version/v14.rs | 10 ++-- 12 files changed, 178 insertions(+), 54 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 1d3db9797e8..f9d6d3679fd 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -716,7 +716,7 @@ The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeas Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan": ["quantity", 10] }] } }` refuses a free order of more than 10. An `anyOf` or `allOf` may not list two alike conditions, nor hold one of its own kind directly (it says what one flat list says), and a `not` may not hold a `not` directly. An expression is one of: - an integer value (`100`; a float with no fractional part, `100.0`, reads as that integer, as the meta-schema's `integer` type admits it); -- a string, the dotted path of an integer property of the document type (`"price"`, `"meta.total"`), whose value it takes, 0 when the document leaves the property out; +- a string, the dotted path of an integer or boolean property of the document type (`"price"`, `"meta.total"`, `"waiveFee"`), whose value it takes, 0 when the document leaves the property out. A boolean reads as 1 for true and 0 for false, so `{ "equal": [{ "multiply": ["waiveFee", "fee"] }, 0] }` says a waived fee is 0; - `{ "ifAbsent": [path, value] }`, the property's value, or `value` when the document leaves it out; - `{ "add": [...] }` or `{ "multiply": [...] }` over two or more operands; - `{ "subtract": [a, b] }`, `{ "divide": [a, b] }`, `{ "modulo": [a, b] }` or `{ "power": [a, b] }`. @@ -727,7 +727,7 @@ The arithmetic is exact over `i128`. Operands are evaluated left to right, and e Conditions are checked in declared order and no further than the outcome needs: a comparison evaluates its left side, then its right; `anyOf` stops at the first condition that holds and `allOf` at the first that fails. A fault in a condition that is checked breaks the rule whatever the others would say, and `not` does not turn it into a pass. So an earlier condition guards a later one: `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` holds for a `b` of 0 without dividing by it, while the same two conditions the other way round divide by zero and break the rule. -The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer property of the type (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `present` or `absent`, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. +The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `present` or `absent`, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. Transfers, purchases and price updates change no property and are not judged. diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 2c3681d51f4..177745675aa 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -422,7 +422,7 @@ From protocol version 14 a document type can declare rules its documents' proper } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 8cd5cc52006..3267950afef 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -127,7 +127,7 @@ "uniqueItems": true }, "propertyConstraintExpression": { - "description": "An integer expression of a propertyConstraints rule: an integer value; the dotted path of an integer property of the document type, whose value it takes, 0 when the document leaves it out; or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two", + "description": "An integer expression of a propertyConstraints rule: an integer value; the dotted path of an integer or boolean property of the document type, whose value it takes (1 for true, 0 for false), 0 when the document leaves it out; or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two", "type": [ "integer", "string", @@ -184,7 +184,7 @@ } }, "propertyConstraintPath": { - "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer property when an operand reads its value, any property when present or absent tests it", + "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer or boolean property when an operand reads its value, any property when present or absent tests it", "type": "string", "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" }, @@ -2051,7 +2051,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, in listing an integer expression and two or more distinct integer values it may take, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer property of the document type, whose value it takes, 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, in listing an integer expression and two or more distinct integer values it may take, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 7d882b21a83..c9ac1d4d861 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -1872,9 +1872,9 @@ pub(super) fn validate_encrypted_for_declarations( } /// Reads the `propertyConstraints` keyword onto the document type and checks -/// every property its rules read: by its value, an integer property of the -/// type (a nested one named by its dotted path, as the flattened map names -/// it); by its presence, a property of any type, an object included; either +/// every property its rules read: by its value, an integer or boolean +/// property of the type (a nested one named by its dotted path, as the +/// flattened map names it); by its presence, a property of any type, an object included; either /// way one that is neither transient nor inside a transient object. A /// transient value is never stored, so a stored document could not be held to /// a rule reading one. The declaration's shape ([`parse_property_constraints`]) and these reads are @@ -1957,24 +1957,29 @@ fn apply_property_constraints_v0( .get(path) .map(|property| &property.property_type) { - // `is_integer` leaves out the 128-bit types, which the arithmetic holds too + // `is_integer` leaves out the 128-bit types, which the arithmetic holds + // too; a boolean reads as 1 for true and 0 for false Some(property_type) if property_type.is_integer() || matches!( property_type, - DocumentPropertyType::U128 | DocumentPropertyType::I128 + DocumentPropertyType::U128 + | DocumentPropertyType::I128 + | DocumentPropertyType::Boolean ) => {} Some(other) => { return Err(structure_error(format!( - "rule \"{name}\" reads \"{path}\", which has type {}, not integer", + "rule \"{name}\" reads \"{path}\", which has type {}, not integer or \ + boolean", other.name() ))); } // An object is not in the flattened map either: only its members hold values None => { return Err(structure_error(format!( - "rule \"{name}\" reads \"{path}\", which is not an integer property \ - of the document type (a nested one is named by its dotted path)" + "rule \"{name}\" reads \"{path}\", which is not an integer or boolean \ + property of the document type (a nested one is named by its dotted \ + path)" ))); } }, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index 5920a441086..9bacd4f99c9 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -24,8 +24,9 @@ use platform_value::string_encoding::Encoding; use serde_json::json; /// An `order` type: four required integers, an optional nested `meta` object -/// with an integer `total`, and a string, a number, a typed array and an -/// integer `code` to be refused as operands or listed as transient. +/// with an integer `total`, a string, a number, a typed array and an integer +/// `code` to be refused as operands or listed as transient, and a boolean +/// `rush`. fn order_schema(rules: Option, transient: Option<&str>) -> serde_json::Value { let mut schema = json!({ "type": "object", @@ -52,7 +53,8 @@ fn order_schema(rules: Option, transient: Option<&str>) -> se "items": { "type": "integer", "minimum": 0, "maximum": 10 }, "maxItems": 4, "position": 8 - } + }, + "rush": { "type": "boolean", "position": 9 } }, "required": ["price", "fee", "quantity", "deposit"], "additionalProperties": false @@ -184,7 +186,7 @@ fn should_parse_combined_conditions_and_check_every_property_they_read() { for full_validation in [true, false] { expect_structure_error( parse_order(nested_string.clone(), full_validation), - "rule \"rule\" reads \"note\", which has type string, not integer", + "rule \"rule\" reads \"note\", which has type string, not integer or boolean", ); } } @@ -208,11 +210,33 @@ fn should_parse_an_in_and_hold_its_operand_to_integer_properties() { json!({ "rule": { "in": ["note", [1, 2]] } }), full_validation, ), - "rule \"rule\" reads \"note\", which has type string, not integer", + "rule \"rule\" reads \"note\", which has type string, not integer or boolean", ); } } +/// An operand may read a boolean property, as 1 for true and 0 for false, on +/// both paths, in a comparison and in an `in`. +#[test] +fn should_let_an_operand_read_a_boolean_property() { + let rules = json!({ + "rushCostsMore": { + "greaterThanOrEqual": ["fee", { "multiply": ["rush", 50] }] + }, + "rushIsFlag": { "in": [{ "ifAbsent": ["rush", 0] }, [0, 1]] } + }); + for full_validation in [true, false] { + let document_type = parse_order(rules.clone(), full_validation) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let constraints = document_type.property_constraints(); + assert_eq!( + constraints["rushCostsMore"].property_paths(), + ["fee", "rush"] + ); + assert_eq!(constraints["rushIsFlag"].property_paths(), ["rush"]); + } +} + /// A system property is not a property of the type: the meta-schema refuses /// its `$` when registering, and the parser the path when reading. #[test] @@ -296,31 +320,37 @@ fn should_test_the_presence_of_any_property_the_type_declares() { #[test] fn should_refuse_a_rule_reading_anything_but_an_integer_property() { for (operand, needle) in [ - ("note", "reads \"note\", which has type string, not integer"), - ("ratio", "reads \"ratio\", which has type f64, not integer"), + ( + "note", + "reads \"note\", which has type string, not integer or boolean", + ), + ( + "ratio", + "reads \"ratio\", which has type f64, not integer or boolean", + ), ( "counts", - "reads \"counts\", which has type array, not integer", + "reads \"counts\", which has type array, not integer or boolean", ), ( "meta.tag", - "reads \"meta.tag\", which has type string, not integer", + "reads \"meta.tag\", which has type string, not integer or boolean", ), ( "meta", - "reads \"meta\", which is not an integer property of the document type", + "reads \"meta\", which is not an integer or boolean property of the document type", ), ( "missing", - "reads \"missing\", which is not an integer property", + "reads \"missing\", which is not an integer or boolean property", ), ( "meta.missing", - "reads \"meta.missing\", which is not an integer property", + "reads \"meta.missing\", which is not an integer or boolean property", ), ( "$ownerId", - "reads \"$ownerId\", which is not an integer property", + "reads \"$ownerId\", which is not an integer or boolean property", ), ] { for full_validation in [true, false] { @@ -648,11 +678,13 @@ fn should_check_the_grammar_with_the_meta_schema_and_the_parser() { #[test] fn should_refuse_property_constraints_before_protocol_version_14_and_ignore_them_when_reading() { let mut schema = order_schema(Some(json!({ "depositCoversOrder": deposit_rule() })), None); - // Typed arrays arrived with protocol version 14 as well + // Typed arrays arrived with protocol version 14 as well; the property after + // them takes their position, so the positions stay contiguous schema["properties"] .as_object_mut() .expect("the properties") .remove("counts"); + schema["properties"]["rush"]["position"] = json!(8); let schema = schema_value(schema); let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13"); diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 16fbd993bc1..8d214ec9786 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -119,8 +119,9 @@ pub(crate) mod property_names { /// Doctype-level object of named rules, each a condition on the document's /// properties (a comparison of two integer expressions, an `in` list of /// values, a `present` or `absent` test, or an `anyOf`, `allOf` or `not` of - /// conditions) that every created or replaced document must meet. Meta-schema v3+ (protocol version 14). See - /// `parse_property_constraints` in `property_constraints`. + /// conditions) that every created or replaced document must meet. + /// Meta-schema v3+ (protocol version 14). See `parse_property_constraints` + /// in `property_constraints`. pub const PROPERTY_CONSTRAINTS: &str = "propertyConstraints"; pub const DISTINCT_FROM: &str = "distinctFrom"; pub const CONTRACT_ID: &str = "contractId"; diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index 031fee7d954..04d46aa4828 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -27,10 +27,11 @@ //! } //! ``` //! -//! An operand is an integer value, the dotted path of an integer property, or -//! an object with one key: an arithmetic operator over its operands, or -//! `ifAbsent`, a property with the value it takes when the document leaves it -//! out. A property named on its own takes 0 when absent. How the arithmetic +//! An operand is an integer value, the dotted path of an integer or boolean +//! property (a boolean reads as 1 for true and 0 for false), or an object with +//! one key: an arithmetic operator over its operands, or `ifAbsent`, a +//! property with the value it takes when the document leaves it out. A +//! property named on its own takes 0 when absent. How the arithmetic //! treats overflow, division and powers is set out on //! [`ConstraintExpression::evaluate`], and how conditions combine on //! [`PropertyConstraint::holds`]. @@ -143,9 +144,9 @@ impl ConstraintComparison { pub enum ConstraintExpression { /// An integer value. Value(i128), - /// The value of the integer property at the dotted `path`, or `if_absent` - /// when the document leaves it out: 0 for a path on its own, the declared - /// value for an `ifAbsent` operand. + /// The value of the integer or boolean property at the dotted `path` (1 + /// for true, 0 for false), or `if_absent` when the document leaves it out: + /// 0 for a path on its own, the declared value for an `ifAbsent` operand. Property { path: String, if_absent: i128 }, /// `add`: the sum of two or more operands. Add(Vec), @@ -168,8 +169,9 @@ impl ConstraintExpression { /// Exact arithmetic over `i128`. Operands are evaluated left to right and /// the first fault is returned; every intermediate result must fit: /// - /// * a property the document leaves out takes its `if_absent` value; one it - /// holds must be an integer ([`PropertyConstraintViolation::NotAnInteger`] + /// * a property the document leaves out takes its `if_absent` value; a + /// boolean it holds reads as 1 for true and 0 for false; any other value + /// must be an integer ([`PropertyConstraintViolation::NotAnInteger`] /// otherwise: the schema validation running first admits a float with no /// fractional part as an integer, which the document could not be stored /// with anyway) that fits an `i128` @@ -295,7 +297,7 @@ impl ConstraintExpression { /// How a rule reads a property, which decides the properties it may name. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PropertyRead { - /// By its value, as an operand: an integer property. + /// By its value, as an operand: an integer or boolean property. Value, /// Only whether the document holds it, in a `present` or `absent`: a /// property of any type, an object included. @@ -951,8 +953,8 @@ fn is_present(data: &Value, path: &str) -> bool { ) } -/// The value of the property at `path` in `data`, or `if_absent` when the -/// document leaves it out. An intermediate that is not an object reads as +/// The value of the property at `path` in `data`, 1 or 0 for a boolean, or +/// `if_absent` when the document leaves it out. An intermediate that is not an object reads as /// absent: the schema validation that runs first refuses such a document. fn property_value( data: &Value, @@ -961,6 +963,7 @@ fn property_value( ) -> Result { match data.get_optional_value_at_path(path) { Ok(Some(Value::Null)) | Ok(None) | Err(_) => Ok(if_absent), + Ok(Some(Value::Bool(flag))) => Ok(i128::from(*flag)), Ok(Some(value)) if value.is_integer() => value .to_integer::() .map_err(|_| PropertyConstraintViolation::Overflow), diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index 70bfc4b16be..42008d93edd 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -1207,3 +1207,43 @@ fn should_tell_a_property_left_out_from_one_set_to_zero() { Some(PropertyConstraintViolation::NotMet) ); } + +/// A boolean reads as 1 for true and 0 for false, and one the document leaves out as 0 +/// or its `ifAbsent` value, as any operand does. +#[test] +fn should_read_a_boolean_as_one_or_zero() { + let values = data(&[ + ("yes", Value::Bool(true)), + ("no", Value::Bool(false)), + ("fee", Value::U64(10)), + ]); + for (expression, expected) in [ + (platform_value!("yes"), 1), + (platform_value!("no"), 0), + (platform_value!("missing"), 0), + (platform_value!({ "ifAbsent": ["missing", 1] }), 1), + (platform_value!({ "ifAbsent": ["no", 1] }), 0), + (platform_value!({ "add": ["yes", "yes", "no"] }), 2), + (platform_value!({ "multiply": ["yes", "fee"] }), 10), + (platform_value!({ "multiply": ["no", "fee"] }), 0), + ] { + assert_eq!( + evaluate(expression.clone(), &values), + Ok(expected), + "{expression:?}" + ); + } + + // A waived fee is 0: `waived * fee == 0` + let rule = parse_rule_value(platform_value!({ + "equal": [{ "multiply": ["waived", "fee"] }, 0] + })); + let order = + |waived: bool, fee: u64| data(&[("waived", Value::Bool(waived)), ("fee", Value::U64(fee))]); + assert_eq!(rule.violation(&order(true, 0)), None); + assert_eq!(rule.violation(&order(false, 10)), None); + assert_eq!( + rule.violation(&order(true, 10)), + Some(PropertyConstraintViolation::NotMet) + ); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 86a5b6c323d..7e50f06499c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -170,10 +170,11 @@ pub struct DocumentTypeV2 { /// order they are checked (`propertyConstraints` keyword, protocol version /// 14): each a condition on the document's properties, a comparison of two /// integer expressions, an `in` list of values, a `present` or `absent` - /// test, or an `anyOf`, `allOf` or `not` of conditions. Empty on document types that declare none. The - /// parser (`apply_property_constraints`) holds every property an operand - /// reads to be an integer, and every property a rule reads to be neither - /// transient nor inside a transient object. + /// test, or an `anyOf`, `allOf` or `not` of conditions. Empty on document + /// types that declare none. The parser (`apply_property_constraints`) holds + /// every property an operand reads to be an integer or a boolean, and every + /// property a rule reads to be neither transient nor inside a transient + /// object. pub(in crate::data_contract) property_constraints: BTreeMap, /// How many seconds after its creation (`$createdAt`) the platform deletes each /// document of the type (`ttl` keyword, protocol version 14), `None` when the diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index 969a5d47f83..15a15b1cbe0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -44,6 +44,7 @@ mod property_constraints_tests { /// * `feeWaivedOrAtLeastTen`: `fee == 0 || fee >= 10` /// * `perUnitDeposit`: `deposit / quantity >= 1`, which divides by zero for no quantity /// * `tieredFee`: `fee` is one of 0, 10, 25 or 50 + /// * `waivedFeeIsZero`: `waiveFee * fee == 0`, the boolean reading as 1 or 0 fn offer_schema() -> Value { platform_value!({ "type": "object", @@ -54,7 +55,8 @@ mod property_constraints_tests { "quantity": { "type": "integer", "minimum": 0, "maximum": 100, "position": 2 }, "deposit": { "type": "integer", "minimum": 0, "position": 3 }, "discount": { "type": "integer", "minimum": 0, "maximum": 1000000, "position": 4 }, - "boost": { "type": "integer", "minimum": 0, "maximum": 100, "position": 5 } + "boost": { "type": "integer", "minimum": 0, "maximum": 100, "position": 5 }, + "waiveFee": { "type": "boolean", "position": 6 } }, "required": ["price", "fee", "quantity", "deposit"], "propertyConstraints": { @@ -86,7 +88,8 @@ mod property_constraints_tests { "perUnitDeposit": { "greaterThanOrEqual": [{ "divide": ["deposit", "quantity"] }, 1] }, - "tieredFee": { "in": ["fee", [0, 10, 25, 50]] } + "tieredFee": { "in": ["fee", [0, 10, 25, 50]] }, + "waivedFeeIsZero": { "equal": [{ "multiply": ["waiveFee", "fee"] }, 0] } }, "additionalProperties": false }) @@ -549,6 +552,42 @@ mod property_constraints_tests { assert_eq!(fixture.stored_offers().len(), 1); } + /// A boolean reads as 1 for true and 0 for false: a waived fee must be 0, and + /// an offer that does not waive it, or leaves the flag out, may charge one. + #[tokio::test] + async fn should_read_a_boolean_property_as_one_or_zero() { + let mut fixture = OfferFixture::new(); + + let result = fixture + .create(|document| document.set("waiveFee", Value::Bool(true))) + .await; + expect_violated( + result, + "waivedFeeIsZero", + PropertyConstraintViolation::NotMet, + ); + assert!(fixture.stored_offers().is_empty()); + + // A waived fee of 0 needs a discount (`feeWaivedOnlyWithDiscount`) + assert_matches!( + fixture + .create(|document| { + document.set("waiveFee", Value::Bool(true)); + document.set("fee", Value::U64(0)); + document.set("discount", Value::U64(10)); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_matches!( + fixture + .create(|document| document.set("waiveFee", Value::Bool(false))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 2); + } + #[tokio::test] async fn should_judge_a_replace_against_the_rules() { let mut fixture = OfferFixture::new(); 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 323c34fe921..ef1d4340cba 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -55,8 +55,9 @@ pub struct SystemLimits { pub max_property_constraints: u16, /// Maximum number of nodes in one `propertyConstraints` rule: every comparison, every /// `in` and each value it lists, every `present` or `absent` and every `anyOf`, `allOf` or - /// `not`, every arithmetic operator and every operand, an integer value or a property. An `ifAbsent` operand is one node, the default it gives included. Refused under full validation - /// only, like `max_property_constraints`. Read by document type parser generation 3 + /// `not`, every arithmetic operator and every operand, an integer value or a property. An + /// `ifAbsent` operand is one node, the default it gives included. Refused under full + /// validation only, like `max_property_constraints`. Read by document type parser generation 3 /// (protocol version 14) and never reached before. pub max_property_constraint_nodes: u16, /// Max size of a state transition in bytes. diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 23cfdbae248..34f4024210a 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1047,8 +1047,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// document's properties must meet, each a condition: a comparison /// (`equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan`, /// `greaterThanOrEqual`) of two integer expressions built from integer -/// literals, property paths and `add`, `subtract`, `multiply`, `divide`, -/// `modulo` and `power`; `in`, whether an integer expression takes one of +/// literals, paths of integer or boolean properties (a boolean reading as +/// 1 for true and 0 for false) and `add`, `subtract`, `multiply`, +/// `divide`, `modulo` and `power`; `in`, whether an integer expression takes one of /// two or more distinct integer values; `present` or `absent` naming a /// property of any type, whether the document holds it (the one way to /// tell a property left out from one set to 0); `anyOf` or `allOf` over @@ -1063,8 +1064,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// that is checked refuses the document whatever the others say, and `not` /// never turns a fault into a pass, so an earlier condition guards a later /// one. The parser checks that every path an operand reads names an -/// integer property and every path `present` or `absent` tests names a -/// property of any type, neither transient nor inside a transient object; +/// integer or boolean property and every path `present` or `absent` +/// tests names a property of any type, neither transient nor inside a +/// transient object; /// that every comparison and `in` reads a property; that no `in` lists a /// value twice; that an `anyOf` or `allOf` holds none directly of its own /// kind and a `not` no `not`; and that no condition or operand nests From eeed935bd14847c86656143256f62948e659aece Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 15:09:03 +0700 Subject: [PATCH 016/113] fix(platform)!: contenders state the most they pay and are charged the join price (PV14) (#5039) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/contested-documents.md | 13 +- packages/js-evo-sdk/src/documents/facade.ts | 3 +- packages/js-evo-sdk/src/dpns/facade.ts | 6 + .../dashsdk/documents/DocumentTransactions.kt | 9 + .../dashsdk/ffi/IdentityNative.kt | 5 + .../dashsdk/ffi/TransactionsNative.kt | 4 + .../dashsdk/identity/IdentityRegistration.kt | 17 +- .../document_create_transition/v0/mod.rs | 13 +- .../document/batch_transition/methods/mod.rs | 70 ++++ .../batch_transition/v0/v0_methods.rs | 1 + .../batch_transition/v1/v0_methods.rs | 1 + .../mod.rs | 7 +- .../advanced_structure_v1/mod.rs | 138 ++++--- .../document_create_transition_action/mod.rs | 7 +- .../state_v2/mod.rs | 51 +-- .../state_transitions/batch/state/v0/mod.rs | 7 +- .../batch/tests/document/creation.rs | 377 +++++++++++++----- .../masternode_vote/charter_election_tests.rs | 118 +++++- .../state_transition/state_transitions/mod.rs | 130 ++++-- .../document_create_transition_action/mod.rs | 10 + .../v0/mod.rs | 5 + .../drive_abci_validation_versions/v10.rs | 13 +- .../fee/vote_resolution_fund_fees/mod.rs | 6 +- .../rs-platform-version/src/version/v14.rs | 25 +- .../rs-platform-wallet-ffi/src/document.rs | 14 + packages/rs-platform-wallet-ffi/src/dpns.rs | 14 +- .../examples/dpns_marketplace_testnet.rs | 2 +- .../src/wallet/identity/network/document.rs | 18 +- .../src/wallet/identity/network/dpns.rs | 12 + packages/rs-sdk-ffi/src/document/helpers.rs | 31 ++ packages/rs-sdk-ffi/src/dpns/register.rs | 1 + packages/rs-sdk-ffi/src/identity/helpers.rs | 1 + packages/rs-sdk-ffi/src/token/utils.rs | 2 + packages/rs-sdk-ffi/src/types.rs | 3 + .../src/platform/documents/contest_fund.rs | 265 ++++++++++++ packages/rs-sdk/src/platform/documents/mod.rs | 1 + .../platform/documents/transitions/create.rs | 49 ++- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 17 +- .../src/platform/transition/put_document.rs | 141 ++++--- packages/rs-unified-sdk-jni/src/identity.rs | 10 + .../rs-unified-sdk-jni/src/transactions.rs | 11 +- .../FFI/StateTransitionExtensions.swift | 45 ++- .../ManagedPlatformWallet.swift | 22 +- packages/wasm-sdk/src/dpns.rs | 20 +- .../src/state_transitions/document.rs | 29 +- 45 files changed, 1376 insertions(+), 368 deletions(-) create mode 100644 packages/rs-sdk/src/platform/documents/contest_fund.rs diff --git a/book/src/data-model/contested-documents.md b/book/src/data-model/contested-documents.md index 8e677443375..55e6cbb06a1 100644 --- a/book/src/data-model/contested-documents.md +++ b/book/src/data-model/contested-documents.md @@ -40,11 +40,14 @@ cap: | ... | doubles every 50 | doubles every 50 | | 950 to 999 | 3,276.8 Dash | 16,384 Dash | -Filling a DPNS contest to 1,000 contenders costs 327,695 Dash (100 at a flat 0.1 Dash). A contender -states its fund in its prefunded voting balance, and one stating less than the fund of the contest -it joins is refused, paid, with `DocumentContestNotPaidForError`, which carries the fund it has to -pay. A contender may state more; everything it states goes to the contest's fund. Before 14 every -contender stated exactly the fund, however many had joined. +Filling a DPNS contest to 1,000 contenders costs 327,695 Dash (100 at a flat 0.1 Dash). A contender's +prefunded voting balance is the most it is willing to pay, and it must hold that much: it is +charged the fund to join the contest it joins, and what it stated beyond that stays with it. One stating less, the first +contender of a new contest included, is refused, paid, with `DocumentContestNotPaidForError`, +which carries the fund it has to pay. The SDKs read how many contenders a contest holds and state +that fund unless the caller names the most it will pay, which lets a join go through while others +join ahead of it. Before 14 every contender stated exactly the contest's fund, however many had +joined, and paid what it stated. The index's `contested.resolution` says how the contest is decided. diff --git a/packages/js-evo-sdk/src/documents/facade.ts b/packages/js-evo-sdk/src/documents/facade.ts index 1910b77058b..353c46a75c6 100644 --- a/packages/js-evo-sdk/src/documents/facade.ts +++ b/packages/js-evo-sdk/src/documents/facade.ts @@ -104,7 +104,8 @@ export class DocumentsFacade { * Creates a document and resolves to the confirmed Document as Platform * committed it, consensus-populated system fields included — keep this * instance when you later intend to delete an indexOnly document whose - * type requires `$createdAt`. + * type requires `$createdAt`. A document of a contested index joins a + * contest: `options.contestFund` is the most, in credits, it pays into it. */ async create(options: wasm.DocumentCreateOptions): Promise { const w = await this.sdk.getWasmSdkConnected(); diff --git a/packages/js-evo-sdk/src/dpns/facade.ts b/packages/js-evo-sdk/src/dpns/facade.ts index 0d0a80f1f99..4bc4f49abb6 100644 --- a/packages/js-evo-sdk/src/dpns/facade.ts +++ b/packages/js-evo-sdk/src/dpns/facade.ts @@ -33,6 +33,12 @@ export class DpnsFacade { return w.dpnsResolveName(name); } + /** + * Registers a DPNS name. A contested name joins a contest: pass + * `options.contestFund` as the most, in credits, the registration pays into + * it, or leave it out to state the fund to join read just before the domain + * is submitted. + */ async registerName(options: wasm.DpnsRegisterNameOptions): Promise { const w = await this.sdk.getWasmSdkConnected(); return w.dpnsRegisterName(options); diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt index 518bff78278..866dfffea82 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt @@ -113,6 +113,10 @@ class DocumentTransactions internal constructor( * @param propertiesJson JSON object keyed by property name (byte-array * fields as hex, identifier fields as base58); `"{}"` for a document * type with no required properties. + * @param maxContestFund the most, in credits, [ownerId] pays into the + * contest a contested document joins, and [ownerId] must hold it; + * `null` states the current fund to join, read just before signing. A document that joins no contest + * ignores it. * @return the confirmed document's canonical JSON (now owned by * [ownerId]; its 32-byte id is the `$id` field). */ @@ -123,9 +127,13 @@ class DocumentTransactions internal constructor( documentType: String, propertiesJson: String, signerHandle: Long, + maxContestFund: Long? = null, ): String = gate.op { require(ownerId.size == 32) { "ownerId must be 32 bytes" } require(contractId.size == 32) { "contractId must be 32 bytes" } + require(maxContestFund == null || maxContestFund >= 0) { + "maxContestFund must be non-negative, got $maxContestFund" + } mapNativeErrors { TransactionsNative.documentCreate( walletHandle, @@ -133,6 +141,7 @@ class DocumentTransactions internal constructor( contractId, documentType, propertiesJson, + maxContestFund ?: 0L, signerHandle, ) } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt index 29be7860bdf..20c31492e1c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt @@ -219,11 +219,16 @@ internal object IdentityNative { /** * Register a DPNS name for [identityId] (32 bytes), signed via * [signerHandle]. Returns the full domain name (e.g. `"alice.dash"`). + * + * @param maxContestFund the most, in credits, the identity pays into + * the contest a contested name joins; `0` states the current fund to + * join, read just before signing. Must be non-negative. */ external fun registerDpnsName( walletHandle: Long, identityId: ByteArray, label: String, + maxContestFund: Long, signerHandle: Long, ): String diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index 7c3b6e6c6a7..dc0c357cff4 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -111,6 +111,9 @@ internal object TransactionsNative { * @param propertiesJson JSON object keyed by property name (byte-array * fields as hex, identifier fields as base58); `"{}"` for a type with * no required properties. + * @param maxContestFund the most, in credits, the owner pays into the + * contest a contested document joins; `0` states the current fund to + * join, read just before signing. Must be non-negative. * @return the confirmed document's canonical JSON (its 32-byte id is the * base58 `$id` field). */ @@ -120,6 +123,7 @@ internal object TransactionsNative { contractId: ByteArray, documentType: String, propertiesJson: String, + maxContestFund: Long, signerHandle: Long, ): String diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt index 965b5a14b0c..b8c4cd647f4 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt @@ -489,15 +489,30 @@ class IdentityRegistration internal constructor( /** * Register a DPNS name for [identityId] (32 bytes), signed via * [signerHandle]. Returns the full domain name (e.g. `"alice.dash"`). + * + * @param maxContestFund the most, in credits, the identity pays into + * the contest a contested name joins, and the identity must hold it; + * `null` states the current fund to join, read just before signing. A name that joins no contest + * ignores it. Mirrors Swift `ManagedPlatformWallet.registerDpnsName`. */ suspend fun registerDpnsName( walletHandle: Long, identityId: ByteArray, label: String, signerHandle: Long, + maxContestFund: Long? = null, ): String = gate.op { + require(maxContestFund == null || maxContestFund >= 0) { + "maxContestFund must be non-negative, got $maxContestFund" + } mapNativeErrors { - IdentityNative.registerDpnsName(walletHandle, identityId, label, signerHandle) + IdentityNative.registerDpnsName( + walletHandle, + identityId, + label, + maxContestFund ?: 0L, + signerHandle, + ) } } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs index e2f5490347a..c151f5c1183 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs @@ -91,10 +91,15 @@ pub struct DocumentCreateTransitionV0 { with = "crate::serialization::json::safe_integer::json_safe_option_string_u64_tuple" ) )] - /// Pre funded balance (for unique index conflict resolution voting - the identity will put money - /// aside that will be used by voters to vote) - /// This is a map of index names to the amount we want to prefund them for - /// Since index conflict resolution is not a common feature most often nothing should be added here. + /// The fund a contested document puts into the contest it opens or joins, which pays the + /// masternode votes that decide it: the name of the contested index, and an amount in credits. + /// `None` for a document that joins no contest, which is most documents. + /// + /// From protocol version 14 the amount is the most the contender is willing to pay. It is + /// charged the fund to join the contest (the contest's fund, doubled once the contest holds + /// 250 contenders and again for every 50 more), what it stated beyond that stays with it, and + /// one stating less is refused. The identity must hold the amount it states. Before 14 the + /// amount is exactly the contest's fund, and is what the contender pays. pub prefunded_voting_balance: Option<(String, Credits)>, } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs index 65b1f437bb0..29192356135 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/mod.rs @@ -25,6 +25,7 @@ use crate::state_transition::batch_transition::batched_transition::document_tran DocumentTransition, DocumentTransitionV0Methods, }; use crate::state_transition::batch_transition::batched_transition::BatchedTransition; +use crate::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; use crate::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; use crate::state_transition::batch_transition::methods::v1::DocumentsBatchTransitionMethodsV1; use crate::state_transition::batch_transition::BatchTransition; @@ -60,6 +61,14 @@ pub struct StateTransitionCreationOptions { /// The action fees the document transition agrees to pay. Required when the document type /// charges a fee for the action (protocol version 14). pub action_fee_agreement: Option, + /// The most a contested document create is willing to pay into the contest it joins. From + /// protocol version 14 it pays the fund to join, which doubles as the contest grows past + /// 250 contenders, and is refused when that is more than this: it pays the transition's + /// fees, but nothing into the contest. The identity must hold what it states, because the + /// balance check made before the contest is counted is against it. `None` keeps the fund + /// the create is built with, the contest's fund, what joining a contest holding fewer than + /// 250 contenders costs. A create that joins no contest ignores it. + pub contest_fund: Option, } impl StateTransitionCreationOptions { @@ -108,6 +117,19 @@ impl StateTransitionCreationOptions { } Ok(transition) } + + /// `transition` stating the options' contest fund as the most it pays into its contest, if + /// they name one and it is a contested create. + pub fn apply_contest_fund(&self, mut transition: DocumentTransition) -> DocumentTransition { + if let (Some(contest_fund), DocumentTransition::Create(create)) = + (self.contest_fund, &mut transition) + { + if let Some((_, stated)) = create.prefunded_voting_balances_mut() { + *stated = contest_fund; + } + } + transition + } } impl DocumentsBatchTransitionMethodsV0 for BatchTransition { @@ -1199,3 +1221,51 @@ mod action_fee_agreement_option_tests { .is_ok()); } } + +#[cfg(test)] +mod contest_fund_option_tests { + use super::*; + use crate::state_transition::batch_transition::document_create_transition::DocumentCreateTransition; + + fn create(prefunded_voting_balance: Option<(String, Credits)>) -> DocumentTransition { + let mut create = DocumentCreateTransition::default(); + *create.prefunded_voting_balances_mut() = prefunded_voting_balance; + DocumentTransition::Create(create) + } + + fn stated(transition: &DocumentTransition) -> Option { + let DocumentTransition::Create(create) = transition else { + panic!("expected a document create"); + }; + create + .prefunded_voting_balance() + .as_ref() + .map(|(_, credits)| *credits) + } + + /// A contested create states the options' contest fund as the most it pays, in place of + /// the contest's fund it was built with + #[test] + fn should_state_the_contest_fund_of_the_options_on_a_contested_create() { + let options = StateTransitionCreationOptions { + contest_fund: Some(7), + ..Default::default() + }; + let contested = create(Some(("parentNameAndLabel".to_string(), 1))); + + assert_eq!( + stated(&options.apply_contest_fund(contested.clone())), + Some(7) + ); + assert_eq!( + stated(&StateTransitionCreationOptions::default().apply_contest_fund(contested)), + Some(1), + "options without a contest fund keep the one the create was built with" + ); + assert_eq!( + stated(&options.apply_contest_fund(create(None))), + None, + "a create that joins no contest ignores it" + ); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v0/v0_methods.rs index c1503f9bb6f..a1d3b12da3f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v0/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v0/v0_methods.rs @@ -117,6 +117,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV0 { resolved_options.base_feature_version, )?; let create_transition = resolved_options.apply_action_fee_agreement(create_transition)?; + let create_transition = resolved_options.apply_contest_fund(create_transition); let documents_batch_transition: BatchTransition = BatchTransitionV0 { owner_id, transitions: vec![create_transition], diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v0_methods.rs index 6e265cb84d3..a27e1eddaed 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v0_methods.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/v1/v0_methods.rs @@ -128,6 +128,7 @@ impl DocumentsBatchTransitionMethodsV0 for BatchTransitionV1 { resolved_options.base_feature_version, )?; let create_transition = resolved_options.apply_action_fee_agreement(create_transition)?; + let create_transition = resolved_options.apply_contest_fund(create_transition); let documents_batch_transition: BatchTransition = BatchTransitionV1 { owner_id, transitions: vec![BatchedTransition::Document(create_transition)], diff --git a/packages/rs-dpp/src/voting/vote_polls/contested_document_resource_vote_poll/mod.rs b/packages/rs-dpp/src/voting/vote_polls/contested_document_resource_vote_poll/mod.rs index 3fd9a6fdc5c..422b425cdfe 100644 --- a/packages/rs-dpp/src/voting/vote_polls/contested_document_resource_vote_poll/mod.rs +++ b/packages/rs-dpp/src/voting/vote_polls/contested_document_resource_vote_poll/mod.rs @@ -102,7 +102,8 @@ impl ContestedDocumentResourceVotePoll { } /// The prefunded voting balance a contender pays to join this contest while it holds - /// `contenders` contenders, see [`required_vote_resolution_fund_to_join`]. + /// `contenders` contenders, see [`required_vote_resolution_fund_to_join`]. A client reads + /// the contenders the contest holds and states this, or more, before it joins. pub fn required_vote_resolution_fund_to_join( &self, contenders: u16, @@ -144,7 +145,9 @@ pub fn required_vote_resolution_fund( /// to the 1,000th, the last a contest accepts, 32,768 times it (3,276.8 Dash for a DPNS name). /// Before 14 every contender pays the fund. /// -/// This is the least a contender may pay; everything it pays goes to the contest's fund. +/// From 14 a contender states the most it will pay and is charged the fund this returns: what +/// it stated beyond that stays with the contender, and one stating less is refused. Before 14 a +/// contender states exactly the fund, and pays what it states. pub fn required_vote_resolution_fund_to_join( contract_id: &Identifier, document_type_name: &str, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs index a9cdf3389fb..59535d4e838 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs @@ -59,24 +59,13 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans match (expected_vote_poll, self.prefunded_voting_balance()) { ( Some(VotePoll::ContestedDocumentResourceVotePoll(expected)), - Some((provided, paid_amount)), + Some((provided, _)), ) => { - // A moderation election is prefunded with the moderation fund, every other - // contest with the contested document fund. -->> Changed in V1 <<-- A - // contender pays at least that: joining a contest holding 250 contenders or - // more costs a multiple of it, which state validation checks once it has - // counted them, and everything paid goes to the contest's fund. - let expected_amount = expected.required_vote_resolution_fund(platform_version); - if *paid_amount < expected_amount { - return Ok(SimpleConsensusValidationResult::new_with_error( - DocumentContestNotPaidForError::new( - self.base().id(), - expected_amount, - *paid_amount, - ) - .into(), - )); - } + // -->> Changed in V1 <<-- The amount is the most the contender pays, and + // what it has to pay depends on how many contenders the contest holds, so + // state validation, which counts them, judges it and refuses a contender + // stating less with the fund it has to pay. V0 wanted exactly the contested + // document fund here. // -->> Introduced in V1 <<-- // The index name in the prefunded voting balance is chosen by the submitter, @@ -99,6 +88,8 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans // -->> End Introduced in V1 <<-- } (Some(VotePoll::ContestedDocumentResourceVotePoll(expected)), None) => { + // A contested document stating no fund at all is refused with the contest's + // fund, the least a contest takes: this step does not count contenders let expected_amount = expected.required_vote_resolution_fund(platform_version); return Ok(SimpleConsensusValidationResult::new_with_error( DocumentContestNotPaidForError::new(self.base().id(), expected_amount, 0) @@ -315,52 +306,68 @@ mod tests { .collect() } - /// A contender pays the contested document fund: exactly it before protocol version 14, at - /// least it from 14, where joining a contest of 250 contenders or more costs a multiple of it + /// The action of a DPNS contender stating `paid_amount` as its fund + fn dpns_contender_action( + paid_amount: Credits, + platform_version: &PlatformVersion, + ) -> DocumentCreateTransitionAction { + let mut action = create_action( + CONTESTED_LABEL, + Some(CONTESTED_INDEX_NAME), + platform_version, + ); + let DocumentCreateTransitionAction::V0(action_data) = &mut action; + action_data + .prefunded_voting_balance + .as_mut() + .expect("prefunded contest") + .1 = paid_amount; + action + } + + /// What a contender states is the most it pays, and what it has to pay depends on how many + /// contenders the contest holds, so structure validation leaves the amount to state + /// validation, which counts them #[test] - fn should_require_the_contested_dpns_fee_for_each_protocol_version() { - for (protocol_version, expected_amount) in [(13, 20_000_000_000), (14, 10_000_000_000)] { - let platform_version = PlatformVersion::get(protocol_version).expect("known version"); - for paid_amount in [ - 9_999_999_999, - 10_000_000_000, - 10_000_000_001, - 20_000_000_000, - 20_000_000_001, - ] { - let mut action = create_action( - CONTESTED_LABEL, - Some(CONTESTED_INDEX_NAME), - platform_version, + fn should_leave_the_contested_dpns_fund_to_state_validation() { + let platform_version = PlatformVersion::latest(); + let fund = required_amount(platform_version); + for paid_amount in [0, 1, fund - 1, fund, fund + 1, 2 * fund] { + let errors = validate( + &dpns_contender_action(paid_amount, platform_version), + platform_version, + ); + assert!( + contest_errors(&errors).is_empty(), + "stating {paid_amount}: {errors:?}" + ); + } + } + + /// PROTOCOL_VERSION_13: a contender states exactly the contested document fund + #[test] + fn should_leave_the_contested_dpns_fund_to_state_validation_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("known version"); + let fund = required_amount(platform_version); + assert_eq!(fund, 20_000_000_000); + for paid_amount in [0, fund - 1, fund, fund + 1, 2 * fund] { + let errors = validate( + &dpns_contender_action(paid_amount, platform_version), + platform_version, + ); + let contest_errors = contest_errors(&errors); + if paid_amount == fund { + assert!( + contest_errors.is_empty(), + "stating {paid_amount}: {errors:?}" ); - let DocumentCreateTransitionAction::V0(action_data) = &mut action; - action_data - .prefunded_voting_balance - .as_mut() - .expect("prefunded contest") - .1 = paid_amount; - - let errors = validate(&action, platform_version); - let contest_errors = contest_errors(&errors); - let accepted = if protocol_version < 14 { - paid_amount == expected_amount - } else { - paid_amount >= expected_amount + } else { + let [StateError::DocumentContestNotPaidForError(error)] = contest_errors.as_slice() + else { + panic!("stating {paid_amount}: expected a fee error, got {errors:?}"); }; - if accepted { - assert!( - contest_errors.is_empty(), - "protocol {protocol_version}: {errors:?}" - ); - } else { - let [StateError::DocumentContestNotPaidForError(error)] = - contest_errors.as_slice() - else { - panic!("protocol {protocol_version}: expected a fee error, got {errors:?}"); - }; - assert_eq!(error.expected_amount(), expected_amount); - assert_eq!(error.paid_amount(), paid_amount); - } + assert_eq!(error.expected_amount(), fund); + assert_eq!(error.paid_amount(), paid_amount); } } } @@ -545,10 +552,11 @@ mod tests { }) } - /// An application in a moderation election prefunds at least the moderation fund, 0.5 Dash; - /// the contested document fund every other contest takes is refused. + /// An application in a moderation election stating no fund is refused with the moderation + /// fund, 0.5 Dash; what one states is judged by state validation, which counts the + /// applicants #[test] - fn should_require_the_moderation_fund_of_a_charter_application() { + fn should_refuse_a_charter_application_stating_no_fund_with_the_moderation_fund() { let platform_version = PlatformVersion::latest(); let moderation_fund = platform_version .fee_version @@ -566,7 +574,7 @@ mod tests { let action = charter_application_action(paid_amount, platform_version); let errors = validate(&action, platform_version); let contest_errors = contest_errors(&errors); - if paid_amount >= Some(moderation_fund) { + if paid_amount.is_some() { assert!(contest_errors.is_empty(), "{errors:?}"); } else { let [StateError::DocumentContestNotPaidForError(error)] = contest_errors.as_slice() @@ -574,7 +582,7 @@ mod tests { panic!("paid {paid_amount:?}: expected a fee error, got {errors:?}"); }; assert_eq!(error.expected_amount(), moderation_fund); - assert_eq!(error.paid_amount(), paid_amount.unwrap_or_default()); + assert_eq!(error.paid_amount(), 0); } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/mod.rs index 6d67acc456c..0b849b088e4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/mod.rs @@ -30,8 +30,11 @@ pub trait DocumentCreateTransitionActionValidation { platform_version: &PlatformVersion, ) -> Result; + /// Validates the create against state. From version 2 it also settles what a contested + /// create pays into its contest: the fund to join it, which may be less than the most the + /// contender stated. fn validate_state( - &self, + &mut self, platform: &PlatformStateRef, owner_id: Identifier, block_info: &BlockInfo, @@ -69,7 +72,7 @@ impl DocumentCreateTransitionActionValidation for DocumentCreateTransitionAction } fn validate_state( - &self, + &mut self, platform: &PlatformStateRef, owner_id: Identifier, block_info: &BlockInfo, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs index fe260b9c768..e2d702dacc8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs @@ -28,7 +28,7 @@ use crate::platform_types::platform::PlatformStateRef; pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait DocumentCreateTransitionActionStateValidationV2 { fn validate_state_v2( - &self, + &mut self, platform: &PlatformStateRef, owner_id: Identifier, block_info: &BlockInfo, @@ -40,7 +40,7 @@ pub(in crate::execution::validation::state_transition::state_transitions::batch: impl DocumentCreateTransitionActionStateValidationV2 for DocumentCreateTransitionAction { fn validate_state_v2( - &self, + &mut self, platform: &PlatformStateRef, owner_id: Identifier, block_info: &BlockInfo, @@ -65,12 +65,11 @@ impl DocumentCreateTransitionActionStateValidationV2 for DocumentCreateTransitio // join doubles once it holds `contested_document_contenders_before_fund_doubling` // contenders and again for every `contested_document_contenders_per_fund_doubling` more, // so filling it costs far more than the fund times the contenders. v1 has let the - // document join the contest when it exists; a new contest has no contenders to count, - // and structure validation has checked its fund. - if let Some((contested_document_resource_vote_poll, paid_amount)) = + // document join the contest when it exists; a new contest has no contenders to count. + if let Some((contested_document_resource_vote_poll, most_it_pays)) = self.prefunded_voting_balance() { - if self.current_store_contest_info().is_some() { + let contenders = if self.current_store_contest_info().is_some() { let max_contenders = platform_version.system_limits.max_contenders_per_contest; let (fee_result, contenders) = platform .drive @@ -97,25 +96,31 @@ impl DocumentCreateTransitionActionStateValidationV2 for DocumentCreateTransitio ), )); } + contenders + } else { + 0 + }; - let expected_amount = required_vote_resolution_fund_to_join( - &contested_document_resource_vote_poll.contract.id(), - &contested_document_resource_vote_poll.document_type_name, - contenders, - platform_version, - ); - if *paid_amount < expected_amount { - return Ok(ConsensusValidationResult::new_with_error( - ConsensusError::StateError(StateError::DocumentContestNotPaidForError( - DocumentContestNotPaidForError::new( - self.base().id(), - expected_amount, - *paid_amount, - ), - )), - )); - } + // The contender states the most it pays and is charged the fund to join, what it + // stated beyond that staying with it + let fund_to_join = required_vote_resolution_fund_to_join( + &contested_document_resource_vote_poll.contract.id(), + &contested_document_resource_vote_poll.document_type_name, + contenders, + platform_version, + ); + if *most_it_pays < fund_to_join { + return Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::DocumentContestNotPaidForError( + DocumentContestNotPaidForError::new( + self.base().id(), + fund_to_join, + *most_it_pays, + ), + )), + )); } + self.set_prefunded_voting_fund(fund_to_join); } // The creator of a document being created is its writer diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs index 28f531a6a81..80d33b1b812 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs @@ -116,8 +116,11 @@ impl DocumentsBatchStateTransitionStateValidationV0 for BatchTransition { let mut seated_charter_reads = SeatedCharterReads::default(); // Next we need to validate the structure of all actions (this means with the data contract) - for transition in state_transition_action.transitions_take() { - let transition_validation_result = match &transition { + for mut transition in state_transition_action.transitions_take() { + // Borrowed mutably so a contested create's validation can settle the fund it pays + // (document create state validation 2, protocol version 14); earlier versions of + // every validation below read the action only + let transition_validation_result = match &mut transition { BatchedTransitionAction::DocumentAction(document_action) => match document_action { DocumentTransitionAction::CreateAction(create_action) => create_action .validate_state( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index b93c1059db8..668a0f1e3f0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -25,6 +25,7 @@ mod creation_tests { use dpp::util::hash::hash_double; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::TowardsIdentity; + use dpp::voting::vote_polls::contested_document_resource_vote_poll::required_vote_resolution_fund; use drive::util::object_size_info::DataContractResolvedInfo; use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfoAllowBorrowed; use drive::query::vote_poll_vote_state_query::ContestedDocumentVotePollDriveQueryResultType::DocumentsAndVoteTally; @@ -32,12 +33,13 @@ mod creation_tests { use drive::util::test_helpers::setup_contract; use crate::test::helpers::setup::TempPlatform; use crate::rpc::core::MockCoreRPCLike; - use crate::execution::validation::state_transition::state_transitions::tests::{add_contender_to_dpns_name_contest, add_contender_to_dpns_name_contest_paying, create_dpns_identity_name_contest, create_dpns_name_contest_give_key_info, dpns_name_vote_poll, perform_votes_multi}; + use crate::execution::validation::state_transition::state_transitions::tests::{add_contender_to_dpns_name_contest, add_contender_to_dpns_name_contest_paying, create_dpns_identity_name_contest, create_dpns_name_contest_give_key_info, dpns_name_vote_poll, fill_contest_with_bare_contenders, perform_votes_multi, DpnsContenderJoin}; use drive::drive::votes::paths::VotePollPaths; use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::resolve::ContestedDocumentResourceVotePollResolver; use drive::fees::op::LowLevelDriveOperation; use drive::grovedb::Element; use crate::platform_types::platform_state::PlatformStateV0Methods; + use std::sync::Arc; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::PaidConsensusError; use crate::test::helpers::fast_forward_to_block::fast_forward_to_block; use dpp::consensus::state::state_error::StateError; @@ -3574,56 +3576,6 @@ mod creation_tests { assert_eq!(consensus_error.to_string(), "An Identity with the id BjNejy4r9QAvLHpQ9Yq6yRMgNymeGZ46d48fJxJbMrfW is already a contestant for the vote_poll ContestedDocumentResourceVotePoll { contract_id: GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec, document_type_name: domain, index_name: parentNameAndLabel, index_values: [string dash, string quantum] }"); } - /// Fills the contest on `name` up to `contenders` contenders with bare contender entries, - /// written straight to GroveDB in one batch: a join reads how many contenders a contest - /// holds, never what they hold, so this stands in for thousands of contested documents. - fn fill_contest_with_bare_contenders( - platform: &TempPlatform, - dpns_contract: &DataContract, - name: &str, - contenders: u64, - platform_version: &PlatformVersion, - ) { - let choices_path = dpns_name_vote_poll(dpns_contract, name) - .resolve(&platform.drive, None, platform_version) - .expect("expected to resolve the vote poll") - .contenders_path(platform_version) - .expect("expected the choices path"); - let (_, held) = platform - .drive - .fetch_contested_document_vote_poll_contender_count( - &dpns_name_vote_poll(dpns_contract, name) - .resolve(&platform.drive, None, platform_version) - .expect("expected to resolve the vote poll"), - u16::MAX, - &Default::default(), - None, - PlatformVersion::latest(), - ) - .expect("expected the contender count"); - let operations = (held as u64..contenders) - .map(|n| { - let mut key = [0xEEu8; 32]; - key[24..].copy_from_slice(&n.to_be_bytes()); - LowLevelDriveOperation::insert_for_known_path_key_element( - choices_path.clone(), - key.to_vec(), - Element::empty_tree(), - ) - }) - .collect(); - platform - .drive - .apply_batch_low_level_drive_operations( - None, - None, - operations, - &mut vec![], - &platform_version.drive, - ) - .expect("expected to write the bare contenders"); - } - /// The fund a contest's prefunded specialized balance holds fn dpns_name_contest_fund( platform: &TempPlatform, @@ -3645,21 +3597,63 @@ mod creation_tests { .expect("expected the contest to have a fund") } - /// The contested document fund at `platform_version` - fn contested_document_fund(platform_version: &PlatformVersion) -> Credits { - platform_version - .fee_version - .vote_resolution_fund_fees - .contested_document_vote_resolution_fund_required_amount + /// What `contender` holds + fn balance_of( + platform: &TempPlatform, + contender: &Identity, + platform_version: &PlatformVersion, + ) -> Credits { + platform + .drive + .fetch_identity_balance(contender.id().to_buffer(), None, platform_version) + .expect("expected to fetch the contender's balance") + .expect("expected the contender to have a balance") + } + + /// Asserts `join` succeeded charging the contender `fund` beside the fees of its document + fn assert_joined_paying( + platform: &TempPlatform, + join: DpnsContenderJoin, + fund: Credits, + platform_version: &PlatformVersion, + ) { + let DpnsContenderJoin { + contender, + balance_before_create, + result, + } = join; + let SuccessfulExecution { fee_result, .. } = result else { + panic!("expected the contender to join, got {result:?}"); + }; + assert_eq!( + balance_before_create - balance_of(platform, &contender, platform_version), + fund + fee_result.total_base_fee(), + "the contender pays the fund to join and the fees of its document" + ); + } + + /// Asserts `join` was refused, paid, for stating `paid` where the contest takes `expected` + fn assert_refused_for_underpaying(join: DpnsContenderJoin, expected: Credits, paid: Credits) { + let PaidConsensusError { + error: ConsensusError::StateError(StateError::DocumentContestNotPaidForError(error)), + .. + } = join.result + else { + panic!( + "expected the contest not to be paid for, got {:?}", + join.result + ); + }; + assert_eq!(error.expected_amount(), expected); + assert_eq!(error.paid_amount(), paid); } /// The fund a contender pays doubles once the contest holds 250 contenders: from then, a - /// contender stating the contested document fund is refused, paid, and one stating twice it - /// joins and pays all of it into the contest's fund + /// contender stating the contested document fund is refused, paid, with the fund it has to + /// pay, and one stating twice it joins and pays it into the contest's fund #[tokio::test] async fn should_double_the_fund_a_contender_pays_once_a_contest_holds_250_contenders() { let platform_version = PlatformVersion::latest(); - let fund = contested_document_fund(platform_version); let mut platform = TestPlatformBuilder::new() .with_latest_protocol_version() .build_with_mock_rpc() @@ -3674,18 +3668,18 @@ mod creation_tests { platform_version, ) .await; + let fund = required_vote_resolution_fund(&dpns_contract.id(), "domain", platform_version); fill_contest_with_bare_contenders( &platform, - &dpns_contract, - "quantum", + &dpns_name_vote_poll(&dpns_contract, "quantum"), 250, platform_version, ); let contest_fund = dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version); - let (_, result) = add_contender_to_dpns_name_contest_paying( + let join = add_contender_to_dpns_name_contest_paying( &mut platform, &platform_state, 4, @@ -3694,21 +3688,13 @@ mod creation_tests { platform_version, ) .await; - let PaidConsensusError { - error: ConsensusError::StateError(StateError::DocumentContestNotPaidForError(error)), - .. - } = result - else { - panic!("expected the contest not to be paid for, got {result:?}"); - }; - assert_eq!(error.expected_amount(), 2 * fund); - assert_eq!(error.paid_amount(), fund); + assert_refused_for_underpaying(join, 2 * fund, fund); assert_eq!( dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version), contest_fund ); - let (contender, result) = add_contender_to_dpns_name_contest_paying( + let join = add_contender_to_dpns_name_contest_paying( &mut platform, &platform_state, 9, @@ -3717,24 +3703,11 @@ mod creation_tests { platform_version, ) .await; - let SuccessfulExecution { fee_result, .. } = result else { - panic!("expected the contender to join, got {result:?}"); - }; + assert_joined_paying(&platform, join, 2 * fund, platform_version); assert_eq!( dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version), contest_fund + 2 * fund ); - let balance = platform - .drive - .fetch_identity_balance(contender.id().to_buffer(), None, platform_version) - .expect("expected to fetch the contender's balance") - .expect("expected the contender to have a balance"); - // The contender paid the fund, the fees of its document and those of its preorder - let paid = contender.balance() - balance; - assert!( - paid > 2 * fund + fee_result.total_base_fee() && paid < 2 * fund + fund / 100, - "paid {paid}" - ); } /// PROTOCOL_VERSION_13: every contender pays the same fund, however many the contest holds @@ -3742,7 +3715,6 @@ mod creation_tests { async fn should_double_the_fund_a_contender_pays_once_a_contest_holds_250_contenders_protocol_version_13( ) { let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); - let fund = contested_document_fund(platform_version); let mut platform = TestPlatformBuilder::new() .with_initial_protocol_version(13) .build_with_mock_rpc() @@ -3757,18 +3729,18 @@ mod creation_tests { platform_version, ) .await; + let fund = required_vote_resolution_fund(&dpns_contract.id(), "domain", platform_version); fill_contest_with_bare_contenders( &platform, - &dpns_contract, - "quantum", + &dpns_name_vote_poll(&dpns_contract, "quantum"), 250, platform_version, ); let contest_fund = dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version); - let (_, result) = add_contender_to_dpns_name_contest_paying( + let join = add_contender_to_dpns_name_contest_paying( &mut platform, &platform_state, 4, @@ -3777,19 +3749,18 @@ mod creation_tests { platform_version, ) .await; - assert_matches!(result, SuccessfulExecution { .. }); + assert_joined_paying(&platform, join, fund, platform_version); assert_eq!( dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version), contest_fund + fund ); } - /// A contender may state more than the fund it has to pay; all of it goes to the contest's - /// fund + /// A contender states the most it pays: it is charged the fund to join, and what it stated + /// beyond that stays with it #[tokio::test] - async fn should_put_everything_a_contender_pays_into_the_contest_fund() { + async fn should_charge_a_contender_the_fund_to_join_and_leave_it_the_rest() { let platform_version = PlatformVersion::latest(); - let fund = contested_document_fund(platform_version); let mut platform = TestPlatformBuilder::new() .with_latest_protocol_version() .build_with_mock_rpc() @@ -3804,10 +3775,11 @@ mod creation_tests { platform_version, ) .await; + let fund = required_vote_resolution_fund(&dpns_contract.id(), "domain", platform_version); let contest_fund = dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version); - let (_, result) = add_contender_to_dpns_name_contest_paying( + let join = add_contender_to_dpns_name_contest_paying( &mut platform, &platform_state, 4, @@ -3816,13 +3788,207 @@ mod creation_tests { platform_version, ) .await; - assert_matches!(result, SuccessfulExecution { .. }); + assert_joined_paying(&platform, join, fund, platform_version); + assert_eq!( + dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version), + contest_fund + fund + ); + + // Holding 300 contenders the contest takes four times its fund + fill_contest_with_bare_contenders( + &platform, + &dpns_name_vote_poll(&dpns_contract, "quantum"), + 300, + platform_version, + ); + let join = add_contender_to_dpns_name_contest_paying( + &mut platform, + &platform_state, + 9, + "quantum", + Some(10 * fund), + platform_version, + ) + .await; + assert_joined_paying(&platform, join, 4 * fund, platform_version); assert_eq!( dpns_name_contest_fund(&platform, &dpns_contract, "quantum", platform_version), - contest_fund + fund + fund / 2 + contest_fund + 5 * fund ); } + /// The 250th contender, joining a contest holding 249, still pays the contest's fund + #[tokio::test] + async fn should_let_the_250th_contender_join_for_the_contest_fund() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_state = platform.state.load(); + let (_, _, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + "quantum", + platform_version, + ) + .await; + let fund = required_vote_resolution_fund(&dpns_contract.id(), "domain", platform_version); + + fill_contest_with_bare_contenders( + &platform, + &dpns_name_vote_poll(&dpns_contract, "quantum"), + 249, + platform_version, + ); + let join = add_contender_to_dpns_name_contest_paying( + &mut platform, + &platform_state, + 4, + "quantum", + Some(fund), + platform_version, + ) + .await; + assert_joined_paying(&platform, join, fund, platform_version); + } + + /// The first contender of a contest pays its fund too: one stating less is refused, paid, + /// with the fund, and opens no contest + #[tokio::test] + async fn should_refuse_a_contender_opening_a_contest_for_less_than_its_fund() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let dpns_contract = platform + .drive + .cache + .system_data_contracts + .load_dpns(platform_version) + .expect("expected the dpns system contract"); + let fund = required_vote_resolution_fund(&dpns_contract.id(), "domain", platform_version); + + let platform_state = platform.state.load(); + let join = add_contender_to_dpns_name_contest_paying( + &mut platform, + &platform_state, + 4, + "sapphire", + Some(fund - 1), + platform_version, + ) + .await; + assert_refused_for_underpaying(join, fund, fund - 1); + let specialized_balance_id = dpns_name_vote_poll(&dpns_contract, "sapphire") + .specialized_balance_id() + .expect("expected the specialized balance id"); + assert_eq!( + platform + .drive + .fetch_prefunded_specialized_balance( + specialized_balance_id.to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch the contest's fund"), + None, + "the refused contender opened no contest" + ); + + let join = add_contender_to_dpns_name_contest_paying( + &mut platform, + &platform_state, + 9, + "sapphire", + Some(fund), + platform_version, + ) + .await; + assert_joined_paying(&platform, join, fund, platform_version); + } + + /// A contest started before protocol version 14 counts its contenders by walking them, not + /// from a count tree, and from 14 its fund doubles past 250 contenders like any other + #[tokio::test] + async fn should_double_the_fund_of_a_contest_started_before_protocol_version_14() { + let platform_version_13 = PlatformVersion::get(13).expect("expected protocol version 13"); + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_state = platform.state.load(); + let (_, _, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + "quantum", + platform_version_13, + ) + .await; + fill_contest_with_bare_contenders( + &platform, + &dpns_name_vote_poll(&dpns_contract, "quantum"), + 250, + platform_version_13, + ); + + let transaction = platform.drive.grove.start_transaction(); + platform + .perform_events_on_first_block_of_protocol_change( + &platform_state, + &BlockInfo::default_with_time( + platform_state + .last_committed_block_time_ms() + .unwrap_or_default() + + 1000, + ), + &transaction, + 13, + platform_version, + ) + .expect("expected the first block of protocol version 14"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + let mut upgraded_state = platform_state.as_ref().clone(); + upgraded_state.set_current_protocol_version_in_consensus(14); + upgraded_state.set_next_epoch_protocol_version(14); + platform.state.store(Arc::new(upgraded_state)); + let platform_state = platform.state.load(); + + let fund = required_vote_resolution_fund(&dpns_contract.id(), "domain", platform_version); + let join = add_contender_to_dpns_name_contest_paying( + &mut platform, + &platform_state, + 4, + "quantum", + Some(fund), + platform_version, + ) + .await; + assert_refused_for_underpaying(join, 2 * fund, fund); + + let join = add_contender_to_dpns_name_contest_paying( + &mut platform, + &platform_state, + 9, + "quantum", + Some(2 * fund), + platform_version, + ) + .await; + assert_joined_paying(&platform, join, 2 * fund, platform_version); + } + /// A contest accepts at most `max_contenders_per_contest` contenders (1,000): the one that /// would be the 1,001st is refused, paid #[tokio::test] @@ -3846,22 +4012,22 @@ mod creation_tests { fill_contest_with_bare_contenders( &platform, - &dpns_contract, - "quantum", + &dpns_name_vote_poll(&dpns_contract, "quantum"), max_contenders - 1, platform_version, ); // The 1,000th contender pays 32,768 times the fund - let (_, result) = add_contender_to_dpns_name_contest_paying( + let fund = required_vote_resolution_fund(&dpns_contract.id(), "domain", platform_version); + let join = add_contender_to_dpns_name_contest_paying( &mut platform, &platform_state, 4, "quantum", - Some(32_768 * contested_document_fund(platform_version)), + Some(32_768 * fund), platform_version, ) .await; - assert_matches!(result, SuccessfulExecution { .. }); + assert_joined_paying(&platform, join, 32_768 * fund, platform_version); add_contender_to_dpns_name_contest( &mut platform, @@ -3898,8 +4064,7 @@ mod creation_tests { fill_contest_with_bare_contenders( &platform, - &dpns_contract, - "quantum", + &dpns_name_vote_poll(&dpns_contract, "quantum"), max_contenders, platform_version, ); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs index 94a4ffedb5f..3a5e421f1e8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs @@ -5,7 +5,8 @@ //! included, keeps the generic windows and fund. use crate::execution::validation::state_transition::state_transitions::tests::{ - create_dpns_identity_name_contest, setup_identity, setup_masternode_voting_identity, + create_dpns_identity_name_contest, fill_contest_with_bare_contenders, setup_identity, + setup_masternode_voting_identity, }; use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; @@ -36,6 +37,7 @@ use dpp::platform_value::{Bytes32, Identifier, Value}; use dpp::prelude::IdentityNonce; use dpp::serialization::PlatformSerializable; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use dpp::state_transition::batch_transition::BatchTransition; use dpp::state_transition::masternode_vote_transition::methods::MasternodeVoteTransitionMethodsV0; use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; @@ -201,12 +203,14 @@ fn charter_poll(target: Identifier) -> ContestedDocumentResourceVotePoll { } /// A serialized create of a `document_type_name` document of the charter contract holding -/// `properties`, signed by `applicant`, and the id of the document it creates. +/// `properties`, signed by `applicant`, and the id of the document it creates. An application +/// states `contest_fund` as the most it pays into its election, the moderation fund when `None`. async fn create_transition( charters: &DataContract, applicant: &mut Applicant, document_type_name: &str, properties: BTreeMap, + contest_fund: Option, rng: &mut StdRng, platform_version: &PlatformVersion, ) -> (Vec, Identifier) { @@ -237,7 +241,10 @@ async fn create_transition( None, signer, platform_version, - None, + Some(StateTransitionCreationOptions { + contest_fund, + ..Default::default() + }), ) .await .expect("expected to create the batch transition"); @@ -347,6 +354,7 @@ async fn propose( ]), ), ]), + None, rng, platform_version, ) @@ -385,6 +393,29 @@ async fn application_naming_the_target_as( proposal_id: Identifier, rng: &mut StdRng, platform_version: &PlatformVersion, +) -> Vec { + application_stating( + charters, + applicant, + target, + proposal_id, + None, + rng, + platform_version, + ) + .await +} + +/// [`application_naming_the_target_as`] stating `contest_fund` as the most it pays into the +/// election, the moderation fund when `None`. +async fn application_stating( + charters: &DataContract, + applicant: &mut Applicant, + target: Value, + proposal_id: Identifier, + contest_fund: Option, + rng: &mut StdRng, + platform_version: &PlatformVersion, ) -> Vec { create_transition( charters, @@ -398,6 +429,7 @@ async fn application_naming_the_target_as( ), (property_names::MEMBERS.to_string(), Value::Array(vec![])), ]), + contest_fund, rng, platform_version, ) @@ -933,6 +965,86 @@ async fn should_prefund_each_application_with_half_a_dash_and_release_the_remain ); } +/// A moderation election doubles its own fund once it holds 250 applicants: an application +/// stating the moderation fund is refused, paid, with twice it, and one stating more joins, +/// paying twice the moderation fund and keeping the rest +#[tokio::test] +async fn should_double_the_moderation_fund_once_an_election_holds_250_applicants() { + let (mut platform, platform_version, charters, mut rng) = setup(); + let moderation_fund = platform_version + .fee_version + .vote_resolution_fund_fees + .moderation_vote_resolution_fund_required_amount; + + let target = elected_target(&platform, 0xA6, ONE_DAY, ONE_DAY, platform_version); + let poll = charter_poll(target); + let mut alice = applicant(&mut platform, &mut rng); + let mut bob = applicant(&mut platform, &mut rng); + + let (start, _) = apply( + &platform, + &charters, + &mut alice, + target, + 10_000, + &mut rng, + platform_version, + ) + .await; + fill_contest_with_bare_contenders(&platform, &poll, 250, platform_version); + let election_fund = prefunded_balance(&platform, &poll, platform_version); + + let proposal_id = propose( + &platform, + &charters, + &mut bob, + target, + start + 1000, + &mut rng, + platform_version, + ) + .await; + let underpaid = application_stating( + &charters, + &mut bob, + Value::Identifier(target.to_buffer()), + proposal_id, + Some(moderation_fund), + &mut rng, + platform_version, + ) + .await; + let ConsensusError::StateError(StateError::DocumentContestNotPaidForError(error)) = + process_refused(&platform, underpaid, start + 2000, platform_version) + else { + panic!("expected the election not to be paid for"); + }; + assert_eq!(error.expected_amount(), 2 * moderation_fund); + assert_eq!(error.paid_amount(), moderation_fund); + + let bob_before = balance_of(&platform, bob.id(), platform_version); + let application = application_stating( + &charters, + &mut bob, + Value::Identifier(target.to_buffer()), + proposal_id, + Some(3 * moderation_fund), + &mut rng, + platform_version, + ) + .await; + let fee = process_valid(&platform, application, start + 3000, platform_version); + assert_eq!( + bob_before - balance_of(&platform, bob.id(), platform_version), + 2 * moderation_fund + fee.total_base_fee(), + "applying costs twice the moderation fund on top of the document fee" + ); + assert_eq!( + prefunded_balance(&platform, &poll, platform_version), + election_fund + 2 * moderation_fund + ); +} + #[tokio::test] async fn should_keep_the_generic_windows_and_fund_for_a_dpns_contest() { let (mut platform, platform_version, _, _) = setup(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index b5663b58637..75757fdc576 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -172,6 +172,7 @@ pub(in crate::execution) mod tests { use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; + use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; use dpp::state_transition::masternode_vote_transition::methods::MasternodeVoteTransitionMethodsV0; use dpp::state_transition::StateTransition; @@ -192,6 +193,10 @@ pub(in crate::execution) mod tests { use drive::query::vote_poll_vote_state_query::ContestedDocumentVotePollDriveQueryResultType::DocumentsAndVoteTally; use drive::query::vote_poll_vote_state_query::{ContestedDocumentVotePollDriveQueryResultType, ResolvedContestedDocumentVotePollDriveQuery}; use drive::util::test_helpers::setup_contract; + use drive::drive::votes::paths::VotePollPaths; + use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::resolve::ContestedDocumentResourceVotePollResolver; + use drive::fees::op::LowLevelDriveOperation; + use drive::grovedb::Element; use crate::execution::types::block_execution_context::BlockExecutionContext; use crate::execution::types::block_execution_context::v0::BlockExecutionContextV0; use crate::expect_match; @@ -1826,7 +1831,11 @@ pub(in crate::execution) mod tests { expect_err: Option<&str>, platform_version: &PlatformVersion, ) -> Identity { - let (identity, result) = add_contender_to_dpns_name_contest_paying( + let DpnsContenderJoin { + contender: identity, + result, + .. + } = add_contender_to_dpns_name_contest_paying( platform, platform_state, seed, @@ -1851,24 +1860,34 @@ pub(in crate::execution) mod tests { identity } + /// A contender joining a DPNS name contest, see [`add_contender_to_dpns_name_contest_paying`] + pub(in crate::execution) struct DpnsContenderJoin { + /// The contender + pub contender: Identity, + /// Its balance once its preorder is in, before its document create + pub balance_before_create: Credits, + /// How its document create executed + pub result: StateTransitionExecutionResult, + } + /// Adds a contender to the DPNS name contest on `name` like - /// [`add_contender_to_dpns_name_contest`], stating `prefunded_voting_balance` as its fund, and - /// holding that much beside 0.5 Dash for fees, instead of the fund the transition is built - /// with. Returns the contender and how its document create executed. + /// [`add_contender_to_dpns_name_contest`], stating `contest_fund` as the most it pays into + /// the contest (the contest's fund when `None`) and holding that much beside 0.5 Dash for + /// fees. pub(in crate::execution) async fn add_contender_to_dpns_name_contest_paying( platform: &mut TempPlatform, platform_state: &PlatformState, seed: u64, name: &str, - prefunded_voting_balance: Option, + contest_fund: Option, platform_version: &PlatformVersion, - ) -> (Identity, StateTransitionExecutionResult) { + ) -> DpnsContenderJoin { let mut rng = StdRng::seed_from_u64(seed); let (identity_1, signer_1, key_1) = setup_identity( platform, rng.gen(), - dash_to_credits!(0.5) + prefunded_voting_balance.unwrap_or_default(), + dash_to_credits!(0.5) + contest_fund.unwrap_or_default(), ); let dpns = platform @@ -1960,7 +1979,7 @@ pub(in crate::execution) mod tests { .serialize_to_bytes() .expect("expected documents batch serialized state transition"); - let mut documents_batch_create_transition_1 = + let documents_batch_create_transition_1 = BatchTransition::new_document_creation_transition_from_document( document_1, domain, @@ -1971,31 +1990,14 @@ pub(in crate::execution) mod tests { None, &signer_1, platform_version, - None, + Some(StateTransitionCreationOptions { + contest_fund, + ..Default::default() + }), ) .await .expect("expect to create documents batch transition"); - if let Some(prefunded_voting_balance) = prefunded_voting_balance { - let StateTransition::Batch(batch) = &mut documents_batch_create_transition_1 else { - panic!("expected a batch transition"); - }; - let Some(BatchedTransitionMutRef::Document(DocumentTransition::Create(create))) = - batch.first_transition_mut() - else { - panic!("expected a document create"); - }; - create - .prefunded_voting_balances_mut() - .as_mut() - .expect("expected a contested document") - .1 = prefunded_voting_balance; - documents_batch_create_transition_1 - .sign_external(&key_1, &signer_1, Some(|_, _| Ok(SecurityLevel::HIGH))) - .await - .expect("expected to sign"); - } - let documents_batch_create_serialized_transition_1 = documents_batch_create_transition_1 .serialize_to_bytes() .expect("expected documents batch serialized state transition"); @@ -2027,7 +2029,18 @@ pub(in crate::execution) mod tests { .unwrap() .expect("expected to commit transaction"); - assert_eq!(processing_result.valid_count(), 1); + assert_eq!( + processing_result.valid_count(), + 1, + "expected the preorder to pass: {:?}", + processing_result.execution_results() + ); + + let balance_before_create = platform + .drive + .fetch_identity_balance(identity_1.id().to_buffer(), None, platform_version) + .expect("expected to fetch the contender's balance") + .expect("expected the contender to have a balance"); let transaction = platform.drive.grove.start_transaction(); @@ -2056,10 +2069,11 @@ pub(in crate::execution) mod tests { .unwrap() .expect("expected to commit transaction"); - ( - identity_1, - processing_result.into_execution_results().remove(0), - ) + DpnsContenderJoin { + contender: identity_1, + balance_before_create, + result: processing_result.into_execution_results().remove(0), + } } pub(in crate::execution) fn verify_dpns_name_contest( @@ -2286,6 +2300,54 @@ pub(in crate::execution) mod tests { } } + /// Fills the contest `vote_poll` up to `contenders` contenders with bare contender entries, + /// written straight to GroveDB in one batch: a join reads how many contenders a contest + /// holds, never what they hold, so this stands in for thousands of contested documents. + pub(in crate::execution) fn fill_contest_with_bare_contenders( + platform: &TempPlatform, + vote_poll: &ContestedDocumentResourceVotePoll, + contenders: u64, + platform_version: &PlatformVersion, + ) { + let resolved_vote_poll = vote_poll + .resolve(&platform.drive, None, platform_version) + .expect("expected to resolve the vote poll"); + let choices_path = resolved_vote_poll + .contenders_path(platform_version) + .expect("expected the choices path"); + let (_, held) = platform + .drive + .fetch_contested_document_vote_poll_contender_count( + &resolved_vote_poll, + u16::MAX, + &Default::default(), + None, + PlatformVersion::latest(), + ) + .expect("expected the contender count"); + let operations = (held as u64..contenders) + .map(|n| { + let mut key = [0xEEu8; 32]; + key[24..].copy_from_slice(&n.to_be_bytes()); + LowLevelDriveOperation::insert_for_known_path_key_element( + choices_path.clone(), + key.to_vec(), + Element::empty_tree(), + ) + }) + .collect(); + platform + .drive + .apply_batch_low_level_drive_operations( + None, + None, + operations, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to write the bare contenders"); + } + /// A masternode's signed vote on the DPNS name contest on `name`, serialized as broadcast #[allow(clippy::too_many_arguments)] pub(in crate::execution) async fn serialized_dpns_name_vote( diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs index 5584aec2faf..c56aa110ca0 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs @@ -81,6 +81,16 @@ impl DocumentCreateTransitionActionAccessorsV0 for DocumentCreateTransitionActio } } + fn set_prefunded_voting_fund(&mut self, fund: Credits) { + match self { + DocumentCreateTransitionAction::V0(v0) => { + if let Some((_, credits)) = v0.prefunded_voting_balance.as_mut() { + *credits = fund; + } + } + } + } + fn should_store_contest_info(&self) -> &Option { match self { DocumentCreateTransitionAction::V0(v0) => &v0.should_store_contest_info, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs index d119adcf799..aadd2dc3948 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs @@ -70,6 +70,11 @@ pub trait DocumentCreateTransitionActionAccessorsV0 { &self, ) -> &Option<(ContestedDocumentResourceVotePollWithContractInfo, Credits)>; + /// Sets what a contested create pays into its contest, which state validation settles at + /// the fund to join the contest once it has checked the contender stated at least that. A + /// create that joins no contest is left as it is. + fn set_prefunded_voting_fund(&mut self, fund: Credits); + /// Get the should store contest info (if it should be stored) fn should_store_contest_info(&self) -> &Option; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 9f0a5f27ba3..9020695db28 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -37,11 +37,12 @@ use crate::version::drive_abci_versions::drive_abci_validation_versions::{ // `maximum_contenders_to_consider` rises from 100 to 10,000 so the end of a poll // tallies and cleans up every contender of a poll within the 1,000 a contest // accepts, and up to 10,000 of one that grew past it before this version. -// Document create state validation 2 also refuses a contender whose prefunded voting -// balance is less than the fund doubled once the contest holds -// `contested_document_contenders_before_fund_doubling` (250) contenders and again for every -// `contested_document_contenders_per_fund_doubling` (50) more (DocumentContestNotPaidForError), and structure validation 1 accepts a prefunded voting -// balance of at least the contest's fund where 0 wanted exactly it. +// Document create state validation 2 also treats a contender's prefunded voting balance as +// the most it pays: it refuses one stating less than the fund to join, the contest's fund +// doubled once the contest holds `contested_document_contenders_before_fund_doubling` (250) +// contenders and again for every `contested_document_contenders_per_fund_doubling` (50) more +// (DocumentContestNotPaidForError), and charges one stating more only that fund. Structure +// validation 1 leaves the amount to it where 0 wanted exactly the contest's fund. // v9 remains unchanged for PROTOCOL_VERSION_13 chain replay. pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = DriveAbciValidationVersions { @@ -243,7 +244,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = // PROTOCOL_VERSION_14: a batch that asks the contract owner to pay its gas // only has to fund its principal (purchases, contest collateral) itself. identity_minimum_balance_pre_check: 1, - document_create_transition_structure_validation: 1, // changed: v1 also cross-checks the prefunded voting balance against the contested index, accepts one of at least the contest's fund, and refuses a `distinctFrom` identifier property equal to the value it must differ from + document_create_transition_structure_validation: 1, // changed: v1 also cross-checks the prefunded voting balance against the contested index, leaves its amount to state validation, and refuses a `distinctFrom` identifier property equal to the value it must differ from // Reject deletes on legacy keep-history types as paid consensus errors. // Protocols through 13 retain the original internal-error outcome. document_delete_transition_structure_validation: 1, diff --git a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs index df6b2560a27..0362a8df271 100644 --- a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs +++ b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs @@ -18,9 +18,9 @@ pub struct VoteResolutionFundFees { /// amount here, so choosing between the two changes nothing before 14. pub moderation_vote_resolution_fund_required_amount: u64, /// How many contenders a contest holds before the fund a contender joining it pays first - /// doubles: joining a contest holding fewer costs the contest's fund. Read with - /// `contested_document_contenders_per_fund_doubling`, by contested document create state - /// validation 2 only. + /// doubles: joining a contest holding fewer, a new one included, costs the contest's fund. + /// Read with `contested_document_contenders_per_fund_doubling`, by contested document + /// create state validation 2 only. pub contested_document_contenders_before_fund_doubling: u16, /// How many more contenders a contest holds for each further doubling of the fund a /// contender joining it pays: joining a contest holding `n` contenders, at least diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 34f4024210a..221475de4b2 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1286,18 +1286,19 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// contender; version 0 compared at most 100. /// /// 51. **The fund a contender pays doubles for every 50 contenders a contest -/// holds past 250**: document create state validation 2 refuses, paid, a -/// contender whose prefunded voting balance is less than the contest's fund -/// doubled once the contest holds -/// `contested_document_contenders_before_fund_doubling` (`FEE_VERSION3`, -/// 250) contenders and again for every -/// `contested_document_contenders_per_fund_doubling` (50) more -/// (`DocumentContestNotPaidForError`): 0.1 DASH for the first 250 DPNS -/// contenders, 0.2 for the next 50, up to 3,276.8 for the 951st to the -/// 1,000th, so filling a contest costs 327,695 DASH where it cost 100. -/// Document create structure validation 1 accepts a prefunded voting -/// balance of at least the contest's fund; version 0 wants exactly it. -/// Everything a contender pays goes to the contest's fund. +/// holds past 250**: the fund to join a contest is its fund doubled once +/// the contest holds `contested_document_contenders_before_fund_doubling` +/// (`FEE_VERSION3`, 250) contenders and again for every +/// `contested_document_contenders_per_fund_doubling` (50) more: 0.1 DASH +/// for the first 250 DPNS contenders, 0.2 for the next 50, up to 3,276.8 +/// for the 951st to the 1,000th, so filling a contest costs 327,695 DASH +/// where it cost 100. A contender's prefunded voting balance is the most it +/// pays: document create state validation 2 refuses, paid, one stating less +/// than the fund to join (`DocumentContestNotPaidForError`, carrying that +/// fund), the first contender of a new contest included, and charges one +/// stating more only the fund to join, the rest staying with it. Document +/// create structure validation 1 leaves the amount to state validation; +/// version 0 wants exactly the contest's fund. /// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index 79c18bc2e1f..e9e0aceddb4 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -55,13 +55,21 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return}; /// schema-driven sanitize step on the Rust side converts them to the /// protocol's native types. Pass `"{}"` for a document type with no /// required properties. +/// +/// `contest_fund` is the most, in credits, a contested document pays +/// into the contest it joins (the fund to join doubles once a contest +/// holds 250 contenders and again for every 50 more). `0` states the +/// fund to join read just before the document is submitted. A document +/// that joins no contest ignores it. #[no_mangle] +#[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_create_document_with_signer( wallet_handle: Handle, owner_identity_id: *const u8, contract_id: *const u8, document_type_name: *const c_char, properties_json: *const c_char, + contest_fund: u64, signer_handle: *mut SignerHandle, out_document_id: *mut u8, out_document_json: *mut *mut c_char, @@ -82,6 +90,11 @@ pub unsafe extern "C" fn platform_wallet_create_document_with_signer( let document_type_str = unwrap_result_or_return!(CStr::from_ptr(document_type_name).to_str()).to_string(); let properties_str = unwrap_result_or_return!(CStr::from_ptr(properties_json).to_str()); + let contest_fund = if contest_fund == 0 { + None + } else { + Some(contest_fund) + }; let signer_addr = signer_handle as usize; let owner_id_for_async = owner_id; @@ -98,6 +111,7 @@ pub unsafe extern "C" fn platform_wallet_create_document_with_signer( &contract_id_for_async, &document_type_str, properties_str, + contest_fund, signer, ) .await?; diff --git a/packages/rs-platform-wallet-ffi/src/dpns.rs b/packages/rs-platform-wallet-ffi/src/dpns.rs index 775d1668562..e73d13c76af 100644 --- a/packages/rs-platform-wallet-ffi/src/dpns.rs +++ b/packages/rs-platform-wallet-ffi/src/dpns.rs @@ -44,11 +44,18 @@ pub struct DpnsSearchResultFFI { /// `on_persist_identities_fn`. `signer_handle` must be a valid, /// non-destroyed handle produced by `dash_sdk_signer_create_with_ctx` /// (typically `KeychainSigner.handle`); the caller retains ownership. +/// +/// `contest_fund` is the most, in credits, the registration pays into +/// the contest a contested name joins (the fund to join doubles once a +/// contest holds 250 contenders and again for every 50 more). `0` states +/// the fund to join read just before the domain is submitted. A name +/// that joins no contest ignores it. #[no_mangle] pub unsafe extern "C" fn platform_wallet_register_dpns_name_with_signer( wallet_handle: Handle, identity_id: *const u8, name: *const c_char, + contest_fund: u64, signer_handle: *mut SignerHandle, out_full_domain_name: *mut *mut c_char, ) -> PlatformWalletFFIResult { @@ -63,6 +70,11 @@ pub unsafe extern "C" fn platform_wallet_register_dpns_name_with_signer( let id = unwrap_result_or_return!(unsafe { read_identifier(identity_id) }); let name_str = unwrap_result_or_return!(unsafe { CStr::from_ptr(name) }.to_str()).to_string(); + let contest_fund = if contest_fund == 0 { + None + } else { + Some(contest_fund) + }; let signer_addr = signer_handle as usize; @@ -71,7 +83,7 @@ pub unsafe extern "C" fn platform_wallet_register_dpns_name_with_signer( block_on_worker(async move { let signer: &VTableSigner = unsafe { &*(signer_addr as *const VTableSigner) }; identity_wallet - .register_name_with_external_signer(&id, &name_str, signer) + .register_name_with_external_signer(&id, &name_str, contest_fund, signer) .await }) }); diff --git a/packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs b/packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs index 9db1d6a312a..fcc44cdf40a 100644 --- a/packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs +++ b/packages/rs-platform-wallet/examples/dpns_marketplace_testnet.rs @@ -374,7 +374,7 @@ async fn run_flow( let label = format!("mktp{unix}test"); println!("== registering test name {label:?} on seller =="); let full_name = idw - .register_name_with_external_signer(&seller_id, &label, &signer) + .register_name_with_external_signer(&seller_id, &label, None, &signer) .await?; check("register", full_name.ends_with(".dash"), &full_name); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs index c0409a1e3f5..a25542e544c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs @@ -45,6 +45,7 @@ use dpp::identity::signer::Signer; use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::platform_value::{BinaryData, Value}; use dpp::prelude::{DataContract, Identifier}; +use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use dpp::ProtocolError; use dash_sdk::platform::documents::transitions::{ @@ -54,6 +55,7 @@ use dash_sdk::platform::documents::transitions::{ DocumentTransferTransitionBuilder, }; use dash_sdk::platform::transition::put_document::PutDocument; +use dash_sdk::platform::transition::put_settings::PutSettings; use dash_sdk::platform::{ContextProvider, DocumentQuery, Fetch}; use crate::error::PlatformWalletError; @@ -178,12 +180,19 @@ impl IdentityWallet { /// sanitize step converts them to the protocol's native `Bytes` / /// `Identifier` values. An empty object (`"{}"`) is valid for a /// document type with no required properties. + /// + /// `contest_fund` is the most a contested document pays into the + /// contest it joins (the fund to join doubles once a contest holds 250 + /// contenders and again for every 50 more); `None` states the fund to + /// join read just before the document is submitted. A document that + /// joins no contest ignores it. pub async fn create_document_with_signer( &self, owner_identity_id: &Identifier, contract_id: &Identifier, document_type_name: &str, properties_json: &str, + contest_fund: Option, signer: &S, ) -> Result where @@ -296,6 +305,13 @@ impl IdentityWallet { // worker stack. `None` entropy -> the SDK generates entropy // and the canonical document id for this revision-1 create; // `None` token-payment-info -> no token gating. + let settings = contest_fund.map(|contest_fund| PutSettings { + state_transition_creation_options: Some(StateTransitionCreationOptions { + contest_fund: Some(contest_fund), + ..Default::default() + }), + ..Default::default() + }); let confirmed = document .put_to_platform_and_wait_for_response( &self.sdk, @@ -304,7 +320,7 @@ impl IdentityWallet { signing_key, None, &SignerRef(signer), - None, + settings, ) .await .map_err(|e| { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs index 86f0b809bb2..1629bf2149e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs @@ -1,5 +1,6 @@ //! DPNS name registration, resolution, search, and contest queries. +use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; @@ -131,12 +132,17 @@ impl IdentityWallet { /// `IdentityManager`. The caller supplies the `Identity`, the /// signing key, and a `Signer` directly. /// + /// `contest_fund` is the most the registration pays into the contest a contested name + /// joins (the fund to join doubles once a contest holds 250 contenders and again for every + /// 50 more); `None` states the fund to join read just before the domain is submitted. + /// /// Returns the full domain name (e.g. "alice.dash"). pub async fn register_name_with_signer>( &self, identity: Identity, name: &str, identity_public_key: IdentityPublicKey, + contest_fund: Option, signer: S, ) -> Result { use dash_sdk::platform::dpns_usernames::RegisterDpnsNameInput; @@ -147,6 +153,7 @@ impl IdentityWallet { identity_public_key, signer, preorder_callback: None, + contest_fund, }; let result = self.sdk.register_dpns_name(input).await?; @@ -160,6 +167,9 @@ impl IdentityWallet { /// /// * `identity_id` - The identity to register the name for. /// * `name` - The desired username label (e.g., "alice"). + /// * `contest_fund` - The most the registration pays into the contest a + /// contested name joins; `None` states the fund to join read just + /// before the domain is submitted. /// * `signer` - External `Signer` for the /// document state-transition signature — the architecturally /// correct path per `swift-sdk/CLAUDE.md`. @@ -177,6 +187,7 @@ impl IdentityWallet { &self, identity_id: &Identifier, name: &str, + contest_fund: Option, signer: &S, ) -> Result where @@ -241,6 +252,7 @@ impl IdentityWallet { // over ownership / wrap in an Arc per call. signer: SignerRef(signer), preorder_callback: None, + contest_fund, }; let result = self.sdk.register_dpns_name(input).await.map_err(|e| { diff --git a/packages/rs-sdk-ffi/src/document/helpers.rs b/packages/rs-sdk-ffi/src/document/helpers.rs index b7f7c9d3746..afdc0239f1b 100644 --- a/packages/rs-sdk-ffi/src/document/helpers.rs +++ b/packages/rs-sdk-ffi/src/document/helpers.rs @@ -104,5 +104,36 @@ pub unsafe fn convert_state_transition_creation_options( Some(options.base_feature_version) }, action_fee_agreement: None, + // 0 leaves a contested create stating the fund to join, which rs-sdk reads when it signs + contest_fund: if options.contest_fund == 0 { + None + } else { + Some(options.contest_fund) + }, }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn creation_options(contest_fund: u64) -> DashSDKStateTransitionCreationOptions { + DashSDKStateTransitionCreationOptions { + allow_signing_with_any_security_level: false, + allow_signing_with_any_purpose: false, + batch_feature_version: 0, + method_feature_version: 0, + base_feature_version: 0, + contest_fund, + } + } + + #[test] + fn should_state_the_contest_fund_only_when_it_is_not_zero() { + let unset = unsafe { convert_state_transition_creation_options(&creation_options(0)) }; + assert_eq!(unset, Some(StateTransitionCreationOptions::default())); + + let stated = unsafe { convert_state_transition_creation_options(&creation_options(7)) }; + assert_eq!(stated.and_then(|options| options.contest_fund), Some(7)); + } +} diff --git a/packages/rs-sdk-ffi/src/dpns/register.rs b/packages/rs-sdk-ffi/src/dpns/register.rs index e3ed6a4f544..6977be10026 100644 --- a/packages/rs-sdk-ffi/src/dpns/register.rs +++ b/packages/rs-sdk-ffi/src/dpns/register.rs @@ -106,6 +106,7 @@ pub unsafe extern "C" fn dash_sdk_dpns_register_name( identity_public_key: key_clone, signer: signer_ref, preorder_callback: None, + contest_fund: None, }; // Register the name diff --git a/packages/rs-sdk-ffi/src/identity/helpers.rs b/packages/rs-sdk-ffi/src/identity/helpers.rs index e92ec59259d..0e106bfd9b6 100644 --- a/packages/rs-sdk-ffi/src/identity/helpers.rs +++ b/packages/rs-sdk-ffi/src/identity/helpers.rs @@ -61,6 +61,7 @@ pub unsafe fn convert_put_settings(put_settings: *const DashSDKPutSettings) -> O method_feature_version: None, base_feature_version: None, action_fee_agreement: None, + contest_fund: None, }); let wait_timeout = if ios_settings.wait_timeout_ms > 0 { diff --git a/packages/rs-sdk-ffi/src/token/utils.rs b/packages/rs-sdk-ffi/src/token/utils.rs index 53da1090c0a..b628c87b35b 100644 --- a/packages/rs-sdk-ffi/src/token/utils.rs +++ b/packages/rs-sdk-ffi/src/token/utils.rs @@ -43,6 +43,8 @@ pub unsafe fn convert_state_transition_creation_options( Some(options.base_feature_version) }, action_fee_agreement: None, + // A token transition joins no contest, so `options.contest_fund` is not read + contest_fund: None, }) } diff --git a/packages/rs-sdk-ffi/src/types.rs b/packages/rs-sdk-ffi/src/types.rs index da0d9d32622..913d25148c2 100644 --- a/packages/rs-sdk-ffi/src/types.rs +++ b/packages/rs-sdk-ffi/src/types.rs @@ -670,6 +670,9 @@ pub struct DashSDKStateTransitionCreationOptions { pub method_feature_version: u16, /// Base feature version (0 means use default) pub base_feature_version: u16, + /// Most a contested document create pays into the contest it joins, in credits (0 means + /// the fund to join read just before the create is signed). Only document creates read it. + pub contest_fund: u64, } /// Free a string allocated by the FFI diff --git a/packages/rs-sdk/src/platform/documents/contest_fund.rs b/packages/rs-sdk/src/platform/documents/contest_fund.rs new file mode 100644 index 00000000000..699dc5ad29c --- /dev/null +++ b/packages/rs-sdk/src/platform/documents/contest_fund.rs @@ -0,0 +1,265 @@ +//! The fund a contested document create pays to join its contest. +//! +//! From protocol version 14 the fund doubles once a contest holds 250 contenders and again for +//! every 50 more, and a create states the most it pays: Platform charges it the fund to join +//! and refuses it, paid, when it stated less. A create that names no maximum states the fund to +//! join read here just before it is signed. + +use crate::platform::fetch_many::FetchMany; +use crate::{Error, Sdk}; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::document::Document; +use dpp::fee::Credits; +use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; +use dpp::voting::contender_structs::ContenderWithSerializedDocument; +use dpp::voting::vote_polls::VotePoll; +use drive::config::DEFAULT_QUERY_LIMIT; +use drive::query::vote_poll_vote_state_query::{ + ContestedDocumentVotePollDriveQuery, ContestedDocumentVotePollDriveQueryResultType, +}; + +impl Sdk { + /// The fund a contested create of `document` pays to join its contest now, or `None` when + /// the document joins no contest: the contest's fund, doubled once the contest holds 250 + /// contenders and again for every 50 more (protocol version 14; before it the fund never + /// doubles). + /// + /// The contenders are counted with proved contested resource vote state queries of up to + /// 100 contenders each, so a contest of `n` contenders costs `n / 100 + 1` of them. The + /// fund is priced at the protocol version known once they are read, which the first + /// response of an SDK that auto-detects the version brings. + pub async fn contest_fund_to_join( + &self, + document_type: DocumentTypeRef<'_>, + document: &Document, + ) -> Result, Error> { + let Some(VotePoll::ContestedDocumentResourceVotePoll(vote_poll)) = + document_type.contested_vote_poll_for_document(document, self.version())? + else { + return Ok(None); + }; + + let mut contenders: u16 = 0; + let mut start_at = None; + loop { + let page = ContenderWithSerializedDocument::fetch_many( + self, + ContestedDocumentVotePollDriveQuery { + vote_poll: vote_poll.clone(), + result_type: ContestedDocumentVotePollDriveQueryResultType::VoteTally, + offset: None, + limit: Some(DEFAULT_QUERY_LIMIT), + start_at, + allow_include_locked_and_abstaining_vote_tally: false, + }, + ) + .await?; + let read = page.contenders.len(); + contenders = contenders.saturating_add(u16::try_from(read).unwrap_or(u16::MAX)); + // A contest accepts at most `max_contenders_per_contest`, and a join past it is + // refused whatever it states, so counting stops there + let max_contenders = self.version().system_limits.max_contenders_per_contest; + match page.contenders.keys().next_back() { + Some(last) + if read >= DEFAULT_QUERY_LIMIT as usize && contenders < max_contenders => + { + start_at = Some((last.to_buffer(), false)); + } + _ => break, + } + } + + Ok(Some(vote_poll.required_vote_resolution_fund_to_join( + contenders, + self.version(), + ))) + } +} + +/// `options` naming the most a create of `document` pays into the contest it joins: the fund +/// to join the contest now, when the options name no maximum and the document is contested. +/// Callers read it before they reserve an identity contract nonce, so a failed read spends none. +pub(crate) async fn with_contest_fund_to_join( + sdk: &Sdk, + document_type: DocumentTypeRef<'_>, + document: &Document, + options: Option, +) -> Result, Error> { + if options.and_then(|options| options.contest_fund).is_some() { + return Ok(options); + } + let Some(contest_fund) = sdk.contest_fund_to_join(document_type, document).await? else { + return Ok(options); + }; + Ok(Some(StateTransitionCreationOptions { + contest_fund: Some(contest_fund), + ..options.unwrap_or_default() + })) +} + +#[cfg(all(test, feature = "mocks"))] +mod tests { + use super::*; + use crate::SdkBuilder; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::document::DocumentV0; + use dpp::platform_value::{Identifier, Value}; + use dpp::tests::fixtures::get_dpns_data_contract_fixture; + use dpp::version::PlatformVersion; + use dpp::voting::contender_structs::ContenderWithSerializedDocumentV0; + use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; + use drive_proof_verifier::types::Contenders; + use std::collections::BTreeMap; + + fn dpns_contract() -> dpp::data_contract::DataContract { + get_dpns_data_contract_fixture(Some(Identifier::new([7; 32])), 0, 14).data_contract_owned() + } + + /// A DPNS domain document for `label`, which contests the name + fn domain(label: &str) -> Document { + DocumentV0 { + properties: BTreeMap::from([ + ("label".to_string(), Value::Text(label.to_string())), + ( + "normalizedLabel".to_string(), + Value::Text(label.to_string()), + ), + ( + "parentDomainName".to_string(), + Value::Text("dash".to_string()), + ), + ( + "normalizedParentDomainName".to_string(), + Value::Text("dash".to_string()), + ), + ]), + ..Default::default() + } + .into() + } + + /// The contest query reading the page of contenders after `start_at` + fn contenders_page_query( + contract: &dpp::data_contract::DataContract, + label: &str, + start_at: Option<[u8; 32]>, + ) -> ContestedDocumentVotePollDriveQuery { + ContestedDocumentVotePollDriveQuery { + vote_poll: ContestedDocumentResourceVotePoll { + contract_id: contract.id(), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + Value::Text("dash".to_string()), + Value::Text(label.to_string()), + ], + }, + result_type: ContestedDocumentVotePollDriveQueryResultType::VoteTally, + offset: None, + limit: Some(DEFAULT_QUERY_LIMIT), + start_at: start_at.map(|start_at| (start_at, false)), + allow_include_locked_and_abstaining_vote_tally: false, + } + } + + fn contender_id(n: u16) -> [u8; 32] { + let mut id = [0u8; 32]; + id[30..].copy_from_slice(&n.to_be_bytes()); + id + } + + /// Contenders `from` up to `to`, exclusive + fn contenders(from: u16, to: u16) -> Contenders { + Contenders::from_iter((from..to).map(|n| { + let identity_id = Identifier::new(contender_id(n)); + ( + identity_id, + Some(ContenderWithSerializedDocument::V0( + ContenderWithSerializedDocumentV0 { + identity_id, + serialized_document: None, + vote_tally: Some(0), + }, + )), + ) + })) + } + + /// The fund to join a contest of 250 contenders is twice the contest's fund, read page by + /// page, and it is what a create naming no maximum states + #[tokio::test] + async fn should_state_the_fund_to_join_a_contest_read_page_by_page() { + let mut sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .build() + .expect("expected a mock sdk"); + let contract = dpns_contract(); + let document_type = contract + .document_type_for_name("domain") + .expect("expected the domain document type"); + let fund = sdk + .version() + .fee_version + .vote_resolution_fund_fees + .contested_document_vote_resolution_fund_required_amount; + + for (start_at, page) in [ + (None, contenders(0, 100)), + (Some(contender_id(99)), contenders(100, 200)), + (Some(contender_id(199)), contenders(200, 250)), + ] { + sdk.mock() + .expect_fetch_many::<_, ContenderWithSerializedDocument, _, Contenders>( + contenders_page_query(&contract, "quantum", start_at), + Some(page), + ) + .await + .expect("expected to register the page"); + } + + let options = with_contest_fund_to_join(&sdk, document_type, &domain("quantum"), None) + .await + .expect("expected to read the fund to join"); + assert_eq!( + options.and_then(|options| options.contest_fund), + Some(2 * fund) + ); + } + + /// Nothing is read for a create joining no contest, or one naming the most it pays + #[tokio::test] + async fn should_read_nothing_when_the_create_joins_no_contest_or_names_its_maximum() { + let sdk = SdkBuilder::new_mock().build().expect("expected a mock sdk"); + let contract = dpns_contract(); + let domain_type = contract + .document_type_for_name("domain") + .expect("expected the domain document type"); + let preorder_type = contract + .document_type_for_name("preorder") + .expect("expected the preorder document type"); + + let preorder: Document = DocumentV0 { + properties: BTreeMap::from([("saltedDomainHash".to_string(), Value::Bytes32([1; 32]))]), + ..Default::default() + } + .into(); + assert_eq!( + with_contest_fund_to_join(&sdk, preorder_type, &preorder, None) + .await + .expect("expected no read"), + None + ); + + let naming_its_maximum = Some(StateTransitionCreationOptions { + contest_fund: Some(5), + ..Default::default() + }); + assert_eq!( + with_contest_fund_to_join(&sdk, domain_type, &domain("quantum"), naming_its_maximum) + .await + .expect("expected no read"), + naming_its_maximum + ); + } +} diff --git a/packages/rs-sdk/src/platform/documents/mod.rs b/packages/rs-sdk/src/platform/documents/mod.rs index e34a2d0be6f..5bf12321fb8 100644 --- a/packages/rs-sdk/src/platform/documents/mod.rs +++ b/packages/rs-sdk/src/platform/documents/mod.rs @@ -12,6 +12,7 @@ pub use dash_platform_queries::documents::{ }; pub mod chained_document_query_sdk; +pub mod contest_fund; pub mod document_query_sdk; mod fetch_bindings; pub mod transitions; diff --git a/packages/rs-sdk/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index 438f2dd4a63..90098883e51 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -1,3 +1,4 @@ +use crate::platform::documents::contest_fund::with_contest_fund_to_join; use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; use crate::{Error, Sdk}; @@ -5,6 +6,7 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::action_fees::agreement::DocumentActionFeeAgreement; use dpp::data_contract::DataContract; use dpp::document::{Document, DocumentV0Getters}; +use dpp::fee::Credits; use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; use dpp::prelude::UserFeeIncrease; @@ -146,6 +148,32 @@ impl DocumentCreateTransitionBuilder { self } + /// Names the most this create is willing to pay into the contest it joins, when its + /// document is contested. From protocol version 14 it is charged the fund to join the + /// contest, which doubles once the contest holds 250 contenders and again for every 50 + /// more, and refused, paid, when that is more than this. Without it the create states the + /// fund to join read when it is signed, so it is refused if others join first and push the + /// fund up. The identity must hold what it states, since Platform checks its balance + /// against it before counting the contest. Before protocol version 14 a contender states + /// exactly the contest's fund. + /// + /// Call it after `with_state_transition_creation_options`, which replaces the options this + /// is kept in. + /// + /// # Arguments + /// + /// * `contest_fund` - The most the create pays into its contest + /// + /// # Returns + /// + /// * `Self` - The updated builder + pub fn with_contest_fund(mut self, contest_fund: Credits) -> Self { + self.state_transition_creation_options + .get_or_insert_with(Default::default) + .contest_fund = Some(contest_fund); + self + } + /// Signs the document create transition /// /// # Arguments @@ -171,6 +199,20 @@ impl DocumentCreateTransitionBuilder { creation_options.validate_base_carries_action_fee_agreement(platform_version)?; } + let document_type = self + .data_contract + .document_type_for_name(&self.document_type_name) + .map_err(|e| Error::Protocol(e.into()))?; + + // A contested create states the most it pays to join its contest + let state_transition_creation_options = with_contest_fund_to_join( + sdk, + document_type, + &self.document, + self.state_transition_creation_options, + ) + .await?; + let identity_contract_nonce = sdk .get_identity_contract_nonce( self.document.owner_id(), @@ -180,11 +222,6 @@ impl DocumentCreateTransitionBuilder { ) .await?; - let document_type = self - .data_contract - .document_type_for_name(&self.document_type_name) - .map_err(|e| Error::Protocol(e.into()))?; - let state_transition = BatchTransition::new_document_creation_transition_from_document( self.document.clone(), document_type, @@ -195,7 +232,7 @@ impl DocumentCreateTransitionBuilder { self.token_payment_info, signer, platform_version, - self.state_transition_creation_options, + state_transition_creation_options, ) .await?; diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 00fc293b348..e7dc5b5fad7 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -8,17 +8,20 @@ pub use dash_platform_queries::dpns_usernames::{ pub use queries::DpnsUsername; use crate::platform::transition::put_document::PutDocument; +use crate::platform::transition::put_settings::PutSettings; use crate::platform::{Document, FetchMany}; use crate::{Error, Sdk}; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::document::{DocumentV0, DocumentV0Getters}; +use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::platform_value::Value; use dpp::prelude::Identifier; +use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use std::collections::BTreeMap; use std::sync::Arc; use tracing::debug; @@ -68,6 +71,11 @@ pub struct RegisterDpnsNameInput> { pub signer: S, /// Optional callback to be called with the preorder document result pub preorder_callback: Option, + /// The most the registration is willing to pay into the contest a contested name joins. + /// From protocol version 14 the fund to join doubles once a contest holds 250 contenders + /// and again for every 50 more, and a registration is charged it. The identity must hold + /// what it states. `None` states the fund to join read just before the domain is submitted. + pub contest_fund: Option, } /// Result of a DPNS name registration @@ -261,6 +269,13 @@ impl Sdk { // Submit domain document after preorder debug!(%identity_id, stage = "domain", "DPNS registration: submitting document"); + let domain_settings = input.contest_fund.map(|contest_fund| PutSettings { + state_transition_creation_options: Some(StateTransitionCreationOptions { + contest_fund: Some(contest_fund), + ..Default::default() + }), + ..Default::default() + }); let platform_domain_document = domain_document .put_to_platform_and_wait_for_response( self, @@ -269,7 +284,7 @@ impl Sdk { input.identity_public_key, None, // token payment info &input.signer, - None, // settings + domain_settings, ) .await .inspect_err(|error| { diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index 792b356c7a3..231db20015e 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -1,6 +1,7 @@ use super::broadcast::BroadcastStateTransition; use super::validation::ensure_valid_state_transition_structure; use super::waitable::{wait_for_document_and_owner_balance, Waitable}; +use crate::platform::documents::contest_fund::with_contest_fund_to_join; use crate::platform::transition::put_settings::PutSettings; use crate::{Error, Sdk}; use dpp::dashcore::secp256k1::rand::rngs::StdRng; @@ -94,12 +95,24 @@ impl> PutDocument for Document { ) -> Result { // A local failure after the nonce is reserved would leave the cached nonce ahead of // Platform's, so what can be refused without it is refused first. - if let Some(creation_options) = - settings.and_then(|settings| settings.state_transition_creation_options) - { + let creation_options = + settings.and_then(|settings| settings.state_transition_creation_options); + if let Some(creation_options) = creation_options { creation_options.validate_base_carries_action_fee_agreement(sdk.version())?; } + let document = prepare_document_for_transition(self, &document_type); + let is_replacement = + self.revision().is_some() && self.revision().unwrap() != INITIAL_REVISION; + // A contested create states the most it pays to join its contest, read before the + // nonce is reserved + let creation_options = if is_replacement { + creation_options + } else { + with_contest_fund_to_join(sdk, document_type.as_ref(), &document, creation_options) + .await? + }; + let new_identity_contract_nonce = sdk .get_identity_contract_nonce( self.owner_id(), @@ -110,72 +123,70 @@ impl> PutDocument for Document { .await?; let settings = settings.unwrap_or_default(); - let document = prepare_document_for_transition(self, &document_type); - let transition = - if self.revision().is_some() && self.revision().unwrap() != INITIAL_REVISION { - BatchTransition::new_document_replacement_transition_from_document( - document, - document_type.as_ref(), - &identity_public_key, - new_identity_contract_nonce, - settings.user_fee_increase.unwrap_or_default(), - token_payment_info, - signer, - sdk.version(), - settings.state_transition_creation_options, - ) - .await? - } else { - let (document, document_state_transition_entropy) = - match document_state_transition_entropy { - Some(entropy) => { - // While the id derives from the entropy alone, a caller-supplied - // entropy must derive the document's own id: consensus recomputes - // it and rejects the create with InvalidDocumentTransitionIdError - // on mismatch, so guard here before broadcasting to fail locally - // (no wasted nonce/fee). Once the id also commits to the identity - // contract nonce, the id the caller set is only a placeholder and - // the transition is built with the id derived below. - if !Document::document_id_depends_on_nonce(sdk.version())? { - ensure_entropy_matches_document_id( - &document_type.data_contract_id(), - &document.owner_id(), - document_type.name(), - &entropy, - document.id(), - )?; - } - (document, entropy) - } - None => { - let mut rng = StdRng::from_entropy(); - let mut document = document; - let entropy = rng.gen::<[u8; 32]>(); - document.set_id(Document::generate_document_id( + let transition = if is_replacement { + BatchTransition::new_document_replacement_transition_from_document( + document, + document_type.as_ref(), + &identity_public_key, + new_identity_contract_nonce, + settings.user_fee_increase.unwrap_or_default(), + token_payment_info, + signer, + sdk.version(), + creation_options, + ) + .await? + } else { + let (document, document_state_transition_entropy) = + match document_state_transition_entropy { + Some(entropy) => { + // While the id derives from the entropy alone, a caller-supplied + // entropy must derive the document's own id: consensus recomputes + // it and rejects the create with InvalidDocumentTransitionIdError + // on mismatch, so guard here before broadcasting to fail locally + // (no wasted nonce/fee). Once the id also commits to the identity + // contract nonce, the id the caller set is only a placeholder and + // the transition is built with the id derived below. + if !Document::document_id_depends_on_nonce(sdk.version())? { + ensure_entropy_matches_document_id( &document_type.data_contract_id(), &document.owner_id(), document_type.name(), - entropy.as_slice(), - new_identity_contract_nonce, - sdk.version(), - )?); - (document, entropy) + &entropy, + document.id(), + )?; } - }; - BatchTransition::new_document_creation_transition_from_document( - document, - document_type.as_ref(), - document_state_transition_entropy, - &identity_public_key, - new_identity_contract_nonce, - settings.user_fee_increase.unwrap_or_default(), - token_payment_info, - signer, - sdk.version(), - settings.state_transition_creation_options, - ) - .await? - }; + (document, entropy) + } + None => { + let mut rng = StdRng::from_entropy(); + let mut document = document; + let entropy = rng.gen::<[u8; 32]>(); + document.set_id(Document::generate_document_id( + &document_type.data_contract_id(), + &document.owner_id(), + document_type.name(), + entropy.as_slice(), + new_identity_contract_nonce, + sdk.version(), + )?); + (document, entropy) + } + }; + BatchTransition::new_document_creation_transition_from_document( + document, + document_type.as_ref(), + document_state_transition_entropy, + &identity_public_key, + new_identity_contract_nonce, + settings.user_fee_increase.unwrap_or_default(), + token_payment_info, + signer, + sdk.version(), + creation_options, + ) + .await? + }; ensure_valid_state_transition_structure(&transition, sdk.version())?; // response is empty for a broadcast, result comes from the stream wait for state transition result diff --git a/packages/rs-unified-sdk-jni/src/identity.rs b/packages/rs-unified-sdk-jni/src/identity.rs index 48418e3d66c..560891952b6 100644 --- a/packages/rs-unified-sdk-jni/src/identity.rs +++ b/packages/rs-unified-sdk-jni/src/identity.rs @@ -839,6 +839,10 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_discov /// Register a DPNS name for an identity, signed via the external signer. /// Works on watch-only wallets (no seed Rust-side). Returns the full /// domain name (e.g. `"alice.dash"`). +/// +/// `maxContestFund` is the most, in credits, the registration pays into +/// the contest a contested name joins; `0` states the fund to join read +/// just before the domain is submitted. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_registerDpnsName( mut env: JNIEnv, @@ -846,9 +850,14 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_regist wallet_handle: jlong, identity_id: JByteArray, label: JString, + max_contest_fund: jlong, signer_handle: jlong, ) -> jni::sys::jstring { guard(&mut env, ptr::null_mut(), |env| { + if max_contest_fund < 0 { + throw_sdk_exception(env, 1, "maxContestFund must be non-negative"); + return ptr::null_mut(); + } let Some(id) = read_id32(env, &identity_id, "identityId") else { return ptr::null_mut(); }; @@ -874,6 +883,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_regist wallet_handle as Handle, id.as_ptr(), c_label.as_ptr(), + max_contest_fund as u64, signer_handle as *mut SignerHandle, &mut out_full as *mut *mut c_char, ) diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index ea20309ab9a..c9347bedfc8 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -516,13 +516,16 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do /// document type's requirement, so key selection never crosses JNI. /// `propertiesJson` is a JSON object keyed by property name (byte-array /// fields as hex, identifier fields as base58); pass `"{}"` for a type -/// with no required properties. +/// with no required properties. `maxContestFund` is the most, in +/// credits, a contested document pays into the contest it joins; `0` +/// states the fund to join read just before the document is submitted. /// /// Returns the confirmed document's canonical query-side JSON — the same /// shape a DOC-01 query returns, with the 32-byte id rendered as the /// base58 `$id` field, so Kotlin reads the id from there rather than a /// second return. Null after throwing on error. #[no_mangle] +#[allow(clippy::too_many_arguments)] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_documentCreate( mut env: JNIEnv, _class: JClass, @@ -531,9 +534,14 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do contract_id: JByteArray, document_type: JString, properties_json: JString, + max_contest_fund: jlong, signer_handle: jlong, ) -> jstring { guard(&mut env, ptr::null_mut(), |env| { + if max_contest_fund < 0 { + throw_sdk_exception(env, 1, "maxContestFund must be non-negative"); + return ptr::null_mut(); + } let Some(owner) = read_id32(env, &owner_id, "ownerId") else { return ptr::null_mut(); }; @@ -556,6 +564,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do contract.as_ptr(), doc_type.as_ptr(), props.as_ptr(), + max_contest_fund as u64, signer_handle as *mut SignerHandle, out_id.as_mut_ptr(), &mut out_json as *mut *mut c_char, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift index f6feefd05b1..fdc8b66b7ef 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift @@ -437,12 +437,18 @@ extension SDK { // MARK: - Document State Transitions /// Create a new document + /// + /// `maxContestFund` is the most, in credits, the owner pays into the + /// contest a contested document joins, and the owner must hold it; + /// `nil` states the current fund to join, read just before signing. A + /// document that joins no contest ignores it. public func documentCreate( contractId: String, documentType: String, ownerIdentity: DPPIdentity, properties: [String: Any], - signer: OpaquePointer + signer: OpaquePointer, + maxContestFund: UInt64? = nil ) async throws -> [String: Any] { let signerBox = SendableOpaque(signer) let startTime = Date() @@ -554,7 +560,10 @@ extension SDK { // 4. Create put settings (null for defaults) let putSettings: UnsafePointer? = nil let tokenPaymentInfo: UnsafePointer? = nil - let stateTransitionOptions: UnsafePointer? = nil + // Creation options only carry a stated `maxContestFund`; without + // one they stay null and rs-sdk states the current fund to join. + var stateTransitionOptions = DashSDKStateTransitionCreationOptions() + stateTransitionOptions.contest_fund = maxContestFund ?? 0 // Use the entropy from document creation (already generated) @@ -563,21 +572,23 @@ extension SDK { print("🚀 [DOCUMENT CREATE] This is the NETWORK CALL - using contract from trusted context...") let putStart = Date() var mutableEntropy = entropy // Create mutable copy for withUnsafePointer - let putResult = withUnsafePointer(to: &mutableEntropy) { entropyPtr in - contractId.withCString { contractIdCStr in - documentType.withCString { docTypeCStr in - dash_sdk_document_put_to_platform_and_wait( - handle, - documentHandle, - contractIdCStr, - docTypeCStr, - entropyPtr, - keyHandle, - signerBox.p, - tokenPaymentInfo, - putSettings, - stateTransitionOptions - ) + let putResult = withUnsafePointer(to: &stateTransitionOptions) { optionsPtr in + withUnsafePointer(to: &mutableEntropy) { entropyPtr in + contractId.withCString { contractIdCStr in + documentType.withCString { docTypeCStr in + dash_sdk_document_put_to_platform_and_wait( + handle, + documentHandle, + contractIdCStr, + docTypeCStr, + entropyPtr, + keyHandle, + signerBox.p, + tokenPaymentInfo, + putSettings, + maxContestFund == nil ? nil : optionsPtr + ) + } } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 917519ee3cd..1fd570e8b0f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -1710,12 +1710,18 @@ extension ManagedPlatformWallet { /// `public_keys` map; the signer's role is to sign with whatever /// key was picked. /// + /// `maxContestFund` is the most, in credits, the identity pays into + /// the contest a contested name joins, and the identity must hold it; + /// `nil` states the current fund to join, read just before signing. A + /// name that joins no contest ignores it. + /// /// Returns the full domain name (e.g. `"alice.dash"`). @discardableResult public func registerDpnsName( identityId: Identifier, name: String, - signer: KeychainSigner + signer: KeychainSigner, + maxContestFund: UInt64? = nil ) async throws -> String { let handle = self.handle // Take the raw signer handle outside the Task. `KeychainSigner` @@ -1731,6 +1737,8 @@ extension ManagedPlatformWallet { let idBytes: [UInt8] = identityId.withFFIBytes { ptr in Array(UnsafeBufferPointer(start: ptr, count: 32)) } + // `0` asks the Rust side for the current fund to join. + let contestFund = maxContestFund ?? 0 return try await Task.detached(priority: .userInitiated) { () -> String in _ = signer var outPtr: UnsafeMutablePointer? = nil @@ -1740,6 +1748,7 @@ extension ManagedPlatformWallet { handle, idBp.baseAddress!, namePtr, + contestFund, signerHandle, &outPtr ) @@ -3789,6 +3798,11 @@ extension ManagedPlatformWallet { /// converts them to native bytes / identifiers). Pass `"{}"` for a /// document type with no required properties. /// + /// `maxContestFund` is the most, in credits, the owner pays into the + /// contest a contested document joins, and the owner must hold it; + /// `nil` states the current fund to join, read just before signing. A + /// document that joins no contest ignores it. + /// /// Lifetime contract: the `signer` instance MUST stay alive for the /// duration of the synchronous FFI call inside this async wrapper /// (Rust holds a `passUnretained` ctx pointer to the underlying @@ -3800,10 +3814,13 @@ extension ManagedPlatformWallet { contractId: Identifier, documentType: String, propertiesJSON: String, - signer: KeychainSigner + signer: KeychainSigner, + maxContestFund: UInt64? = nil ) async throws -> (Identifier, String) { let handle = self.handle let signerHandle = signer.handle + // `0` asks the Rust side for the current fund to join. + let contestFund = maxContestFund ?? 0 let ownerBytes: [UInt8] = ownerIdentityId.withFFIBytes { ptr in Array(UnsafeBufferPointer(start: ptr, count: 32)) } @@ -3841,6 +3858,7 @@ extension ManagedPlatformWallet { contractBp.baseAddress!, typePtr, propsPtr, + contestFund, signerHandle, outBp.baseAddress!, &documentJsonPtr diff --git a/packages/wasm-sdk/src/dpns.rs b/packages/wasm-sdk/src/dpns.rs index 3c57617b20d..2e8470896a5 100644 --- a/packages/wasm-sdk/src/dpns.rs +++ b/packages/wasm-sdk/src/dpns.rs @@ -24,7 +24,9 @@ use wasm_dpp2::data_contract::document::DocumentWasm; use wasm_dpp2::identifier::{IdentifierLikeJs, IdentifierWasm}; use wasm_dpp2::identity::IdentityPublicKeyWasm; use wasm_dpp2::identity::IdentityWasm; -use wasm_dpp2::utils::{try_from_options_optional_with, try_from_options_with, try_to_string}; +use wasm_dpp2::utils::{ + try_from_options_optional_with, try_from_options_with, try_to_string, try_to_u64, +}; use wasm_dpp2::IdentitySignerWasm; #[wasm_bindgen(js_name = "RegisterDpnsNameResult")] @@ -136,6 +138,16 @@ export interface DpnsRegisterNameOptions { * Receives the preorder Document object. */ preorderCallback?: (preorderDocument: Document) => void; + + /** + * The most, in credits, the registration pays into the contest a contested + * name joins. From protocol version 14 it is charged the fund to join the + * contest, which doubles once the contest holds 250 contenders and again + * for every 50 more, and is refused, paid, when that is more than this. + * The identity must hold what it states. Leave it out to state the fund to + * join read just before the domain is submitted. + */ + contestFund?: bigint; } "#; @@ -348,6 +360,11 @@ impl WasmSdk { // Extract optional preorder callback let preorder_callback = extract_callback_from_options(&options, "preorderCallback")?; + // The most the registration pays into the contest a contested name joins + let contest_fund = try_from_options_optional_with(&options, "contestFund", |v| { + try_to_u64(v, "contestFund") + })?; + // Set up the callback if provided thread_local! { static PREORDER_CALLBACK: std::cell::RefCell> @@ -388,6 +405,7 @@ impl WasmSdk { identity_public_key, signer, preorder_callback: callback_box, + contest_fund, }; let result = self.as_ref().register_dpns_name(input).await?; diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 28abdff81ed..cbd6531f7f2 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -15,6 +15,7 @@ use dash_sdk::dpp::tokens::token_payment_info::TokenPaymentInfo; use dash_sdk::platform::documents::transitions::DocumentDeleteTransitionBuilder; use dash_sdk::platform::transition::purchase_document::PurchaseDocument; use dash_sdk::platform::transition::put_document::PutDocument; +use dash_sdk::platform::transition::put_settings::PutSettings; use dash_sdk::platform::transition::transfer_document::TransferDocument; use dash_sdk::platform::transition::update_price_of_document::UpdatePriceOfDocument; use js_sys::Reflect; @@ -27,8 +28,8 @@ use wasm_dpp2::state_transitions::batch::token_payment_info::{ TokenPaymentInfoOptionsJs, TokenPaymentInfoWasm, }; use wasm_dpp2::utils::{ - get_class_type, try_from_options_mut, try_from_options_optional, try_from_options_with, - try_to_string, try_to_u64, IntoWasm, + get_class_type, try_from_options_mut, try_from_options_optional, + try_from_options_optional_with, try_from_options_with, try_to_string, try_to_u64, IntoWasm, }; use wasm_dpp2::IdentitySignerWasm; @@ -124,6 +125,17 @@ export interface DocumentCreateOptions { */ tokenPaymentInfo?: DocumentTokenPaymentInfo; + /** + * The most, in credits, the document pays into the contest it joins when its + * document type has a contested index. From protocol version 14 it is charged + * the fund to join the contest, which doubles once the contest holds 250 + * contenders and again for every 50 more, and is refused, paid, when that is + * more than this. The identity must hold what it states. Leave it out to state + * the fund to join read just before the document is submitted. A document that + * joins no contest ignores it. + */ + contestFund?: bigint; + /** * Optional settings for the broadcast operation. * Includes retries, timeouts, userFeeIncrease, etc. @@ -202,10 +214,21 @@ impl WasmSdk { let document_type = get_document_type(&data_contract, &document_type_name)?; // Extract settings from options - let settings = + let mut settings: Option = try_from_options_optional::(&options, "settings")?.map(Into::into); let token_payment_info = try_from_options_optional_token_payment_info(&options)?; + // The most the document pays into the contest it joins + if let Some(contest_fund) = try_from_options_optional_with(&options, "contestFund", |v| { + try_to_u64(v, "contestFund") + })? { + settings + .get_or_insert_with(Default::default) + .state_transition_creation_options + .get_or_insert_with(Default::default) + .contest_fund = Some(contest_fund); + } + // Use PutDocument trait for creation, keeping the confirmed // document Platform returns — it carries the consensus-assigned // system fields the caller's pre-broadcast wrapper lacks. From 7a5751872e9559178c4ccd294f55ce886de3bc52 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 15:14:35 +0700 Subject: [PATCH 017/113] feat(platform)!: pay document ttl storage fees to the epochs the documents live in (PV14) (#5033) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/document-ttl.md | 43 +++- book/src/fees/overview.md | 6 + .../document/v3/document-meta.json | 2 +- packages/rs-dpp/src/fee/fee_result/mod.rs | 31 +++ .../v0/mod.rs | 1 + .../v0/mod.rs | 184 +++++++++++++-- .../mod.rs | 13 +- .../v0/mod.rs | 32 ++- .../v1/mod.rs | 217 ++++++++++++++++++ .../v1/mod.rs | 141 +++++++++++- .../v0/mod.rs | 73 +++--- .../validate_fees_of_event/v0/mod.rs | 1 + .../src/execution/types/block_fees/mod.rs | 7 + .../src/execution/types/block_fees/v0/mod.rs | 17 +- .../common/seated_moderation_charter/mod.rs | 1 + .../batch/state/v0/fetch_documents.rs | 5 + .../batch/tests/document/creation.rs | 3 +- .../batch/tests/document/document_ttl.rs | 114 ++++++++- .../src/platform_types/platform/mod.rs | 4 +- packages/rs-drive/grovedb-structure.json | 48 +++- packages/rs-drive/src/config.rs | 7 +- .../epochs/epochs_root_tree_key_constants.rs | 4 + .../rs-drive/src/drive/credit_pools/mod.rs | 43 ++++ .../src/drive/credit_pools/operations.rs | 33 +++ .../rs-drive/src/drive/credit_pools/paths.rs | 28 ++- .../fetch_pending_epoch_refunds/v0/mod.rs | 60 ++--- .../fetch_lifetime_storage_fee_pools/mod.rs | 106 +++++++++ .../v0/mod.rs | 68 ++++++ .../storage_fee_distribution_pool/mod.rs | 1 + .../src/drive/credit_pools/structure.rs | 43 +++- .../document/expiration/expiration_tests.rs | 19 +- .../mod.rs | 15 +- .../v0/mod.rs | 16 +- .../src/drive/document/expiration/mod.rs | 2 +- .../src/drive/document/expiration/pricing.rs | 99 ++++---- .../v1/mod.rs | 2 +- .../v1/mod.rs | 2 +- .../src/drive/initialization/v4/mod.rs | 12 +- packages/rs-drive/src/fees/op.rs | 52 ++--- packages/rs-drive/src/structure/tests.rs | 15 +- .../drive_abci_method_versions/v10.rs | 2 +- .../drive_credit_pool_method_versions/mod.rs | 3 + .../drive_credit_pool_method_versions/v1.rs | 1 + .../drive_document_method_versions/mod.rs | 2 +- .../drive_document_method_versions/v1.rs | 2 +- .../drive_document_method_versions/v2.rs | 2 +- .../drive_document_method_versions/v3.rs | 2 +- .../drive_document_method_versions/v4.rs | 2 +- .../src/version/fee/document_ttl/mod.rs | 12 +- .../src/version/fee/document_ttl/v1.rs | 1 - .../rs-platform-version/src/version/v14.rs | 3 +- 51 files changed, 1360 insertions(+), 242 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_block_fees_into_pools_operations/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/credit_pools/storage_fee_distribution_pool/fetch_lifetime_storage_fee_pools/mod.rs create mode 100644 packages/rs-drive/src/drive/credit_pools/storage_fee_distribution_pool/fetch_lifetime_storage_fee_pools/v0/mod.rs rename packages/rs-drive/src/drive/document/expiration/{insert_documents_expirations_tree => insert_document_ttl_trees}/mod.rs (67%) rename packages/rs-drive/src/drive/document/expiration/{insert_documents_expirations_tree => insert_document_ttl_trees}/v0/mod.rs (50%) diff --git a/book/src/data-model/document-ttl.md b/book/src/data-model/document-ttl.md index 41f139f7b3a..c7178797785 100644 --- a/book/src/data-model/document-ttl.md +++ b/book/src/data-model/document-ttl.md @@ -106,9 +106,18 @@ The entry is written with the document and removed with it, whoever deletes it ( in `force_delete_document_for_contract_operations`, which the owner's, the moderators' and the cleanup's deletions share). The last entry of an expiry time takes the tree of that time with it, so every tree of an expiry time holds at least one entry, and a document deleted early -leaves nothing the cleanup would have to read. The expirations tree itself is created with the -initial state structure of protocol version 14 and on the first block of protocol version 14, -through one helper (`Drive::insert_documents_expirations_tree`). +leaves nothing the cleanup would have to read. + +The storage fees of such documents wait in the **lifetime storage fee pools** under `Pools`, +a sum tree so the pools' total counts them, one sum item per epoch they were collected in and +number of epochs their storage lives: + +```text +Pools (48) / l / -> credits +``` + +Both trees are created with the initial state structure of protocol version 14 and on the +first block of protocol version 14, through one helper (`Drive::insert_document_ttl_trees`). ## Fees @@ -134,12 +143,19 @@ document of such a type: byte, 5% of it paid out in the first year) pro rata, rounded up. A one-year `ttl` pays 40 × 34 = 1,360 credits per byte, about what a permanent document deleted after a year keeps paying net of its refund. -- **Route.** A document of a type whose `ttl` is shorter than `processing_route_below_epochs` - epochs (two) of the network pays that amount into the current epoch's processing fees; one - of a longer `ttl` into the storage fee distribution pool, which spreads it over future - epochs like any storage fee. Here the epoch is the network's: the node's - `epoch_time_length_s`, handed to Drive through `DriveConfig`. The route follows the declared - `ttl`, not the lifetime left, so every write of a document takes the same one. +- **Payout.** That amount is a storage fee, paid out to the epochs the document lives in + rather than over the 50 eras of the perpetual storage distribution. Drive counts the + epochs its remaining lifetime spans, rounded up and at most one era (40 epochs), with the + network's epoch length (the node's `epoch_time_length_s`, handed to Drive through + `DriveConfig`) and reports the amount under that count + (`FeeResult::lifetime_storage_fees`). At the end of the block the amounts go to the + lifetime storage fee pools of the current epoch + (`add_distribute_block_fees_into_pools_operations` v1), and the next epoch change spreads + each pool of an earlier epoch evenly over its number of epochs from the new epoch on, the + remainder of the division to the new epoch, and removes it + (`add_distribute_storage_fee_to_epochs_operations` v1). A block never adds to a pool its + epoch change removes, so the first block of an epoch does both in one batch. A network with + hour-long epochs therefore pays a year-long document out within 40 hours. - **Deletion.** Creating the document prepays, as processing, what its deletion will cost: `cleanup_base_processing_cost` (1,200,000), plus `cleanup_processing_cost_per_index_level` (400,000) per index level of the type, where an index counts its properties, times the @@ -152,8 +168,10 @@ document of such a type: own processing like any deletion; the prepaid deletion is the platform's, and is not refunded. -The price never decreases with the lifetime and the route depends on the `ttl` alone, so an -estimate made at an earlier block time (check_tx) stays an upper bound of the execution. A dry +The price never decreases with the lifetime, so an estimate made at an earlier block time +(check_tx) stays an upper bound of the execution; where the amount is paid out does not change +what the writer pays, and a fee increase the writer offers, which multiplies processing only, +never multiplies it. A dry run estimates a change as an insert of the changed document (`estimate_document_change_as_insert_operations_v1`): without the entry and the deletion the creation prepaid, which a change never pays, and with the deletion of all the document's @@ -215,5 +233,8 @@ inert before 14: v0), which only an upgrade to 14 runs; - one batch per pricing rule in `apply_batch_low_level_drive_operations` v0 and the `DocumentTtl` arm of `consume_to_fees_v0`: nothing is tagged ephemeral before 14; +- `add_distribute_block_fees_into_pools_operations` v0, split into a helper that v1 shares, + with its operations unchanged, and `fetch_pending_epoch_refunds` v0, whose query and reading + moved unchanged into a helper the lifetime storage fee pools share; - the pattern-only edits in `batch_insert_empty_tree_if_not_exists` v0 and `convert_drive_operations_to_grove_operations` v0, whose output is unchanged. diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index a771a14e7e3..94af5b6e964 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -52,6 +52,12 @@ Storage fees are **refundable**: when data is deleted, a portion of the original storage fee is returned to the identity that paid it (see [Refunds](#refunds) below). +The documents of a type that declares a `ttl` (protocol version 14) are the exception: they +carry no storage flags and refund nothing, their bytes are priced for the time they live, and +their storage fees are paid out to the epochs they live in, through the lifetime storage fee +pools, instead of over the perpetual distribution. See +[Document Time To Live](../data-model/document-ttl.md). + ### Processing Fees Processing fees pay for computation that does not leave a permanent trace in diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 3267950afef..4c32046fdeb 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1839,7 +1839,7 @@ "type": "integer", "minimum": 1, "maximum": 4294967295, - "description": "Time to live, in seconds: the platform deletes each document of this type once its `$createdAt` plus this many seconds has passed, whoever owns it and whatever `canBeDeleted` says, at most a protocol-versioned number of documents per block (128 at protocol version 14) after the block's state transitions. Its documents are stored without storage flags and refund nothing when deleted: instead of the perpetual storage price, each byte they write pays a price for the time they will live (five tiers up to seven days, then per 9.125 days spanned), paid into the processing fee pool when the `ttl` is shorter than two epochs and into the storage fee pool otherwise, and a document prepays its deletion as processing when it is created. From its expiry on, a document can no longer be replaced, transferred, bought, repriced or restored by a moderator; its owner may still delete it where `canBeDeleted` allows. Requires `$createdAt` in `required`; refused together with `documentsKeepHistory`, `indexOnly` and a contested index. A `permanentDocument` reference, a lookup one included, and a list element reference may not target the type; a `deletableDocument` reference, a lookup one included, may. At least and at most protocol-versioned bounds (3600, one hour, and 31536000, one year, at protocol version 14). Fixed when the document type is created: an update may not add, remove or change it. Available from protocol version 14." + "description": "Time to live, in seconds: the platform deletes each document of this type once its `$createdAt` plus this many seconds has passed, whoever owns it and whatever `canBeDeleted` says, at most a protocol-versioned number of documents per block (128 at protocol version 14) after the block's state transitions. Its documents are stored without storage flags and refund nothing when deleted: instead of the perpetual storage price, each byte they write pays a price for the time they will live (five tiers up to seven days, then per 9.125 days spanned), paid out as storage fees to the epochs they live in (at most one era of them), and a document prepays its deletion as processing when it is created. From its expiry on, a document can no longer be replaced, transferred, bought, repriced or restored by a moderator; its owner may still delete it where `canBeDeleted` allows. Requires `$createdAt` in `required`; refused together with `documentsKeepHistory`, `indexOnly` and a contested index. A `permanentDocument` reference, a lookup one included, and a list element reference may not target the type; a `deletableDocument` reference, a lookup one included, may. At least and at most protocol-versioned bounds (3600, one hour, and 31536000, one year, at protocol version 14). Fixed when the document type is created: an update may not add, remove or change it. Available from protocol version 14." }, "indexOnly": { "type": "boolean", diff --git a/packages/rs-dpp/src/fee/fee_result/mod.rs b/packages/rs-dpp/src/fee/fee_result/mod.rs index 010cc04bbdb..28b4a495f53 100644 --- a/packages/rs-dpp/src/fee/fee_result/mod.rs +++ b/packages/rs-dpp/src/fee/fee_result/mod.rs @@ -50,6 +50,10 @@ use std::convert::TryFrom; pub mod refunds; +/// The part of a storage fee paid for storage that lives a known number of epochs, keyed by +/// that number of epochs. +pub type LifetimeStorageFees = BTreeMap; + /// Fee Result #[derive(Debug, Clone, Eq, PartialEq, Default)] pub struct FeeResult { @@ -61,6 +65,11 @@ pub struct FeeResult { pub fee_refunds: FeeRefunds, /// Removed bytes not needing to be refunded to identities pub removed_bytes_from_system: u32, + /// The part of `storage_fee` paid for storage that lives a known number of epochs, keyed + /// by that number: the writes of a document whose type declares a `ttl` (protocol version + /// 14). The pools pay it out over those epochs, where the rest of `storage_fee` goes to + /// the perpetual storage distribution. Empty before protocol version 14. + pub lifetime_storage_fees: LifetimeStorageFees, } impl TryFrom> for FeeResult { @@ -191,6 +200,7 @@ impl FeeResult { processing_fee: credits, fee_refunds: Default::default(), removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), } } @@ -277,6 +287,18 @@ impl FeeResult { .ok_or(ProtocolError::Overflow( "removed_bytes_from_system overflow error", ))?; + for (lifetime_epochs, credits) in rhs.lifetime_storage_fees { + let lifetime_credits = self + .lifetime_storage_fees + .entry(lifetime_epochs) + .or_default(); + *lifetime_credits = + lifetime_credits + .checked_add(credits) + .ok_or(ProtocolError::Overflow( + "lifetime storage fee overflow error", + ))?; + } Ok(()) } } @@ -351,6 +373,7 @@ mod tests { processing_fee: 50, fee_refunds: refunds, removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), }; let bci = fee_result.into_balance_change(id); let other = bci.other_refunds(); @@ -367,6 +390,7 @@ mod tests { processing_fee: 58, fee_refunds: FeeRefunds::default(), removed_bytes_from_system: 10, + lifetime_storage_fees: Default::default(), }; let id = make_id(1); let bci = fee_result.clone().into_balance_change(id); @@ -388,6 +412,7 @@ mod tests { processing_fee: 50, fee_refunds: refunds, removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), }; let bci = fee_result.into_balance_change(id); match bci.change() { @@ -401,6 +426,7 @@ mod tests { processing_fee: 50, fee_refunds: refunds2, removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), }; let bci2 = fee_result2.into_balance_change(id); let result: Result = bci2.fee_result_outcome(0); @@ -458,6 +484,7 @@ mod tests { processing_fee: 50, fee_refunds: refunds, removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), }; let bci = fee_result.into_balance_change(id); match bci.change() { @@ -471,6 +498,7 @@ mod tests { processing_fee: 50, fee_refunds: refunds2, removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), }; let bci2 = fee_result2.into_balance_change(id); let result: Result = bci2.fee_result_outcome(0); @@ -489,6 +517,7 @@ mod tests { processing_fee: 50, fee_refunds: refunds, removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), }; let bci = fee_result.into_balance_change(id); match bci.change() { @@ -514,6 +543,7 @@ mod tests { processing_fee: 50, fee_refunds: refunds, removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), }; let bci = fee_result.into_balance_change(id); assert_eq!(bci.change(), &BalanceChange::NoBalanceChange); @@ -528,6 +558,7 @@ mod tests { processing_fee: 50, fee_refunds: refunds, removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), }; let bci = fee_result.into_balance_change(id); match bci.change() { diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/add_process_epoch_change_operations/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/add_process_epoch_change_operations/v0/mod.rs index 2d161db6c78..9f8304290de 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/add_process_epoch_change_operations/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/add_process_epoch_change_operations/v0/mod.rs @@ -260,6 +260,7 @@ mod tests { storage_fee: 1000000000, processing_fee: 10000, refunds_per_epoch: CreditsPerEpoch::from_iter([(0, 10000)]), + ..Default::default() } .into(); diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/process_block_fees_and_validate_sum_trees/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/process_block_fees_and_validate_sum_trees/v0/mod.rs index d48d6ff2c65..02729d3400b 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/process_block_fees_and_validate_sum_trees/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/process_block_fees_and_validate_sum_trees/v0/mod.rs @@ -205,8 +205,14 @@ mod tests { use rust_decimal::prelude::ToPrimitive; use crate::config::ExecutionConfig; + use crate::execution::types::block_fees::v0::BlockFeesV0; use crate::{config::PlatformConfig, test::helpers::setup::TestPlatformBuilder}; + use dpp::fee::fee_result::LifetimeStorageFees; + use drive::drive::credit_pools::operations::update_lifetime_storage_fee_pool_operation; + use drive::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; + use drive::util::batch::GroveDbOpBatch; use drive::util::test_helpers::test_utils::identities::create_test_masternode_identities; + use std::collections::BTreeMap; mod helpers { use super::*; @@ -219,19 +225,16 @@ mod tests { use dpp::fee::epoch::{perpetual_storage_epochs, CreditsPerEpoch, GENESIS_EPOCH_INDEX}; use platform_version::version::INITIAL_PROTOCOL_VERSION; - /// Process and validate block fees - pub fn process_and_validate_block_fees( + /// The block at `block_height` in epoch `epoch_index`: its state info, its epoch info and + /// its execution context + pub fn block_execution_context( platform: &Platform, genesis_time_ms: u64, epoch_index: u16, block_height: u64, previous_block_time_ms: Option, proposer_pro_tx_hash: [u8; 32], - transaction: &Transaction, - ) -> BlockStateInfoV0 { - let current_epoch = Epoch::new(epoch_index).unwrap(); - let platform_version = PlatformVersion::latest(); - + ) -> (BlockStateInfoV0, EpochInfo, BlockExecutionContext) { let block_time_ms = genesis_time_ms + epoch_index as u64 * platform.config.execution.epoch_time_length_s * 1000 + block_height; @@ -257,13 +260,6 @@ mod tests { .expect("should calculate epoch info") .into(); - let block_fees: BlockFees = BlockFeesV0 { - storage_fee: 100000, - processing_fee: 10000, - refunds_per_epoch: CreditsPerEpoch::from_iter([(epoch_index, 100)]), - } - .into(); - let block_platform_state = PlatformState::default_with_protocol_versions( INITIAL_PROTOCOL_VERSION, INITIAL_PROTOCOL_VERSION, @@ -280,9 +276,42 @@ mod tests { block_address_balance_changes: Default::default(), }; + (block_info, epoch_info, block_execution_context.into()) + } + + /// Process and validate block fees + pub fn process_and_validate_block_fees( + platform: &Platform, + genesis_time_ms: u64, + epoch_index: u16, + block_height: u64, + previous_block_time_ms: Option, + proposer_pro_tx_hash: [u8; 32], + transaction: &Transaction, + ) -> BlockStateInfoV0 { + let current_epoch = Epoch::new(epoch_index).unwrap(); + let platform_version = PlatformVersion::latest(); + + let (block_info, epoch_info, block_execution_context) = block_execution_context( + platform, + genesis_time_ms, + epoch_index, + block_height, + previous_block_time_ms, + proposer_pro_tx_hash, + ); + + let block_fees: BlockFees = BlockFeesV0 { + storage_fee: 100000, + processing_fee: 10000, + refunds_per_epoch: CreditsPerEpoch::from_iter([(epoch_index, 100)]), + ..Default::default() + } + .into(); + let storage_fee_distribution_outcome = platform .process_block_fees_and_validate_sum_trees_v0( - &block_execution_context.into(), + &block_execution_context, block_fees.clone(), transaction, platform_version, @@ -513,4 +542,129 @@ mod tests { &transaction, ); } + + #[test] + fn should_spread_the_last_epochs_lifetime_pools_and_fill_the_new_epochs_in_one_batch() { + // The first block of an epoch spreads and removes the lifetime storage fee pools of the + // epoch before and adds its own ttl storage fees to pools of the new epoch, a lifetime + // they share included, in the one batch the credits are checked after. The fixture's + // first block adds fees from nobody, so the test checks the credits of the epoch + // change itself instead of the platform checking every block. + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_config(PlatformConfig { + execution: ExecutionConfig { + verify_sum_trees: false, + ..Default::default() + }, + ..Default::default() + }) + .build_with_mock_rpc() + .set_genesis_state_with_activation_info(0, 1); + let transaction = platform.drive.grove.start_transaction(); + platform.create_mn_shares_contract(Some(&transaction), platform_version); + let proposers = create_test_masternode_identities( + &platform.drive, + 2, + Some(56), + Some(&transaction), + platform_version, + ); + let genesis_time_ms = Utc::now() + .timestamp_millis() + .to_u64() + .expect("block time can not be before 1970"); + + // Epoch 0: its first block, and ttl storage fees it collected. + let block_info = helpers::process_and_validate_block_fees( + &platform, + genesis_time_ms, + GENESIS_EPOCH_INDEX, + 1, + None, + proposers[0], + &transaction, + ); + let mut batch = GroveDbOpBatch::new(); + for (lifetime_epochs, credits) in [(2, 1_001), (40, 400)] { + batch.push( + update_lifetime_storage_fee_pool_operation( + GENESIS_EPOCH_INDEX, + lifetime_epochs, + credits, + ) + .expect("expected the pool operation"), + ); + } + platform + .drive + .grove_apply_batch(batch, false, Some(&transaction), &platform_version.drive) + .expect("expected to fill the pools"); + + // The first block of epoch 1 and its own ttl storage fees. The fixture put credits in + // place outside a block and the block's fees come from nobody: count them all, so the + // credits are balanced before the block. + let block_fees: BlockFees = BlockFeesV0 { + storage_fee: 1_000, + processing_fee: 10_000, + lifetime_storage_fees: LifetimeStorageFees::from([(1, 300), (40, 500)]), + ..Default::default() + } + .into(); + let credits = platform + .drive + .calculate_total_credits_balance(Some(&transaction), &platform_version.drive) + .expect("expected to total the credits"); + platform + .drive + .add_to_system_credits( + credits + .total_in_trees() + .expect("expected the credits in trees") + - credits.total_credits_in_platform + + block_fees.storage_fee() + + block_fees.processing_fee(), + Some(&transaction), + platform_version, + ) + .expect("expected to count the fixture's credits"); + let (_, epoch_info, block_execution_context) = helpers::block_execution_context( + &platform, + genesis_time_ms, + GENESIS_EPOCH_INDEX + 1, + 2, + Some(block_info.block_time_ms), + proposers[1], + ); + assert!(epoch_info.is_epoch_change()); + + platform + .process_block_fees_and_validate_sum_trees_v0( + &block_execution_context, + block_fees, + &transaction, + platform_version, + ) + .expect("should process the block fees"); + + assert_eq!( + platform + .drive + .fetch_lifetime_storage_fee_pools(Some(&transaction), platform_version) + .expect("expected to read the lifetime pools"), + BTreeMap::from([( + GENESIS_EPOCH_INDEX + 1, + LifetimeStorageFees::from([(1, 300), (40, 500)]) + )]), + "epoch 0's pools are spread and removed, the block's wait in pools of epoch 1" + ); + let credits = platform + .drive + .calculate_total_credits_balance(Some(&transaction), &platform_version.drive) + .expect("expected to total the credits"); + assert!( + credits.ok().expect("expected to compare the credits"), + "{credits:?}" + ); + } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_block_fees_into_pools_operations/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_block_fees_into_pools_operations/mod.rs index 2145adf36a5..bac9d395cc8 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_block_fees_into_pools_operations/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_block_fees_into_pools_operations/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::error::execution::ExecutionError; use crate::error::Error; @@ -65,9 +66,19 @@ impl Platform { batch, platform_version, ), + // v1 (protocol version 14): lifetime storage fees go to the lifetime storage fee + // pools of the current epoch. + 1 => self.add_distribute_block_fees_into_pools_operations_v1( + current_epoch, + block_fees, + cached_aggregated_storage_fees, + transaction, + batch, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "add_distribute_block_fees_into_pools_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_block_fees_into_pools_operations/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_block_fees_into_pools_operations/v0/mod.rs index cf255492d29..650507391c8 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_block_fees_into_pools_operations/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_block_fees_into_pools_operations/v0/mod.rs @@ -26,6 +26,31 @@ impl Platform { transaction: TransactionArg, batch: &mut Vec, platform_version: &PlatformVersion, + ) -> Result { + self.add_distribute_block_fees_into_pools_operations_v0_with_storage( + current_epoch, + block_fees.processing_fee(), + block_fees.storage_fee(), + cached_aggregated_storage_fees, + transaction, + batch, + platform_version, + ) + } + + /// The body of v0 with the block's processing fees and the storage fees it adds to the + /// storage fee distribution pool given apart, which v1 passes without the lifetime storage + /// fees. Split out, operations unchanged, in place in this shipped generation. + #[allow(clippy::too_many_arguments)] + pub(super) fn add_distribute_block_fees_into_pools_operations_v0_with_storage( + &self, + current_epoch: &Epoch, + block_processing_fee: Credits, + block_storage_fee: Credits, + cached_aggregated_storage_fees: Option, + transaction: TransactionArg, + batch: &mut Vec, + platform_version: &PlatformVersion, ) -> Result { // update epochs pool processing fees let epoch_processing_fees = self @@ -45,7 +70,7 @@ impl Platform { _ => Err(e), })?; - let total_processing_fees = epoch_processing_fees + block_fees.processing_fee(); + let total_processing_fees = epoch_processing_fees + block_processing_fee; batch.push(DriveOperation::GroveDBOperation( current_epoch.update_processing_fee_pool_operation(total_processing_fees)?, @@ -59,12 +84,11 @@ impl Platform { Some(storage_fees) => storage_fees, }; - let total_storage_fees = - storage_distribution_credits_in_fee_pool + block_fees.storage_fee(); + let total_storage_fees = storage_distribution_credits_in_fee_pool + block_storage_fee; batch.push(DriveOperation::GroveDBOperation( update_storage_fee_distribution_pool_operation( - storage_distribution_credits_in_fee_pool + block_fees.storage_fee(), + storage_distribution_credits_in_fee_pool + block_storage_fee, )?, )); diff --git a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_block_fees_into_pools_operations/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_block_fees_into_pools_operations/v1/mod.rs new file mode 100644 index 00000000000..4df04e638fe --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_block_fees_into_pools_operations/v1/mod.rs @@ -0,0 +1,217 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::block_fees::v0::BlockFeesV0Getters; +use crate::execution::types::block_fees::BlockFees; +use crate::execution::types::fees_in_pools::v0::FeesInPoolsV0; +use crate::platform_types::platform::Platform; +use dpp::block::epoch::Epoch; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use drive::drive::credit_pools::operations::update_lifetime_storage_fee_pool_operation; +use drive::grovedb::TransactionArg; +use drive::util::batch::DriveOperation; + +impl Platform { + /// v0, except that the part of the block's storage fees for storage that lives a known + /// number of epochs (documents with a time to live) goes to the lifetime storage fee pools + /// of the current epoch, by that number, instead of the storage fee distribution pool; the + /// next epoch change spreads each pool evenly over its epochs and removes it. + /// + /// A block adds only to the pools of its own epoch, and an epoch change spreads and removes + /// only the pools of earlier epochs, so the two never write the same pool in one batch. + pub(super) fn add_distribute_block_fees_into_pools_operations_v1( + &self, + current_epoch: &Epoch, + block_fees: &BlockFees, + cached_aggregated_storage_fees: Option, + transaction: TransactionArg, + batch: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + let block_lifetime_storage_fees = block_fees.lifetime_storage_fees(); + let lifetime_storage_fees_total = block_lifetime_storage_fees + .values() + .try_fold(0u64, |total, credits| total.checked_add(*credits)) + .ok_or(ExecutionError::Overflow( + "overflow adding the lifetime storage fees of a block", + ))?; + let perpetual_storage_fee = block_fees + .storage_fee() + .checked_sub(lifetime_storage_fees_total) + .ok_or(ExecutionError::CorruptedCodeExecution( + "the lifetime storage fees of a block exceed its storage fees", + ))?; + + // The processing fees and the storage fee distribution pool, as v0 does, with the + // block's perpetual storage fees only. + let fees_in_pools = self.add_distribute_block_fees_into_pools_operations_v0_with_storage( + current_epoch, + block_fees.processing_fee(), + perpetual_storage_fee, + cached_aggregated_storage_fees, + transaction, + batch, + platform_version, + )?; + + if block_lifetime_storage_fees.is_empty() { + return Ok(fees_in_pools); + } + // What the earlier blocks of this epoch collected. The first block of an epoch finds + // none: the pools it reads are those of earlier epochs, which its epoch change spreads. + let current_epoch_pools = self + .drive + .fetch_lifetime_storage_fee_pools(transaction, platform_version)? + .remove(¤t_epoch.index) + .unwrap_or_default(); + for (lifetime_epochs, credits) in block_lifetime_storage_fees { + let pool_credits = current_epoch_pools + .get(lifetime_epochs) + .copied() + .unwrap_or_default() + .checked_add(*credits) + .ok_or(ExecutionError::Overflow( + "overflow adding to a lifetime storage fee pool", + ))?; + batch.push(DriveOperation::GroveDBOperation( + update_lifetime_storage_fee_pool_operation( + current_epoch.index, + *lifetime_epochs, + pool_credits, + )?, + )); + } + + Ok(fees_in_pools) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::execution::types::block_fees::v0::BlockFeesV0; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; + use dpp::block::block_info::BlockInfo; + use dpp::block::epoch::EpochIndex; + use dpp::fee::fee_result::LifetimeStorageFees; + use drive::grovedb::Transaction; + use std::collections::BTreeMap; + + fn block_fees(storage_fee: Credits, lifetime: &[(u16, Credits)]) -> BlockFees { + BlockFeesV0 { + storage_fee, + processing_fee: 1_000, + lifetime_storage_fees: LifetimeStorageFees::from_iter(lifetime.iter().copied()), + ..Default::default() + } + .into() + } + + fn distribute( + platform: &TempPlatform, + epoch_index: EpochIndex, + block_fees: &BlockFees, + transaction: &Transaction, + ) { + let platform_version = PlatformVersion::latest(); + let mut batch = vec![]; + platform + .add_distribute_block_fees_into_pools_operations_v1( + &Epoch::new(epoch_index).expect("epoch"), + block_fees, + None, + Some(transaction), + &mut batch, + platform_version, + ) + .expect("should distribute the block fees"); + platform + .drive + .apply_drive_operations( + batch, + true, + &BlockInfo::default(), + Some(transaction), + platform_version, + None, + ) + .expect("should apply the batch"); + } + + fn lifetime_pools( + platform: &TempPlatform, + transaction: &Transaction, + ) -> BTreeMap { + platform + .drive + .fetch_lifetime_storage_fee_pools(Some(transaction), PlatformVersion::latest()) + .expect("should read the lifetime pools") + } + + #[test] + fn should_add_lifetime_storage_fees_to_their_pools_and_the_rest_to_the_storage_fee_pool() { + let platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + let transaction = platform.drive.grove.start_transaction(); + + distribute( + &platform, + 1, + &block_fees(1_000_000, &[(1, 300_000), (40, 400_000)]), + &transaction, + ); + assert_eq!( + platform + .drive + .get_storage_fees_from_distribution_pool( + Some(&transaction), + PlatformVersion::latest() + ) + .expect("should read the storage fee pool"), + 300_000 + ); + assert_eq!( + lifetime_pools(&platform, &transaction), + BTreeMap::from([(1, LifetimeStorageFees::from([(1, 300_000), (40, 400_000)]))]) + ); + + // The next block of the epoch adds to its pools. + distribute(&platform, 1, &block_fees(100, &[(1, 100)]), &transaction); + assert_eq!( + lifetime_pools(&platform, &transaction), + BTreeMap::from([(1, LifetimeStorageFees::from([(1, 300_100), (40, 400_000)]))]) + ); + } + + #[test] + fn should_add_only_to_the_pools_of_the_blocks_epoch() { + let platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + let transaction = platform.drive.grove.start_transaction(); + distribute( + &platform, + 1, + &block_fees(107, &[(2, 100), (5, 7)]), + &transaction, + ); + + // A block of epoch 2 leaves the pools of epoch 1 to the epoch change, which spreads and + // removes them, and opens its own. + distribute( + &platform, + 2, + &block_fees(41, &[(2, 40), (9, 1)]), + &transaction, + ); + assert_eq!( + lifetime_pools(&platform, &transaction), + BTreeMap::from([ + (1, LifetimeStorageFees::from([(2, 100), (5, 7)])), + (2, LifetimeStorageFees::from([(2, 40), (9, 1)])), + ]) + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/v1/mod.rs index e64ba168856..26258c71256 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/fee_pool_inwards_distribution/add_distribute_storage_fee_to_epochs_operations/v1/mod.rs @@ -2,16 +2,54 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::storage_fee_distribution_outcome; use crate::platform_types::platform::Platform; +use dpp::balances::credits::Creditable; use dpp::block::epoch::EpochIndex; use dpp::fee::epoch::distribution::{ distribute_storage_fee_to_epochs_collection, subtract_refunds_priced_in_epoch_from_epoch_credits_collection, }; use dpp::fee::epoch::SignedCreditsPerEpoch; +use dpp::fee::Credits; use dpp::version::PlatformVersion; +use drive::drive::credit_pools::operations::delete_lifetime_storage_fee_pool_operation; use drive::grovedb::TransactionArg; +use drive::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; use drive::util::batch::GroveDbOpBatch; +/// Adds `credits` to the `lifetime_epochs` epochs from `current_epoch_index` in equal parts, +/// the remainder of the division to the current epoch. +fn spread_credits_over_epochs( + credits_per_epochs: &mut SignedCreditsPerEpoch, + credits: Credits, + current_epoch_index: EpochIndex, + lifetime_epochs: u16, +) -> Result<(), Error> { + let lifetime_epochs = lifetime_epochs.max(1); + let share = credits / u64::from(lifetime_epochs); + let remainder = credits % u64::from(lifetime_epochs); + for offset in 0..lifetime_epochs { + let epoch_index = + current_epoch_index + .checked_add(offset) + .ok_or(ExecutionError::Overflow( + "overflow indexing the epochs a lifetime storage fee covers", + ))?; + let epoch_credits = if offset == 0 { + share + remainder + } else { + share + }; + let epoch_credits = epoch_credits.to_signed()?; + let entry = credits_per_epochs.entry(epoch_index).or_default(); + *entry = entry + .checked_add(epoch_credits) + .ok_or(ExecutionError::Overflow( + "overflow adding a lifetime storage fee to an epoch", + ))?; + } + Ok(()) +} + impl Platform { /// Adds operations to the GroveDB op batch which distribute storage fees /// from the distribution pool and subtract pending refunds @@ -45,6 +83,41 @@ impl Platform { self.config.drive.epochs_per_era, )?; + // Spread each lifetime storage fee pool evenly over its epochs, from the current one; + // what the division leaves goes to the current epoch. Every pool was collected in an + // earlier epoch, and the change removes it here: the blocks of the current epoch add + // to pools of their own (`add_distribute_block_fees_into_pools_operations` v1), never + // to one this batch removes. + let lifetime_storage_fee_pools = self + .drive + .fetch_lifetime_storage_fee_pools(transaction, platform_version)?; + let mut total_distributed_storage_fees = storage_distribution_fees; + for (collected_epoch_index, pools) in lifetime_storage_fee_pools { + if collected_epoch_index >= current_epoch_index { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "a lifetime storage fee pool spread at an epoch change must be collected \ + in an earlier epoch", + ))); + } + for (lifetime_epochs, credits) in pools { + spread_credits_over_epochs( + &mut credits_per_epochs, + credits, + current_epoch_index, + lifetime_epochs, + )?; + total_distributed_storage_fees = total_distributed_storage_fees + .checked_add(credits) + .ok_or(ExecutionError::Overflow( + "overflow adding the lifetime storage fees distributed at an epoch change", + ))?; + batch.push(delete_lifetime_storage_fee_pool_operation( + collected_epoch_index, + lifetime_epochs, + )); + } + } + // Deduct pending refunds from the epochs they were refunded for. Shares of epochs that // closed before the current one come out of the current epoch // Leftovers are ignored since they already deducted from Identity's refund amount @@ -83,7 +156,7 @@ impl Platform { Ok( storage_fee_distribution_outcome::v0::StorageFeeDistributionOutcome { - total_distributed_storage_fees: storage_distribution_fees, + total_distributed_storage_fees, leftovers, refunded_epochs_count, }, @@ -104,10 +177,11 @@ mod tests { use dpp::fee::Credits; use drive::config::DriveConfig; use drive::drive::credit_pools::epochs::operations_factory::EpochOperations; - use drive::drive::credit_pools::operations::update_storage_fee_distribution_pool_operation; + use drive::drive::credit_pools::operations::{ + update_lifetime_storage_fee_pool_operation, update_storage_fee_distribution_pool_operation, + }; use drive::drive::Drive; use drive::grovedb::Transaction; - use drive::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; use drive::util::batch::DriveOperation; use std::ops::Range; @@ -207,6 +281,67 @@ mod tests { .expect("should get storage fees") } + #[test] + fn should_spread_each_lifetime_storage_fee_pool_evenly_over_its_epochs() { + let platform = setup_platform(); + let transaction = platform.drive.grove.start_transaction(); + let platform_version = PlatformVersion::latest(); + + let current_epoch_index = 5; + // Collected over the epoch before the change; one pool of an older epoch too. + let mut batch = GroveDbOpBatch::new(); + for (collected_epoch_index, lifetime_epochs, credits) in + [(4, 2, 1_000), (4, 3, 30), (3, 2, 1)] + { + batch.push( + update_lifetime_storage_fee_pool_operation( + collected_epoch_index, + lifetime_epochs, + credits, + ) + .expect("should return operation"), + ); + } + platform + .drive + .grove_apply_batch(batch, false, Some(&transaction), &platform_version.drive) + .expect("should apply batch"); + + let pools_range = 0..current_epoch_index + 5; + let pools_before = epoch_storage_pools(&platform, pools_range.clone(), &transaction); + + let mut batch = GroveDbOpBatch::new(); + let outcome = platform + .add_distribute_storage_fee_to_epochs_operations( + current_epoch_index, + Some(current_epoch_index - 1), + Some(&transaction), + &mut batch, + platform_version, + ) + .expect("should distribute the pools"); + platform + .drive + .grove_apply_batch(batch, false, Some(&transaction), &platform_version.drive) + .expect("should apply batch"); + + assert_eq!(outcome.total_distributed_storage_fees, 1_031); + let pools_after = epoch_storage_pools(&platform, pools_range.clone(), &transaction); + let added: Vec = pools_after + .iter() + .zip(&pools_before) + .map(|(after, before)| after - before) + .collect(); + // Epoch 5 takes its shares and the remainder, then each epoch of a pool its share. + assert_eq!(added, vec![0, 0, 0, 0, 0, 500 + 1 + 10, 500 + 10, 10, 0, 0]); + // The change removes every pool it spread. + assert!(platform + .drive + .fetch_lifetime_storage_fee_pools(Some(&transaction), platform_version) + .expect("should read the lifetime pools") + .is_empty()); + } + #[test] fn should_claw_back_each_pending_refund_from_the_epochs_it_was_priced_for() { let platform = setup_platform(); diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 194e8e45680..a862f9dbdd0 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -835,11 +835,13 @@ impl Platform { self.drive .insert_contract_fee_pot_trees(Some(transaction), platform_version)?; - // The documents expirations tree under `Misc`, which indexes every document of a type - // declaring a `ttl` (a keyword protocol version 14 introduces) by when it expires. - // Fresh chains call the same helper last in `create_initial_state_structure` v4. + // The document time to live trees: the documents expirations tree under `Misc`, which + // indexes every document of a type declaring a `ttl` (a keyword protocol version 14 + // introduces) by when it expires, and the lifetime storage fee pools sum tree under + // `Pools`, which holds their storage fees until an epoch change spreads them. Fresh + // chains call the same helper last in `create_initial_state_structure` v4. self.drive - .insert_documents_expirations_tree(Some(transaction), platform_version)?; + .insert_document_ttl_trees(Some(transaction), platform_version)?; Ok(()) } @@ -853,6 +855,8 @@ mod tests { use dpp::block::block_info::BlockInfo; use dpp::block::epoch::Epoch; use dpp::version::PlatformVersion; + use drive::drive::credit_pools::epochs::epochs_root_tree_key_constants::KEY_LIFETIME_STORAGE_FEE_POOLS; + use drive::drive::credit_pools::pools_path; use drive::drive::document::expiration::paths::DOCUMENTS_EXPIRATIONS_KEY; use drive::drive::shielded::paths::{ shielded_credit_pool_path, MAIN_SHIELDED_CREDIT_POOL_KEY_U8, SHIELDED_ANCHORS_IN_POOL_KEY, @@ -2139,7 +2143,7 @@ mod tests { } #[test] - fn should_create_the_documents_expirations_tree_on_transition_to_version_14() { + fn should_create_the_document_ttl_trees_on_transition_to_version_14() { let platform_version = PlatformVersion::latest(); let born_at_14 = TestPlatformBuilder::new() .with_initial_protocol_version(14) @@ -2151,42 +2155,59 @@ mod tests { .set_genesis_state(); let transaction = upgraded.drive.grove.start_transaction(); - let tree_exists = |transaction: &Transaction| { - upgraded - .drive - .grove_has_raw( - (&misc_path()).into(), - DOCUMENTS_EXPIRATIONS_KEY, - DirectQueryType::StatefulDirectQuery, - Some(transaction), - &mut vec![], - &platform_version.drive, - ) - .expect("expected to query the expirations tree") + let trees_exist = |transaction: &Transaction| { + let has = |path: &[&[u8]], key: &[u8]| { + upgraded + .drive + .grove_has_raw( + path.into(), + key, + DirectQueryType::StatefulDirectQuery, + Some(transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("expected to query the tree") + }; + ( + has(&misc_path(), DOCUMENTS_EXPIRATIONS_KEY), + has(&pools_path(), KEY_LIFETIME_STORAGE_FEE_POOLS), + ) }; - assert!( - !tree_exists(&transaction), - "protocol version 13 has no documents expirations tree" + assert_eq!( + trees_exist(&transaction), + (false, false), + "protocol version 13 has neither the documents expirations tree nor the lifetime \ + storage fee pools" ); upgraded .transition_to_version_14(&BlockInfo::default(), &transaction, platform_version) .expect("expected version 14 transition to succeed"); - assert!( - tree_exists(&transaction), - "the documents expirations tree must exist after the transition" + assert_eq!( + trees_exist(&transaction), + (true, true), + "both trees must exist after the transition" ); - let diffs = collect_subtree_diffs( + let mut diffs = collect_subtree_diffs( &born_at_14, &upgraded, &transaction, vec![vec![RootTree::Misc as u8]], ); + diffs.extend(collect_subtree_diffs( + &born_at_14, + &upgraded, + &transaction, + vec![ + vec![RootTree::Pools as u8], + KEY_LIFETIME_STORAGE_FEE_POOLS.to_vec(), + ], + )); assert!( diffs.is_empty(), - "the Misc subtree differs between a chain born at version 14 and one upgraded to \ - it:\n{}", + "the trees differ between a chain born at version 14 and one upgraded to it:\n{}", diffs.join("\n"), ); } diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs index a9d0ee9808c..42a477ffc27 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs @@ -264,6 +264,7 @@ where processing_fee: *fees_to_add_to_pool - storage_fee, fee_refunds: Default::default(), removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), }; if *fees_to_add_to_pool >= required_fee { Ok(ConsensusValidationResult::new_with_data( diff --git a/packages/rs-drive-abci/src/execution/types/block_fees/mod.rs b/packages/rs-drive-abci/src/execution/types/block_fees/mod.rs index 82625a5f16a..2b76e04a671 100644 --- a/packages/rs-drive-abci/src/execution/types/block_fees/mod.rs +++ b/packages/rs-drive-abci/src/execution/types/block_fees/mod.rs @@ -6,6 +6,7 @@ use crate::execution::types::block_fees::v0::{ use derive_more::From; use dpp::fee::epoch::CreditsPerEpoch; +use dpp::fee::fee_result::LifetimeStorageFees; use serde::{Deserialize, Serialize}; /// The versioned block fees @@ -45,6 +46,12 @@ impl BlockFeesV0Getters for BlockFees { BlockFees::V0(v0) => v0.refunds_per_epoch_mut(), } } + + fn lifetime_storage_fees(&self) -> &LifetimeStorageFees { + match self { + BlockFees::V0(v0) => v0.lifetime_storage_fees(), + } + } } impl BlockFeesV0Setters for BlockFees { diff --git a/packages/rs-drive-abci/src/execution/types/block_fees/v0/mod.rs b/packages/rs-drive-abci/src/execution/types/block_fees/v0/mod.rs index f958357329a..2d30d8eb26e 100644 --- a/packages/rs-drive-abci/src/execution/types/block_fees/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/types/block_fees/v0/mod.rs @@ -1,5 +1,5 @@ use dpp::fee::epoch::CreditsPerEpoch; -use dpp::fee::fee_result::FeeResult; +use dpp::fee::fee_result::{FeeResult, LifetimeStorageFees}; use serde::{Deserialize, Serialize}; /// Aggregated fees after block execution @@ -12,6 +12,11 @@ pub struct BlockFeesV0 { pub storage_fee: u64, /// Fee refunds per epoch pub refunds_per_epoch: CreditsPerEpoch, + /// The part of `storage_fee` for storage that lives a known number of epochs, by that + /// number (document time to live, protocol version 14): it goes to the lifetime storage + /// fee pools instead of the storage fee distribution pool. + #[serde(default)] + pub lifetime_storage_fees: LifetimeStorageFees, } #[allow(dead_code)] @@ -47,6 +52,10 @@ pub trait BlockFeesV0Getters { /// Returns the fee refunds per epoch. fn refunds_per_epoch_mut(&mut self) -> &mut CreditsPerEpoch; + + /// Returns the part of the storage fee for storage that lives a known number of epochs, + /// by that number. + fn lifetime_storage_fees(&self) -> &LifetimeStorageFees; } /// `BlockFeesV0Setters` trait provides setter methods for `BlockFeesV0`. @@ -82,6 +91,10 @@ impl BlockFeesV0Getters for BlockFeesV0 { fn refunds_per_epoch_mut(&mut self) -> &mut CreditsPerEpoch { &mut self.refunds_per_epoch } + + fn lifetime_storage_fees(&self) -> &LifetimeStorageFees { + &self.lifetime_storage_fees + } } impl BlockFeesV0Setters for BlockFeesV0 { @@ -104,6 +117,7 @@ impl From for BlockFeesV0 { storage_fee: value.storage_fee, processing_fee: value.processing_fee, refunds_per_epoch: value.fee_refunds.sum_per_epoch(), + lifetime_storage_fees: value.lifetime_storage_fees, } } } @@ -119,6 +133,7 @@ mod tests { processing_fee: 100, storage_fee: 200, refunds_per_epoch: CreditsPerEpoch::default(), + ..Default::default() } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/seated_moderation_charter/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/seated_moderation_charter/mod.rs index 406986b9a65..1c250ddaa0b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/seated_moderation_charter/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/seated_moderation_charter/mod.rs @@ -489,6 +489,7 @@ fn query_charter_documents( processing_fee: outcome.cost(), fee_refunds: Default::default(), removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), })); Ok(outcome.documents_owned()) } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs index e26fb251567..8cf8960ea7c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs @@ -204,6 +204,7 @@ fn fetch_documents_for_transitions_knowing_contract_and_document_type_v1( processing_fee: documents_outcome.cost(), fee_refunds: Default::default(), removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), })); Ok(ConsensusValidationResult::new_with_data( @@ -332,6 +333,7 @@ fn fetch_document_with_id_v0( processing_fee: fee, fee_refunds: Default::default(), removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), }; let mut documents = documents_outcome.documents_owned(); @@ -395,6 +397,7 @@ fn fetch_document_with_id_v1( processing_fee: documents_outcome.cost(), fee_refunds: Default::default(), removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), })); let mut documents = documents_outcome.documents_owned(); @@ -498,6 +501,7 @@ pub(crate) fn fetch_document_through_lookup( processing_fee: documents_outcome.cost(), fee_refunds: Default::default(), removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), })); Ok(documents_outcome.documents_owned().into_iter().next()) @@ -539,6 +543,7 @@ pub(crate) fn has_contested_document_with_document_id<'a>( processing_fee: fee, fee_refunds: Default::default(), removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), }; let documents = documents_outcome.documents_owned(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index 668a0f1e3f0..bc3fba52585 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -697,7 +697,8 @@ mod creation_tests { // the nonce derived id is billed 4 SHA-256 blocks instead of 2 processing_fee: 536140, fee_refunds: FeeRefunds::default(), - removed_bytes_from_system: 0 + removed_bytes_from_system: 0, + lifetime_storage_fees: Default::default(), }, address_balance_changes: std::collections::BTreeMap::new() } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/document_ttl.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/document_ttl.rs index 6a0bf812295..c2e3def27ea 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/document_ttl.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/document_ttl.rs @@ -279,6 +279,18 @@ mod document_ttl_tests { text: &str, time_ms: u64, ) -> (Document, StateTransitionExecutionResult) { + let (document, transition) = self.create_transition(document_type_name, text).await; + let result = self.process(&transition, time_ms); + (document, result) + } + + /// The transition creating a `document_type_name` document with `text`, and the + /// document it creates. + async fn create_transition( + &mut self, + document_type_name: &str, + text: &str, + ) -> (Document, StateTransition) { let platform_version = PlatformVersion::latest(); let document_type = self .contract @@ -316,8 +328,7 @@ mod document_ttl_tests { .await .expect("expected the create transition"); self.next_nonce += 1; - let result = self.process(&transition, time_ms); - (document, result) + (document, transition) } async fn delete( @@ -425,6 +436,18 @@ mod document_ttl_tests { .expect("expected to read the document") } + fn buyer_balance(&self) -> u64 { + self.platform + .drive + .fetch_identity_balance( + self.buyer.id().to_buffer(), + None, + PlatformVersion::latest(), + ) + .expect("expected to read the balance") + .expect("expected a balance") + } + fn balance(&self) -> u64 { self.platform .drive @@ -487,9 +510,11 @@ mod document_ttl_tests { let note_fee = fee_of(¬e_result); let memo_fee = fee_of(&memo_result); + // An hour of storage is a storage fee paid out over the one epoch it lives in. + assert!(note_fee.storage_fee > 0); assert_eq!( - note_fee.storage_fee, 0, - "an hour of storage pays into the processing fees, not the storage pool" + note_fee.lifetime_storage_fees, + std::collections::BTreeMap::from([(1, note_fee.storage_fee)]) ); assert!(memo_fee.storage_fee > 0); let note_bytes = note @@ -506,8 +531,8 @@ mod document_ttl_tests { &PlatformVersion::latest().fee_version, ) .expect("expected the cleanup fee"); - // The note's own processing, its bytes' hour of storage included, stays near the - // memo's; on top of it the note prepays its deletion. Drive's expiration tests pin + // The note's own processing stays near the memo's; on top of it the note prepays its + // deletion. Drive's expiration tests pin // the prepaid amount exactly. let prepaid = note_fee .processing_fee @@ -594,6 +619,83 @@ mod document_ttl_tests { ); } + #[tokio::test] + async fn should_collect_a_proposed_blocks_ttl_storage_fees_in_the_lifetime_pools() { + // A note created in a block: its storage fee goes to the pool of the one epoch an hour + // lives in, not to the perpetual storage fee distribution pool. + let mut fixture = NotesFixture::with_genesis_state(); + let (_, transition) = fixture.create_transition("note", "hello").await; + let raw_state_transitions = vec![transition + .serialize_to_bytes() + .expect("expected the transition to serialize")]; + + // The fixture's identities were funded outside a block: count their credits in the + // platform's total, so the block's credit check holds with the lifetime pools in it. + fixture + .platform + .drive + .add_to_system_credits( + fixture.balance() + fixture.buyer_balance(), + None, + PlatformVersion::latest(), + ) + .expect("expected to count the fixture's credits"); + // The last committed block opened epoch 0, whose fee pools the next block adds to. + fixture.platform.drive.set_genesis_time(START_MS); + fast_forward_to_block(&fixture.platform, START_MS, 10, 0, 0, true); + let platform_state = fixture.platform.state.load(); + let transaction = fixture.platform.drive.grove.start_transaction(); + let protocol_version = PlatformVersion::latest().protocol_version as u64; + let proposal = BlockProposal { + consensus_versions: Consensus { + block: 1, + app: protocol_version, + }, + block_hash: None, + height: 11, + round: 0, + block_time_ms: START_MS + 1_000, + core_chain_locked_height: 0, + core_chain_lock_update: None, + proposed_app_version: protocol_version, + proposer_pro_tx_hash: [0u8; 32], + validator_set_quorum_hash: [0u8; 32], + raw_state_transitions: &raw_state_transitions, + }; + // What the proposal does after its fees are processed (its validator set update + // against this test's empty quorum hash) is not under test. + let _ = fixture.platform.run_block_proposal( + proposal, + false, + &platform_state, + &transaction, + None, + ); + // Credits stay balanced with the lifetime pools counted in `Pools`. + let total_credits = fixture + .platform + .drive + .calculate_total_credits_balance(Some(&transaction), &PlatformVersion::latest().drive) + .expect("expected to total the credits"); + assert!( + total_credits.ok().expect("expected to compare the credits"), + "{total_credits:?}" + ); + + let pools = fixture + .platform + .drive + .fetch_lifetime_storage_fee_pools(Some(&transaction), PlatformVersion::latest()) + .expect("expected to read the lifetime pools"); + let epoch_0_pools = pools.get(&0).cloned().unwrap_or_default(); + assert_eq!( + (pools.len(), epoch_0_pools.len()), + (1, 1), + "one pool of epoch 0, for one epoch: {pools:?}" + ); + assert!(epoch_0_pools.get(&1).copied().unwrap_or_default() > 0); + } + #[tokio::test] async fn should_delete_expired_documents_after_the_block_and_refund_nobody() { let mut fixture = NotesFixture::new(); diff --git a/packages/rs-drive-abci/src/platform_types/platform/mod.rs b/packages/rs-drive-abci/src/platform_types/platform/mod.rs index a0e1b5dbec9..e779a5f1685 100644 --- a/packages/rs-drive-abci/src/platform_types/platform/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/platform/mod.rs @@ -154,8 +154,8 @@ impl Platform { } }; - // The epoch length is the execution config's; Drive routes the price of documents with - // a time to live by it, so it gets the same value rather than a setting of its own. + // The epoch length is the execution config's; Drive counts the epochs a document with a + // time to live lives by it, so it gets the same value rather than a setting of its own. let mut drive_config = config.drive.clone(); drive_config.epoch_time_length_s = config.execution.epoch_time_length_s; let (drive, current_platform_version) = diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 9e5b70f7b0d..4e4c8f0a377 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -1914,6 +1914,48 @@ "book": "fees/overview.md", "description": "Credits collected as fees and not yet paid out. A sum tree, so its total is every credit held by the pools.", "children": [ + { + "id": "pools.lifetime_storage_fee_pools", + "key": { + "type": "fixed", + "hex": "6c", + "label": "LifetimeStorageFeePools", + "constant": "KEY_LIFETIME_STORAGE_FEE_POOLS", + "ascii": true + }, + "kinds": [ + "SumTree" + ], + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/credit_pools/epochs/epochs_root_tree_key_constants.rs", + "book": "data-model/document-ttl.md", + "description": "Storage fees for storage that lives a known number of epochs (documents with a time to live), by the epoch they were collected in and that number, waiting for the next epoch change to spread them evenly over those epochs and remove them.", + "children": [ + { + "id": "pools.lifetime_storage_fee_pools.pool", + "key": { + "type": "dynamic", + "name": "collected_epoch_and_lifetime_epochs", + "matcher": { + "type": "len", + "len": 4 + }, + "encoding": "composite", + "description": "The epoch the fees were collected in, u16 big endian without the epoch trees' offset of 256, then how many epochs their storage lives, at most one era, u16 big endian" + }, + "kinds": [ + "SumItem" + ], + "value": "credits", + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/credit_pools/epochs/epochs_root_tree_key_constants.rs", + "description": "The storage fees to spread over that many epochs.", + "children": [] + } + ] + }, { "id": "pools.pending_epoch_refunds", "key": { @@ -1941,7 +1983,7 @@ "len": 2 }, "encoding": "u16_be", - "description": "The epoch the refund comes out of, offset by 256" + "description": "The epoch the refunded storage was paid in, u16 big endian, without the epoch trees' offset of 256" }, "kinds": [ "SumItem" @@ -1997,13 +2039,13 @@ "id": "pools.epoch", "key": { "type": "dynamic", - "name": "epoch_index", + "name": "epoch_index_plus_256", "matcher": { "type": "len", "len": 2 }, "encoding": "u16_be", - "description": "The epoch index offset by 256, so epoch keys sort after the one byte keys" + "description": "The epoch index plus 256 (`EPOCH_KEY_OFFSET`), u16 big endian" }, "kinds": [ "SumTree" diff --git a/packages/rs-drive/src/config.rs b/packages/rs-drive/src/config.rs index 504d74af42b..3715768df4d 100644 --- a/packages/rs-drive/src/config.rs +++ b/packages/rs-drive/src/config.rs @@ -126,10 +126,9 @@ pub struct DriveConfig { /// How long an epoch lasts, in seconds. Neither read nor written with the rest of the /// config: the node sets it from its execution config's `epoch_time_length_s` when it - /// opens Drive, so there is one source. Drive reads it only to route the price of a - /// document whose type declares a `ttl` to the processing fees (a `ttl` under the fee - /// schedule's `processing_route_below_epochs` epochs) or the storage fee pool; the price - /// itself follows the schedule's fixed period. + /// opens Drive, so there is one source. Drive reads it only to count the epochs a + /// document whose type declares a `ttl` has left to live, which its storage fee is paid + /// out over; the price itself follows the fee schedule's fixed period. #[cfg_attr( feature = "serde", serde(skip, default = "default_epoch_time_length_s") diff --git a/packages/rs-drive/src/drive/credit_pools/epochs/epochs_root_tree_key_constants.rs b/packages/rs-drive/src/drive/credit_pools/epochs/epochs_root_tree_key_constants.rs index abdd5780f54..11ae9adc1b7 100644 --- a/packages/rs-drive/src/drive/credit_pools/epochs/epochs_root_tree_key_constants.rs +++ b/packages/rs-drive/src/drive/credit_pools/epochs/epochs_root_tree_key_constants.rs @@ -11,3 +11,7 @@ pub const KEY_UNPAID_EPOCH_INDEX_U8: u8 = b'u'; pub const KEY_PENDING_EPOCH_REFUNDS: &[u8; 1] = b"p"; /// Pending refunds that will be deducted from epoch storage fee pools pub const KEY_PENDING_EPOCH_REFUNDS_U8: u8 = b'p'; +/// Storage fees for storage that lives a known number of epochs, by the epoch they were +/// collected in and that number (protocol version 14, document time to live), waiting to be +/// spread over those epochs +pub const KEY_LIFETIME_STORAGE_FEE_POOLS: &[u8; 1] = b"l"; diff --git a/packages/rs-drive/src/drive/credit_pools/mod.rs b/packages/rs-drive/src/drive/credit_pools/mod.rs index 1e7ff188970..cad708b8598 100644 --- a/packages/rs-drive/src/drive/credit_pools/mod.rs +++ b/packages/rs-drive/src/drive/credit_pools/mod.rs @@ -61,6 +61,8 @@ use crate::fees::get_overflow_error; #[cfg(any(feature = "server", feature = "verify"))] pub use paths::*; +#[cfg(feature = "server")] +use platform_version::version::drive_versions::DriveVersion; #[cfg(feature = "server")] use platform_version::version::PlatformVersion; @@ -174,6 +176,47 @@ impl Drive { Ok(()) } + + /// Reads every element of the sum tree at `path`, raw and in key order, as its key and the + /// value of its sum item: the pools that keep one sum item per key (the pending epoch + /// refunds and the lifetime storage fee pools). Any other element is corrupted state, + /// reported as `not_a_sum_item`. + pub(in crate::drive::credit_pools) fn fetch_sum_items( + &self, + path: Vec>, + not_a_sum_item: &'static str, + transaction: TransactionArg, + drive_version: &DriveVersion, + ) -> Result, SignedCredits)>, Error> { + let mut query = Query::new(); + + query.insert_all(); + + let (query_result, _) = self + .grove + .query_raw( + &PathQuery::new_unsized(path, query), + transaction.is_some(), + true, + true, + QueryResultType::QueryKeyElementPairResultType, + transaction, + &drive_version.grove_version, + ) + .unwrap() + .map_err(Error::from)?; + + query_result + .to_key_elements() + .into_iter() + .map(|(key, element)| match element { + Element::SumItem(credits, _) => Ok((key, credits)), + _ => Err(Error::Drive(DriveError::CorruptedCodeExecution( + not_a_sum_item, + ))), + }) + .collect() + } } #[cfg(feature = "server")] diff --git a/packages/rs-drive/src/drive/credit_pools/operations.rs b/packages/rs-drive/src/drive/credit_pools/operations.rs index 62e42a99d2d..57e06d90224 100644 --- a/packages/rs-drive/src/drive/credit_pools/operations.rs +++ b/packages/rs-drive/src/drive/credit_pools/operations.rs @@ -1,6 +1,9 @@ use crate::drive::credit_pools::epochs::epochs_root_tree_key_constants::{ KEY_PENDING_EPOCH_REFUNDS, KEY_STORAGE_FEE_POOL, KEY_UNPAID_EPOCH_INDEX, }; +use crate::drive::credit_pools::paths::{ + lifetime_storage_fee_pool_key, lifetime_storage_fee_pools_vec_path, +}; use crate::drive::credit_pools::pools_vec_path; use crate::error::Error; use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; @@ -30,6 +33,36 @@ pub fn update_storage_fee_distribution_pool_operation( .dont_check_for_backwards_references()) } +#[cfg(feature = "server")] +/// Sets the lifetime storage fee pool of the fees collected in `collected_epoch_index` for +/// storage living `lifetime_epochs` epochs to `credits` +pub fn update_lifetime_storage_fee_pool_operation( + collected_epoch_index: EpochIndex, + lifetime_epochs: u16, + credits: Credits, +) -> Result { + Ok(QualifiedGroveDbOp::insert_or_replace_op( + lifetime_storage_fee_pools_vec_path(), + lifetime_storage_fee_pool_key(collected_epoch_index, lifetime_epochs), + Element::new_sum_item(credits.to_signed()?), + ) + .dont_check_for_backwards_references()) +} + +#[cfg(feature = "server")] +/// Removes the lifetime storage fee pool of the fees collected in `collected_epoch_index` for +/// storage living `lifetime_epochs` epochs, once an epoch change has spread it +pub fn delete_lifetime_storage_fee_pool_operation( + collected_epoch_index: EpochIndex, + lifetime_epochs: u16, +) -> QualifiedGroveDbOp { + QualifiedGroveDbOp::delete_op( + lifetime_storage_fee_pools_vec_path(), + lifetime_storage_fee_pool_key(collected_epoch_index, lifetime_epochs), + ) + .dont_check_for_backwards_references() +} + #[cfg(feature = "server")] /// Updates the unpaid epoch index pub fn update_unpaid_epoch_index_operation(epoch_index: EpochIndex) -> QualifiedGroveDbOp { diff --git a/packages/rs-drive/src/drive/credit_pools/paths.rs b/packages/rs-drive/src/drive/credit_pools/paths.rs index 51a23e8878c..807a1d5e0cc 100644 --- a/packages/rs-drive/src/drive/credit_pools/paths.rs +++ b/packages/rs-drive/src/drive/credit_pools/paths.rs @@ -1,4 +1,6 @@ -use crate::drive::credit_pools::epochs::epochs_root_tree_key_constants::KEY_STORAGE_FEE_POOL; +use crate::drive::credit_pools::epochs::epochs_root_tree_key_constants::{ + KEY_LIFETIME_STORAGE_FEE_POOLS, KEY_STORAGE_FEE_POOL, +}; use crate::drive::RootTree; use crate::error::Error; use dpp::block::epoch::{EpochIndex, EPOCH_KEY_OFFSET}; @@ -33,6 +35,30 @@ pub fn aggregate_storage_fees_distribution_pool_path() -> [&'static [u8]; 2] { ] } +/// The path of the lifetime storage fee pools: storage fees for storage that lives a known +/// number of epochs, keyed by the epoch they were collected in and that number (see +/// [`lifetime_storage_fee_pool_key`], protocol version 14) +pub fn lifetime_storage_fee_pools_vec_path() -> Vec> { + vec![ + vec![RootTree::Pools as u8], + KEY_LIFETIME_STORAGE_FEE_POOLS.to_vec(), + ] +} + +/// The key of a lifetime storage fee pool: the epoch its fees were collected in, then the +/// number of epochs their storage lives, each a u16 big endian. The blocks of an epoch add +/// only to the pools of that epoch, and the next epoch change spreads and removes them. +pub fn lifetime_storage_fee_pool_key( + collected_epoch_index: EpochIndex, + lifetime_epochs: u16, +) -> Vec { + [ + collected_epoch_index.to_be_bytes(), + lifetime_epochs.to_be_bytes(), + ] + .concat() +} + /// Returns the path to the aggregate storage fee distribution pool as a mutable vector. pub fn aggregate_storage_fees_distribution_pool_vec_path() -> Vec> { vec![vec![RootTree::Pools as u8], KEY_STORAGE_FEE_POOL.to_vec()] diff --git a/packages/rs-drive/src/drive/credit_pools/pending_epoch_refunds/methods/fetch_pending_epoch_refunds/v0/mod.rs b/packages/rs-drive/src/drive/credit_pools/pending_epoch_refunds/methods/fetch_pending_epoch_refunds/v0/mod.rs index 7574d6baf6f..4ab25823860 100644 --- a/packages/rs-drive/src/drive/credit_pools/pending_epoch_refunds/methods/fetch_pending_epoch_refunds/v0/mod.rs +++ b/packages/rs-drive/src/drive/credit_pools/pending_epoch_refunds/methods/fetch_pending_epoch_refunds/v0/mod.rs @@ -5,8 +5,7 @@ use crate::error::Error; use dpp::balances::credits::Creditable; use dpp::fee::epoch::CreditsPerEpoch; -use grovedb::query_result_type::QueryResultType; -use grovedb::{Element, PathQuery, Query, TransactionArg}; +use grovedb::TransactionArg; use platform_version::version::drive_versions::DriveVersion; impl Drive { @@ -16,43 +15,26 @@ impl Drive { transaction: TransactionArg, drive_version: &DriveVersion, ) -> Result { - let mut query = Query::new(); - - query.insert_all(); - - let (query_result, _) = self - .grove - .query_raw( - &PathQuery::new_unsized(pending_epoch_refunds_path_vec(), query), - transaction.is_some(), - true, - true, - QueryResultType::QueryKeyElementPairResultType, - transaction, - &drive_version.grove_version, - ) - .unwrap() - .map_err(Error::from)?; - - query_result - .to_key_elements() - .into_iter() - .map(|(epoch_index_key, element)| { - let epoch_index = - u16::from_be_bytes(epoch_index_key.as_slice().try_into().map_err(|_| { - Error::Drive(DriveError::CorruptedSerialization(String::from( - "epoch index for pending pool updates must be i64", - ))) - })?); - - if let Element::SumItem(credits, _) = element { - Ok((epoch_index, credits.to_unsigned())) - } else { - Err(Error::Drive(DriveError::CorruptedCodeExecution( - "pending refund credits must be sum items", + // Edited in place in this shipped generation: the query and the reading of its sum + // items moved, unchanged, into `fetch_sum_items`, which the lifetime storage fee pools + // share. Only which of two corruption errors a corrupted tree reports first can differ. + self.fetch_sum_items( + pending_epoch_refunds_path_vec(), + "pending refund credits must be sum items", + transaction, + drive_version, + )? + .into_iter() + .map(|(epoch_index_key, credits)| { + let epoch_index = + u16::from_be_bytes(epoch_index_key.as_slice().try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedSerialization(String::from( + "epoch index for pending pool updates must be i64", ))) - } - }) - .collect::>() + })?); + + Ok((epoch_index, credits.to_unsigned())) + }) + .collect::>() } } diff --git a/packages/rs-drive/src/drive/credit_pools/storage_fee_distribution_pool/fetch_lifetime_storage_fee_pools/mod.rs b/packages/rs-drive/src/drive/credit_pools/storage_fee_distribution_pool/fetch_lifetime_storage_fee_pools/mod.rs new file mode 100644 index 00000000000..88c4bcbab7e --- /dev/null +++ b/packages/rs-drive/src/drive/credit_pools/storage_fee_distribution_pool/fetch_lifetime_storage_fee_pools/mod.rs @@ -0,0 +1,106 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::block::epoch::EpochIndex; +use dpp::fee::fee_result::LifetimeStorageFees; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; +use std::collections::BTreeMap; + +impl Drive { + /// Reads the lifetime storage fee pools: the storage fees, by the epoch they were collected + /// in and then by the number of epochs their storage lives, waiting for the next epoch + /// change to spread them over those epochs (protocol version 14, document time to live). + /// + /// # Parameters + /// - `transaction`: the transaction to read in. + /// - `platform_version`: selects the method version. + /// + /// # Returns + /// The credits of each lifetime pool, by its epoch and its number of epochs. A missing + /// pools tree or a negative pool is corrupted state, and an error. + pub fn fetch_lifetime_storage_fee_pools( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .credit_pools + .storage_fee_distribution_pool + .fetch_lifetime_storage_fee_pools + { + 0 => self.fetch_lifetime_storage_fee_pools_v0(transaction, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_lifetime_storage_fee_pools".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::drive::credit_pools::operations::update_lifetime_storage_fee_pool_operation; + use crate::drive::credit_pools::paths::{ + lifetime_storage_fee_pool_key, lifetime_storage_fee_pools_vec_path, + }; + use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; + use crate::util::batch::GroveDbOpBatch; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use grovedb::batch::QualifiedGroveDbOp; + use grovedb::Element; + + #[test] + fn should_read_the_pools_by_epoch_and_refuse_a_missing_tree_or_a_negative_pool() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + let mut batch = GroveDbOpBatch::new(); + for (epoch_index, lifetime_epochs, credits) in [(3, 2, 7), (3, 40, 9), (4, 1, 5)] { + batch.push( + update_lifetime_storage_fee_pool_operation(epoch_index, lifetime_epochs, credits) + .expect("expected the pool operation"), + ); + } + drive + .grove_apply_batch(batch, false, None, &platform_version.drive) + .expect("expected to fill the pools"); + assert_eq!( + drive + .fetch_lifetime_storage_fee_pools(None, platform_version) + .expect("expected to read the pools"), + BTreeMap::from([ + (3, LifetimeStorageFees::from([(2, 7), (40, 9)])), + (4, LifetimeStorageFees::from([(1, 5)])), + ]) + ); + + let mut batch = GroveDbOpBatch::new(); + batch.push(QualifiedGroveDbOp::insert_or_replace_op( + lifetime_storage_fee_pools_vec_path(), + lifetime_storage_fee_pool_key(4, 1), + Element::new_sum_item(-5), + )); + drive + .grove_apply_batch(batch, false, None, &platform_version.drive) + .expect("expected to corrupt a pool"); + assert!(matches!( + drive.fetch_lifetime_storage_fee_pools(None, platform_version), + Err(Error::Drive(DriveError::CorruptedDriveState(_))) + )); + + // A chain of protocol version 13 has no pools tree. + let drive = setup_drive_with_initial_state_structure(Some( + PlatformVersion::get(13).expect("expected protocol version 13"), + )); + assert!(matches!( + drive.fetch_lifetime_storage_fee_pools(None, platform_version), + Err(Error::Drive(DriveError::CorruptedDriveState(_))) + )); + } +} diff --git a/packages/rs-drive/src/drive/credit_pools/storage_fee_distribution_pool/fetch_lifetime_storage_fee_pools/v0/mod.rs b/packages/rs-drive/src/drive/credit_pools/storage_fee_distribution_pool/fetch_lifetime_storage_fee_pools/v0/mod.rs new file mode 100644 index 00000000000..053ebde858a --- /dev/null +++ b/packages/rs-drive/src/drive/credit_pools/storage_fee_distribution_pool/fetch_lifetime_storage_fee_pools/v0/mod.rs @@ -0,0 +1,68 @@ +use crate::drive::credit_pools::epochs::epochs_root_tree_key_constants::KEY_LIFETIME_STORAGE_FEE_POOLS; +use crate::drive::credit_pools::paths::{lifetime_storage_fee_pools_vec_path, pools_path}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::block::epoch::EpochIndex; +use dpp::fee::fee_result::LifetimeStorageFees; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; +use std::collections::BTreeMap; + +impl Drive { + #[inline(always)] + pub(super) fn fetch_lifetime_storage_fee_pools_v0( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + // Only protocol version 14 on reads the pools, and its chains all hold the tree: one + // without it is corrupted, not empty. A query under a missing tree returns nothing, so + // the tree is looked up first. + let tree_exists = self + .grove + .has_raw( + &pools_path(), + KEY_LIFETIME_STORAGE_FEE_POOLS, + transaction, + &platform_version.drive.grove_version, + ) + .unwrap() + .map_err(Error::from)?; + if !tree_exists { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "the lifetime storage fee pools tree must exist from protocol version 14" + .to_string(), + ))); + } + let pools = self.fetch_sum_items( + lifetime_storage_fee_pools_vec_path(), + "a lifetime storage fee pool must be a sum item", + transaction, + &platform_version.drive, + )?; + let mut pools_by_epoch = BTreeMap::::new(); + for (key, credits) in pools { + let [epoch_high, epoch_low, lifetime_high, lifetime_low]: [u8; 4] = + key.as_slice().try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedDriveState( + "a lifetime storage fee pool must be keyed by an epoch and a lifetime, \ + two u16" + .to_string(), + )) + })?; + // A pool only ever receives storage fees: a negative one is corrupted, and + // spreading its absolute value would create credits. + let credits = u64::try_from(credits).map_err(|_| { + Error::Drive(DriveError::CorruptedDriveState( + "a lifetime storage fee pool must not be negative".to_string(), + )) + })?; + pools_by_epoch + .entry(u16::from_be_bytes([epoch_high, epoch_low])) + .or_default() + .insert(u16::from_be_bytes([lifetime_high, lifetime_low]), credits); + } + Ok(pools_by_epoch) + } +} diff --git a/packages/rs-drive/src/drive/credit_pools/storage_fee_distribution_pool/mod.rs b/packages/rs-drive/src/drive/credit_pools/storage_fee_distribution_pool/mod.rs index b98ef16c53f..f4c0bff023e 100644 --- a/packages/rs-drive/src/drive/credit_pools/storage_fee_distribution_pool/mod.rs +++ b/packages/rs-drive/src/drive/credit_pools/storage_fee_distribution_pool/mod.rs @@ -1,4 +1,5 @@ //! Storage Fee Distribution Pool. //! +mod fetch_lifetime_storage_fee_pools; mod get_storage_fees_from_distribution_pool; diff --git a/packages/rs-drive/src/drive/credit_pools/structure.rs b/packages/rs-drive/src/drive/credit_pools/structure.rs index ec93f91de70..057c73457c3 100644 --- a/packages/rs-drive/src/drive/credit_pools/structure.rs +++ b/packages/rs-drive/src/drive/credit_pools/structure.rs @@ -4,7 +4,8 @@ use crate::drive::credit_pools::epochs::epoch_key_constants::{ KEY_START_TIME, }; use crate::drive::credit_pools::epochs::epochs_root_tree_key_constants::{ - KEY_PENDING_EPOCH_REFUNDS, KEY_STORAGE_FEE_POOL, KEY_UNPAID_EPOCH_INDEX, + KEY_LIFETIME_STORAGE_FEE_POOLS, KEY_PENDING_EPOCH_REFUNDS, KEY_STORAGE_FEE_POOL, + KEY_UNPAID_EPOCH_INDEX, }; use crate::drive::RootTree; use crate::structure::{ElementKind, KeyEncoding, KeyMatcher, StructureNode}; @@ -60,6 +61,38 @@ pub(crate) fn structure() -> StructureNode { paid yet.", ), ) + .child( + StructureNode::fixed( + "lifetime_storage_fee_pools", + KEY_LIFETIME_STORAGE_FEE_POOLS, + "LifetimeStorageFeePools", + "KEY_LIFETIME_STORAGE_FEE_POOLS", + ) + .ascii() + .kind(ElementKind::SumTree) + .since(14) + .source(ROOT_KEYS) + .book("data-model/document-ttl.md") + .describe( + "Storage fees for storage that lives a known number of epochs (documents with a \ + time to live), by the epoch they were collected in and that number, waiting for \ + the next epoch change to spread them evenly over those epochs and remove them.", + ) + .child( + StructureNode::dynamic( + "pool", + "collected_epoch_and_lifetime_epochs", + KeyMatcher::Len(4), + KeyEncoding::Composite, + "The epoch the fees were collected in, u16 big endian without the epoch trees' \ + offset of 256, then how many epochs their storage lives, at most one era, u16 \ + big endian", + ) + .kind(ElementKind::SumItem) + .value("credits") + .describe("The storage fees to spread over that many epochs."), + ), + ) .child( StructureNode::fixed( "pending_epoch_refunds", @@ -80,7 +113,8 @@ pub(crate) fn structure() -> StructureNode { "epoch_index", KeyMatcher::Len(2), KeyEncoding::U16Be, - "The epoch the refund comes out of, offset by 256", + "The epoch the refunded storage was paid in, u16 big endian, without the \ + epoch trees' offset of 256", ) .kind(ElementKind::SumItem) .value("credits, negative") @@ -93,11 +127,10 @@ pub(crate) fn structure() -> StructureNode { .child( StructureNode::dynamic( "epoch", - "epoch_index", + "epoch_index_plus_256", KeyMatcher::Len(2), KeyEncoding::U16Be, - "The epoch index offset by 256, so epoch keys \ - sort after the one byte keys", + "The epoch index plus 256 (`EPOCH_KEY_OFFSET`), u16 big endian", ) .kind(ElementKind::SumTree) .source(EPOCH_KEYS) diff --git a/packages/rs-drive/src/drive/document/expiration/expiration_tests.rs b/packages/rs-drive/src/drive/document/expiration/expiration_tests.rs index af538d66257..a955d36f0ab 100644 --- a/packages/rs-drive/src/drive/document/expiration/expiration_tests.rs +++ b/packages/rs-drive/src/drive/document/expiration/expiration_tests.rs @@ -20,7 +20,7 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContractFactory; use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; -use dpp::fee::fee_result::FeeResult; +use dpp::fee::fee_result::{FeeResult, LifetimeStorageFees}; use dpp::platform_value::{platform_value, Identifier, Value}; use dpp::prelude::DataContract; use dpp::version::PlatformVersion; @@ -812,17 +812,20 @@ fn should_prepay_the_deletion_of_the_bytes_a_change_adds() { } #[test] -fn should_price_a_short_lived_document_into_processing_and_prepay_its_deletion() { +fn should_pay_a_short_lived_document_over_its_epoch_and_prepay_its_deletion() { let platform_version = PlatformVersion::latest(); let (drive, contract) = setup(3_600); let note_fee = insert(&drive, &contract, "note", ¬e(1, START_MS, "hello"), true); let memo_fee = insert(&drive, &contract, "memo", ¬e(2, START_MS, "hello"), true); + // An hour lives in one epoch: its whole storage fee is paid out over that epoch. + assert!(note_fee.storage_fee > 0); assert_eq!( - note_fee.storage_fee, 0, - "a lifetime under two epochs pays nothing into the storage pool" + note_fee.lifetime_storage_fees, + LifetimeStorageFees::from([(1, note_fee.storage_fee)]) ); assert!(memo_fee.storage_fee > 0); + assert!(memo_fee.lifetime_storage_fees.is_empty()); let note_type = contract.document_type_for_name("note").expect("type"); let note_bytes = note(1, START_MS, "hello") .serialize(note_type, &contract, platform_version) @@ -875,7 +878,7 @@ fn should_price_a_short_lived_document_into_processing_and_prepay_its_deletion() } #[test] -fn should_price_a_long_lived_document_into_the_storage_pool() { +fn should_pay_a_long_lived_document_over_the_epochs_it_lives() { let (drive, contract) = setup(31_536_000); let note_fee = insert(&drive, &contract, "note", ¬e(1, START_MS, "hello"), true); let memo_fee = insert(&drive, &contract, "memo", ¬e(2, START_MS, "hello"), true); @@ -883,9 +886,13 @@ fn should_price_a_long_lived_document_into_the_storage_pool() { .fee_version .document_ttl .credit_per_byte_per_period; - // A year of 365 days is exactly 40 pricing periods of 9.125 days. + // A year of 365 days is exactly 40 pricing periods, and epochs, of 9.125 days. assert!(note_fee.storage_fee > 0); assert_eq!(note_fee.storage_fee % (40 * per_period), 0); + assert_eq!( + note_fee.lifetime_storage_fees, + LifetimeStorageFees::from([(40, note_fee.storage_fee)]) + ); assert!(note_fee.storage_fee < memo_fee.storage_fee); } diff --git a/packages/rs-drive/src/drive/document/expiration/insert_documents_expirations_tree/mod.rs b/packages/rs-drive/src/drive/document/expiration/insert_document_ttl_trees/mod.rs similarity index 67% rename from packages/rs-drive/src/drive/document/expiration/insert_documents_expirations_tree/mod.rs rename to packages/rs-drive/src/drive/document/expiration/insert_document_ttl_trees/mod.rs index 78809da6e0b..a4b39425160 100644 --- a/packages/rs-drive/src/drive/document/expiration/insert_documents_expirations_tree/mod.rs +++ b/packages/rs-drive/src/drive/document/expiration/insert_document_ttl_trees/mod.rs @@ -7,19 +7,20 @@ use dpp::version::PlatformVersion; use grovedb::TransactionArg; impl Drive { - /// Creates the documents expirations tree under `Misc` if it does not exist yet. + /// Creates the trees document time to live needs if they do not exist yet: the documents + /// expirations tree under `Misc`, and the lifetime storage fee pools under `Pools`. /// /// Called when the initial state structure of protocol version 14 is created and on the /// first block of protocol version 14, so a new chain and an upgraded one hold the same - /// tree. + /// trees. /// /// # Parameters /// - `transaction`: the transaction to write in. /// - `platform_version`: selects the method version. /// /// # Returns - /// `Ok(())` once the tree exists. - pub fn insert_documents_expirations_tree( + /// `Ok(())` once the trees exist. + pub fn insert_document_ttl_trees( &self, transaction: TransactionArg, platform_version: &PlatformVersion, @@ -29,11 +30,11 @@ impl Drive { .methods .document .expiration - .insert_documents_expirations_tree + .insert_document_ttl_trees { - 0 => self.insert_documents_expirations_tree_v0(transaction, platform_version), + 0 => self.insert_document_ttl_trees_v0(transaction, platform_version), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { - method: "insert_documents_expirations_tree".to_string(), + method: "insert_document_ttl_trees".to_string(), known_versions: vec![0], received: version, })), diff --git a/packages/rs-drive/src/drive/document/expiration/insert_documents_expirations_tree/v0/mod.rs b/packages/rs-drive/src/drive/document/expiration/insert_document_ttl_trees/v0/mod.rs similarity index 50% rename from packages/rs-drive/src/drive/document/expiration/insert_documents_expirations_tree/v0/mod.rs rename to packages/rs-drive/src/drive/document/expiration/insert_document_ttl_trees/v0/mod.rs index e7618affb49..faf4bf52175 100644 --- a/packages/rs-drive/src/drive/document/expiration/insert_documents_expirations_tree/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/expiration/insert_document_ttl_trees/v0/mod.rs @@ -1,3 +1,5 @@ +use crate::drive::credit_pools::epochs::epochs_root_tree_key_constants::KEY_LIFETIME_STORAGE_FEE_POOLS; +use crate::drive::credit_pools::paths::pools_path; use crate::drive::document::expiration::paths::DOCUMENTS_EXPIRATIONS_KEY; use crate::drive::system::misc_path; use crate::drive::Drive; @@ -7,12 +9,13 @@ use grovedb::{Element, TransactionArg}; impl Drive { #[inline(always)] - pub(super) fn insert_documents_expirations_tree_v0( + pub(super) fn insert_document_ttl_trees_v0( &self, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result<(), Error> { - // No storage flags: the tree is system structure, and every entry under it is flagless. + // No storage flags: the trees are system structure, and every entry under them is + // flagless. self.grove_insert_if_not_exists( (&misc_path()).into(), DOCUMENTS_EXPIRATIONS_KEY, @@ -21,6 +24,15 @@ impl Drive { None, &platform_version.drive, )?; + // The lifetime storage fee pools, a sum tree so `Pools` counts their credits. + self.grove_insert_if_not_exists( + (&pools_path()).into(), + KEY_LIFETIME_STORAGE_FEE_POOLS, + Element::empty_sum_tree(), + transaction, + None, + &platform_version.drive, + )?; Ok(()) } } diff --git a/packages/rs-drive/src/drive/document/expiration/mod.rs b/packages/rs-drive/src/drive/document/expiration/mod.rs index 49f1544d1e5..5ed2b5633f4 100644 --- a/packages/rs-drive/src/drive/document/expiration/mod.rs +++ b/packages/rs-drive/src/drive/document/expiration/mod.rs @@ -24,7 +24,7 @@ mod add_document_expiration_operations; mod add_estimation_costs_for_document_expiration; mod fetch_expired_documents; -mod insert_documents_expirations_tree; +mod insert_document_ttl_trees; /// Paths of the documents expirations tree pub mod paths; /// Prices of the bytes and the deletion of documents with a time to live diff --git a/packages/rs-drive/src/drive/document/expiration/pricing.rs b/packages/rs-drive/src/drive/document/expiration/pricing.rs index 84295ce6def..58052f6812b 100644 --- a/packages/rs-drive/src/drive/document/expiration/pricing.rs +++ b/packages/rs-drive/src/drive/document/expiration/pricing.rs @@ -13,19 +13,16 @@ use platform_version::version::fee::FeeVersion; /// How the bytes a document writes are priced when it has `remaining_lifetime_ms` left to /// live: the first tier covering that lifetime, or past the last tier the schedule's price -/// per pricing period times the periods it spans, rounded up. A document of a type whose -/// `ttl_seconds` is shorter than the schedule's `processing_route_below_epochs` epochs of -/// `epoch_time_length_s` pays into the processing fees, one of a longer `ttl` into the -/// storage fee pool. +/// per pricing period times the periods it spans, rounded up; paid out over the epochs of +/// `epoch_time_length_s` the lifetime spans, rounded up and at most `epochs_per_era`. /// /// The price never decreases with the lifetime, which keeps an estimate made at an earlier /// block time (a longer remaining lifetime) an upper bound of the price at execution. The -/// route depends on the declared `ttl` alone, so the estimate and the execution take the -/// same one: the fee increase a writer offers multiplies processing only. +/// epochs only decide which epochs the pools pay the amount to. pub fn document_ttl_pricing( remaining_lifetime_ms: u64, - ttl_seconds: u32, epoch_time_length_s: u64, + epochs_per_era: u16, fee_version: &FeeVersion, ) -> Result { let schedule = &fee_version.document_ttl; @@ -51,14 +48,18 @@ pub fn document_ttl_pricing( )))? } }; - let storage_pool_from_seconds = u64::from(schedule.processing_route_below_epochs) - .checked_mul(epoch_time_length_s) - .ok_or(Error::Fee(FeeError::Overflow( - "overflow computing the storage pool route of a document with a time to live", + let epoch_ms = epoch_time_length_s + .checked_mul(1000) + .filter(|epoch_ms| *epoch_ms > 0) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "the epoch length must be a positive number of milliseconds", )))?; + let lifetime_epochs = u16::try_from(remaining_lifetime_ms.div_ceil(epoch_ms)) + .unwrap_or(u16::MAX) + .clamp(1, epochs_per_era.max(1)); Ok(EphemeralPricing::DocumentTtl { credit_per_byte, - storage_pool: u64::from(ttl_seconds) >= storage_pool_from_seconds, + lifetime_epochs, }) } @@ -158,44 +159,43 @@ mod tests { const TESTNET_EPOCH_S: u64 = 3_600; const HOUR_MS: u64 = 3_600_000; const DAY_MS: u64 = 86_400_000; - const WEEK_S: u32 = 604_800; - fn price_on(lifetime_ms: u64, ttl_seconds: u32, epoch_s: u64) -> (Credits, bool) { + const EPOCHS_PER_ERA: u16 = 40; + + fn price_on(lifetime_ms: u64, epoch_s: u64) -> (Credits, u16) { match document_ttl_pricing( lifetime_ms, - ttl_seconds, epoch_s, + EPOCHS_PER_ERA, &PlatformVersion::latest().fee_version, ) .expect("prices") { EphemeralPricing::DocumentTtl { credit_per_byte, - storage_pool, - } => (credit_per_byte, storage_pool), + lifetime_epochs, + } => (credit_per_byte, lifetime_epochs), other => panic!("expected a document ttl price, got {other:?}"), } } - /// The price of a document created now: its whole time to live is left. - fn price(lifetime_ms: u64) -> (Credits, bool) { - let ttl_seconds = u32::try_from(lifetime_ms.div_ceil(1000)).expect("fits"); - price_on(lifetime_ms, ttl_seconds, MAINNET_EPOCH_S) + fn price(lifetime_ms: u64) -> (Credits, u16) { + price_on(lifetime_ms, MAINNET_EPOCH_S) } #[test] fn should_price_each_short_lifetime_by_its_tier() { let tiers = PlatformVersion::latest().fee_version.document_ttl.tiers; - assert_eq!(price(1), (tiers[0].credit_per_byte, false)); - assert_eq!(price(HOUR_MS), (tiers[0].credit_per_byte, false)); - assert_eq!(price(HOUR_MS + 1), (tiers[1].credit_per_byte, false)); - assert_eq!(price(DAY_MS), (tiers[1].credit_per_byte, false)); - assert_eq!(price(DAY_MS + 1), (tiers[2].credit_per_byte, false)); - assert_eq!(price(2 * DAY_MS), (tiers[2].credit_per_byte, false)); - assert_eq!(price(2 * DAY_MS + 1), (tiers[3].credit_per_byte, false)); - assert_eq!(price(4 * DAY_MS), (tiers[3].credit_per_byte, false)); - assert_eq!(price(4 * DAY_MS + 1), (tiers[4].credit_per_byte, false)); - assert_eq!(price(7 * DAY_MS), (tiers[4].credit_per_byte, false)); + assert_eq!(price(1).0, tiers[0].credit_per_byte); + assert_eq!(price(HOUR_MS).0, tiers[0].credit_per_byte); + assert_eq!(price(HOUR_MS + 1).0, tiers[1].credit_per_byte); + assert_eq!(price(DAY_MS).0, tiers[1].credit_per_byte); + assert_eq!(price(DAY_MS + 1).0, tiers[2].credit_per_byte); + assert_eq!(price(2 * DAY_MS).0, tiers[2].credit_per_byte); + assert_eq!(price(2 * DAY_MS + 1).0, tiers[3].credit_per_byte); + assert_eq!(price(4 * DAY_MS).0, tiers[3].credit_per_byte); + assert_eq!(price(4 * DAY_MS + 1).0, tiers[4].credit_per_byte); + assert_eq!(price(7 * DAY_MS).0, tiers[4].credit_per_byte); } #[test] @@ -204,23 +204,25 @@ mod tests { let per_period = schedule.credit_per_byte_per_period; let period_ms = u64::from(schedule.pricing_period_seconds) * 1000; // Past seven days but inside the first period: one period. - assert_eq!(price(7 * DAY_MS + 1), (per_period, false)); - assert_eq!(price(period_ms), (per_period, false)); - assert_eq!(price(period_ms + 1), (2 * per_period, false)); - assert_eq!(price(365 * DAY_MS), (40 * per_period, true)); + assert_eq!(price(7 * DAY_MS + 1).0, per_period); + assert_eq!(price(period_ms).0, per_period); + assert_eq!(price(period_ms + 1).0, 2 * per_period); + assert_eq!(price(365 * DAY_MS).0, 40 * per_period); } #[test] - fn should_route_by_the_declared_time_to_live_in_epochs() { - let epoch_s = u32::try_from(MAINNET_EPOCH_S).expect("fits"); - // A type of two epochs or more pays into the storage fee pool, whatever is left. - assert!(price_on(1, 2 * epoch_s, MAINNET_EPOCH_S).1); - assert!(price_on(u64::from(2 * epoch_s) * 1000, 2 * epoch_s, MAINNET_EPOCH_S).1); - // A shorter type pays into the processing fees. - assert!(!price_on(1, 2 * epoch_s - 1, MAINNET_EPOCH_S).1); - // The network's epochs decide the route: two hours spans two testnet epochs. - assert!(price_on(HOUR_MS, 7_200, TESTNET_EPOCH_S).1); - assert!(!price_on(HOUR_MS, 7_200, MAINNET_EPOCH_S).1); + fn should_pay_out_over_the_epochs_the_lifetime_spans() { + let epoch_ms = MAINNET_EPOCH_S * 1000; + // Less than an epoch still pays one epoch. + assert_eq!(price(1).1, 1); + assert_eq!(price(epoch_ms).1, 1); + assert_eq!(price(epoch_ms + 1).1, 2); + // A year of 365 days is exactly 40 epochs of 9.125 days. + assert_eq!(price(365 * DAY_MS).1, 40); + // Never past one era, whatever the network's epochs: testnet's hour-long epochs pay + // a two-hour lifetime over two epochs and a year over one era. + assert_eq!(price_on(2 * HOUR_MS, TESTNET_EPOCH_S).1, 2); + assert_eq!(price_on(365 * DAY_MS, TESTNET_EPOCH_S).1, EPOCHS_PER_ERA); } #[test] @@ -228,10 +230,9 @@ mod tests { // The price per byte comes from the schedule's own period: a network of one-hour // epochs prices a year like mainnet does. for lifetime_ms in [HOUR_MS, 3 * DAY_MS, 8 * DAY_MS, 30 * DAY_MS, 365 * DAY_MS] { - let ttl_seconds = u32::try_from(lifetime_ms / 1000).expect("fits"); assert_eq!( - price_on(lifetime_ms, ttl_seconds, TESTNET_EPOCH_S).0, - price_on(lifetime_ms, ttl_seconds, MAINNET_EPOCH_S).0, + price_on(lifetime_ms, TESTNET_EPOCH_S).0, + price_on(lifetime_ms, MAINNET_EPOCH_S).0, "{lifetime_ms} ms" ); } @@ -241,7 +242,7 @@ mod tests { fn should_never_price_a_longer_lifetime_below_a_shorter_one() { let mut previous = 0; for lifetime_ms in (0..=400 * DAY_MS).step_by((HOUR_MS / 2) as usize) { - let (credit_per_byte, _) = price_on(lifetime_ms, WEEK_S, MAINNET_EPOCH_S); + let (credit_per_byte, _) = price(lifetime_ms); assert!( credit_per_byte >= previous, "{lifetime_ms} ms costs {credit_per_byte}, less than {previous}" diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract_operations/v1/mod.rs index ce576091d11..a3a1028bdfb 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract_operations/v1/mod.rs @@ -366,8 +366,8 @@ impl Drive { let pricing = document_ttl_pricing( document_remaining_lifetime_ms(created_at, ttl_seconds, block_info.time_ms)?, - ttl_seconds, self.config.epoch_time_length_s, + self.config.epochs_per_era, &platform_version.fee_version, )?; let mut batch_operations: Vec = batch_operations diff --git a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs index 7b65aa05712..21c15e17e83 100644 --- a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs @@ -922,8 +922,8 @@ impl Drive { ttl_seconds, block_info.time_ms, )?, - ttl_seconds, self.config.epoch_time_length_s, + self.config.epochs_per_era, &platform_version.fee_version, )?; batch_operations = batch_operations diff --git a/packages/rs-drive/src/drive/initialization/v4/mod.rs b/packages/rs-drive/src/drive/initialization/v4/mod.rs index f8682140c7a..7e3475fb916 100644 --- a/packages/rs-drive/src/drive/initialization/v4/mod.rs +++ b/packages/rs-drive/src/drive/initialization/v4/mod.rs @@ -104,11 +104,13 @@ impl Drive { // sequence of inserts on both node populations. self.insert_contract_fee_pot_trees(transaction, platform_version)?; - // Documents expirations tree (protocol version 14): under `Misc`, it indexes every - // document of a type declaring a `ttl` by the time it expires. After the batch apply, - // which creates `Misc`, and through the same helper as the upgrade path - // (`Platform::transition_to_version_14`), in the same position: last. - self.insert_documents_expirations_tree(transaction, platform_version)?; + // Document time to live trees (protocol version 14): the documents expirations tree + // under `Misc`, which indexes every document of a type declaring a `ttl` by the time + // it expires, and the lifetime storage fee pools sum tree under `Pools`, which holds + // their storage fees until an epoch change spreads them. After the batch apply, which + // creates `Misc` and the fee pools under `Pools`, and through the same helper as the + // upgrade path (`Platform::transition_to_version_14`), in the same position: last. + self.insert_document_ttl_trees(transaction, platform_version)?; Ok(()) } diff --git a/packages/rs-drive/src/fees/op.rs b/packages/rs-drive/src/fees/op.rs index 20aa281f34d..e8434cdc4f7 100644 --- a/packages/rs-drive/src/fees/op.rs +++ b/packages/rs-drive/src/fees/op.rs @@ -30,7 +30,7 @@ use crate::util::storage_flags::StorageFlags; use dpp::block::epoch::Epoch; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::fee::fee_result::refunds::FeeRefunds; -use dpp::fee::fee_result::FeeResult; +use dpp::fee::fee_result::{FeeResult, LifetimeStorageFees}; use dpp::fee::Credits; use platform_version::version::fee::FeeVersion; @@ -254,16 +254,15 @@ pub enum EphemeralPricing { TimeRangeTtl, /// The writes of a document whose type declares a `ttl`: every added /// byte costs `credit_per_byte`, resolved from the fee schedule's - /// `document_ttl` group for the document's remaining lifetime, into the - /// storage fee distribution pool when `storage_pool`, else into the - /// current epoch's processing fees. + /// `document_ttl` group for the document's remaining lifetime, as a + /// storage fee paid out over the `lifetime_epochs` epochs the document + /// has left to live (see `FeeResult::lifetime_storage_fees`). DocumentTtl { /// Credits per added byte credit_per_byte: Credits, - /// Whether the amount enters the storage fee pool (a document type - /// whose `ttl` spans at least the schedule's - /// `processing_route_below_epochs`) rather than the processing fees - storage_pool: bool, + /// The epochs the document has left to live, at least 1 and at + /// most one era + lifetime_epochs: u16, }, } @@ -327,35 +326,27 @@ impl LowLevelDriveOperation { }), CalculatedEphemeralCostOperation(cost, EphemeralPricing::DocumentTtl { credit_per_byte, - storage_pool, + lifetime_epochs, }) => { // The writes of a document whose type declares a `ttl`: // each added byte costs the price of the document's - // remaining lifetime, into the storage pool or the - // processing fees as its type's `ttl` decides. - // Processing is billed as for any batch. Added in - // place in this shipped generation: only a document - // type parsed from the `ttl` keyword, which no - // protocol version before 14 reads, is tagged - // `DocumentTtl`, so no earlier version reaches this arm. - let bytes_fee = (cost.storage_cost.added_bytes as u64) + // remaining lifetime, as a storage fee the pools pay + // out over the epochs it has left to live. Processing + // is billed as for any batch. Added in place in this + // shipped generation: only a document type parsed from + // the `ttl` keyword, which no protocol version before + // 14 reads, is tagged `DocumentTtl`, so no earlier + // version reaches this arm. + let storage_fee = (cost.storage_cost.added_bytes as u64) .checked_mul(credit_per_byte) .ok_or(Error::Fee(FeeError::Overflow( "overflow pricing the bytes of a document with a time to live", )))?; - let ephemeral_cost = cost.ephemeral_cost(fee_version)?; - let (storage_fee, processing_fee) = if storage_pool { - (bytes_fee, ephemeral_cost) + let processing_fee = cost.ephemeral_cost(fee_version)?; + let lifetime_storage_fees = if storage_fee > 0 { + LifetimeStorageFees::from([(lifetime_epochs, storage_fee)]) } else { - ( - 0, - ephemeral_cost.checked_add(bytes_fee).ok_or(Error::Fee( - FeeError::Overflow( - "overflow adding the bytes fee of a document with a time \ - to live", - ), - ))?, - ) + LifetimeStorageFees::new() }; // The elements of such a document carry no storage flags, // so removals are basic. A sectioned (refundable) removal @@ -369,6 +360,7 @@ impl LowLevelDriveOperation { processing_fee, fee_refunds: FeeRefunds::default(), removed_bytes_from_system, + lifetime_storage_fees, }) } CalculatedEphemeralCostOperation(cost, EphemeralPricing::TimeRangeTtl) => { @@ -409,6 +401,7 @@ impl LowLevelDriveOperation { processing_fee, fee_refunds: FeeRefunds::default(), removed_bytes_from_system, + lifetime_storage_fees: Default::default(), }) } _ => { @@ -458,6 +451,7 @@ impl LowLevelDriveOperation { processing_fee, fee_refunds, removed_bytes_from_system, + lifetime_storage_fees: Default::default(), }) } }) diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index 3022c6e9ec4..5102d8c3e41 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -272,6 +272,7 @@ mod walker { mod fixtures { use super::*; use crate::drive::credit_pools::epochs::operations_factory::EpochOperations; + use crate::drive::credit_pools::operations::update_lifetime_storage_fee_pool_operation; use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; use crate::drive::Drive; use crate::fees::op::LowLevelDriveOperation; @@ -1556,8 +1557,8 @@ mod fixtures { } /// Documents of a type declaring a `ttl`: stored without storage flags, each with an entry - /// in the documents expirations tree under the time it expires. Two of them expire - /// together, created in the same block. + /// in the documents expirations tree under the time it expires, and a lifetime storage fee + /// pool. Two of them expire together, created in the same block. fn expiring_documents(run: &mut FixtureRun) { let platform_version = PlatformVersion::latest(); let drive = setup_drive_with_initial_state_structure(Some(platform_version)); @@ -1635,6 +1636,16 @@ mod fixtures { ) .expect("expected to add the document"); } + // Their storage fees, waiting in the pool of the epochs they live for the next epoch + // change. + let mut batch = GroveDbOpBatch::new(); + batch.push( + update_lifetime_storage_fee_pool_operation(0, 2, 1_000) + .expect("expected the lifetime pool operation"), + ); + drive + .grove_apply_batch(batch, false, None, &platform_version.drive) + .expect("expected to fill a lifetime pool"); conformance_of(&drive, "expiring_documents", run); } diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs index 6cca4a97d9b..95493a875cf 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs @@ -81,7 +81,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet verify_recent_signature_locally: 0, }, fee_pool_inwards_distribution: DriveAbciFeePoolInwardsDistributionMethodVersions { - add_distribute_block_fees_into_pools_operations: 0, + add_distribute_block_fees_into_pools_operations: 1, // changed in v14: document ttl storage fees go to the lifetime storage fee pools add_distribute_storage_fee_to_epochs_operations: 1, // changed in v14: claws each pending refund back from the epochs it was priced for }, fee_pool_outwards_distribution: DriveAbciFeePoolOutwardsDistributionMethodVersions { diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_credit_pool_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_credit_pool_method_versions/mod.rs index ffd0eba7c23..cf5d1ecb456 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_credit_pool_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_credit_pool_method_versions/mod.rs @@ -51,4 +51,7 @@ pub struct DriveCreditPoolPendingEpochRefundsMethodVersions { #[derive(Clone, Debug, Default)] pub struct DriveCreditPoolStorageFeeDistributionPoolMethodVersions { pub get_storage_fees_from_distribution_pool: FeatureVersion, + /// Reads the lifetime storage fee pools (protocol version 14, document time to live); + /// unread before 14. + pub fetch_lifetime_storage_fee_pools: FeatureVersion, } diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_credit_pool_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_credit_pool_method_versions/v1.rs index 547bd4611b0..53efc21c042 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_credit_pool_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_credit_pool_method_versions/v1.rs @@ -39,6 +39,7 @@ pub const CREDIT_POOL_METHOD_VERSIONS_V1: DriveCreditPoolMethodVersions = }, storage_fee_distribution_pool: DriveCreditPoolStorageFeeDistributionPoolMethodVersions { get_storage_fees_from_distribution_pool: 0, + fetch_lifetime_storage_fee_pools: 0, }, unpaid_epoch: DriveCreditPoolUnpaidEpochMethodVersions { get_unpaid_epoch_index: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs index be90fe6ebcb..3a5e75b0d83 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs @@ -25,7 +25,7 @@ pub struct DriveDocumentMethodVersions { /// 0 in every table. #[derive(Clone, Debug, Default)] pub struct DriveDocumentExpirationMethodVersions { - pub insert_documents_expirations_tree: FeatureVersion, + pub insert_document_ttl_trees: FeatureVersion, pub add_document_expiration_operations: FeatureVersion, pub remove_document_expiration_operations: FeatureVersion, pub fetch_expired_documents: FeatureVersion, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs index 7fb00568171..007da0f795c 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs @@ -88,7 +88,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V1: DriveDocumentMethodVersions = }, primary_key_tree_type: 0, expiration: DriveDocumentExpirationMethodVersions { - insert_documents_expirations_tree: 0, + insert_document_ttl_trees: 0, add_document_expiration_operations: 0, remove_document_expiration_operations: 0, fetch_expired_documents: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs index 7c90ceb5f46..f0977e27dba 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs @@ -122,7 +122,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V2: DriveDocumentMethodVersions = // re-prove v0 ≡ v1 for every pre-v12 corner case. primary_key_tree_type: 0, expiration: DriveDocumentExpirationMethodVersions { - insert_documents_expirations_tree: 0, + insert_document_ttl_trees: 0, add_document_expiration_operations: 0, remove_document_expiration_operations: 0, fetch_expired_documents: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs index 33a4804f995..cd31af3d96e 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs @@ -119,7 +119,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V3: DriveDocumentMethodVersions = // tables (see V2's comment for the freeze rationale). primary_key_tree_type: 1, expiration: DriveDocumentExpirationMethodVersions { - insert_documents_expirations_tree: 0, + insert_document_ttl_trees: 0, add_document_expiration_operations: 0, remove_document_expiration_operations: 0, fetch_expired_documents: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs index d96dc74c78e..2969e926e75 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs @@ -179,7 +179,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = // count/sum composition rationale. primary_key_tree_type: 1, expiration: DriveDocumentExpirationMethodVersions { - insert_documents_expirations_tree: 0, + insert_document_ttl_trees: 0, add_document_expiration_operations: 0, remove_document_expiration_operations: 0, fetch_expired_documents: 0, diff --git a/packages/rs-platform-version/src/version/fee/document_ttl/mod.rs b/packages/rs-platform-version/src/version/fee/document_ttl/mod.rs index 80ba49c3311..3d627b5ca48 100644 --- a/packages/rs-platform-version/src/version/fee/document_ttl/mod.rs +++ b/packages/rs-platform-version/src/version/fee/document_ttl/mod.rs @@ -25,11 +25,10 @@ pub struct DocumentTtlFeeTier { /// `credit_per_byte_per_period` for every `pricing_period_seconds` it spans, rounded up. /// The period is part of the schedule, not the node's epoch length, so a network with /// short epochs (testnet, local networks) prices a lifetime like mainnet does. -/// * A document of a type whose `ttl` is shorter than `processing_route_below_epochs` -/// epochs of the network pays that amount into the current epoch's processing fee pool; -/// one of a longer `ttl` pays it into the storage fee distribution pool, like ordinary -/// storage. The route follows the declared `ttl`, not the lifetime left, so every write -/// of a document, and an estimate of it made at an earlier block time, takes one route. +/// * That amount is storage, paid out over the epochs the document lives in (at most one +/// era) rather than by the perpetual storage distribution: the block's storage fees for +/// each lifetime collect in the lifetime storage fee pools, which every epoch change +/// spreads evenly over the epochs of their lifetime. /// * A document created with a `ttl` also prepays its deletion as processing: /// `cleanup_base_processing_cost`, plus `cleanup_processing_cost_per_index_level` for /// every index level of its document type (each index counts its properties, times the @@ -47,9 +46,6 @@ pub struct FeeDocumentTtlVersion { pub credit_per_byte_per_period: u64, /// The length, in seconds, of the period `credit_per_byte_per_period` prices. pub pricing_period_seconds: u32, - /// Documents of a type whose `ttl` is shorter than this many epochs pay their storage - /// into the processing pool. - pub processing_route_below_epochs: u16, /// Prepaid processing of a document's deletion, charged once when it is created. pub cleanup_base_processing_cost: u64, /// Prepaid processing per index level of the document type, charged once on creation. diff --git a/packages/rs-platform-version/src/version/fee/document_ttl/v1.rs b/packages/rs-platform-version/src/version/fee/document_ttl/v1.rs index bda23d54b81..bc22469e1f2 100644 --- a/packages/rs-platform-version/src/version/fee/document_ttl/v1.rs +++ b/packages/rs-platform-version/src/version/fee/document_ttl/v1.rs @@ -35,7 +35,6 @@ pub const FEE_DOCUMENT_TTL_VERSION1: FeeDocumentTtlVersion = FeeDocumentTtlVersi ], credit_per_byte_per_period: 34, pricing_period_seconds: 788_400, // 9.125 days, mainnet's epoch length - processing_route_below_epochs: 2, // Measured on protocol version 14 (drive `delete_document_for_contract` of a document of // a type with a `ttl`, averaged over ten documents): about 1.53M credits of processing with // one index level, 1.94M with two and 2.39M with four. Base plus per level covers each. diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 221475de4b2..7775521ccf3 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1261,7 +1261,8 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// tree under `Misc` (created by `create_initial_state_structure` 4 and /// `transition_to_version_14`), and pay the `document_ttl` group of `FEE_VERSION3`: a /// price per byte for the time they live (tiers up to seven days, then per 9.125 days), -/// into the processing fees for a `ttl` under two epochs and the storage pool otherwise, +/// paid out to the epochs they live in (at most one era) through the lifetime storage fee +/// pools under `Pools` (`add_distribute_block_fees_into_pools_operations` 1), /// plus their deletion prepaid as processing (per index level and per document byte; a /// change that grows a document prepays its added bytes). From its expiry on, a /// document can no longer be replaced, transferred, bought, repriced or restored by a From 50b9fb1daafcd4eb60f3c6a78a783a95a189d94c Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 15:36:50 +0700 Subject: [PATCH 018/113] feat(platform)!: string equality for enums in propertyConstraints rules (PV14) (#5042) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/documents.md | 14 +- packages/js-evo-sdk/README.md | 9 +- .../document/v3/document-meta.json | 29 +- .../class_methods/try_from_schema/mod.rs | 75 ++++- .../v3/property_constraints_tests.rs | 112 +++++++- .../src/data_contract/document_type/mod.rs | 7 +- .../document_type/property_constraints/mod.rs | 264 ++++++++++++++++-- .../property_constraints/tests.rs | 189 +++++++++++++ .../src/data_contract/document_type/v2/mod.rs | 13 +- .../tests/document/property_constraints.rs | 79 +++++- .../src/version/system_limits/mod.rs | 8 +- .../rs-platform-version/src/version/v14.rs | 58 ++-- 12 files changed, 760 insertions(+), 97 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index f9d6d3679fd..03dd117afa7 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -680,7 +680,7 @@ The check runs where the JSON schema validation of a document's properties runs, ## Property Constraints (`propertyConstraints`) -Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named rules the properties of every created or replaced document must meet, where JSON Schema can only bound one property at a time. Each rule is a condition: a comparison of two integer expressions, a test of whether an integer expression takes one of listed values, a test of whether the document holds a property, or `anyOf`, `allOf` or `not` over conditions. +Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named rules the properties of every created or replaced document must meet, where JSON Schema can only bound one property at a time. Each rule is a condition: a comparison of two integer expressions, a test of whether an integer expression takes one of listed values, a comparison of a string property with string constants, a test of whether the document holds a property, or `anyOf`, `allOf` or `not` over conditions. ```json "propertyConstraints": { @@ -700,14 +700,18 @@ Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named "discountGivenAboveZero": { "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] }, - "tieredFee": { "in": ["fee", [0, 10, 25, 50]] } + "tieredFee": { "in": ["fee", [0, 10, 25, 50]] }, + "closedNeedsClosedAt": { + "anyOf": [{ "notEqual": ["status", { "const": "closed" }] }, { "present": "closedAt" }] + } } ``` -The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeastTen` reads `fee == 0 || fee >= 10`, `discountGivenAboveZero` lets an offer leave its discount out but not give a discount of 0, and `tieredFee` holds the fee to four tiers. A rule's name is 1 to 64 letters, digits or underscores, and the rule is a condition, an object with one key: +The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeastTen` reads `fee == 0 || fee >= 10`, `discountGivenAboveZero` lets an offer leave its discount out but not give a discount of 0, `tieredFee` holds the fee to four tiers, and `closedNeedsClosedAt` says a closed offer carries a `closedAt`. A rule's name is 1 to 64 letters, digits or underscores, and the rule is a condition, an object with one key: - a comparison, `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` or `greaterThanOrEqual`, listing the left and the right expression; - `{ "in": [expression, [values]] }`, holding if the integer expression takes one of two or more distinct integer values. It says what an `anyOf` of `equal`s says, in one node per value instead of three, so a set of up to 30 values fits the node limit where the `anyOf` fits 10. A value is a literal, never a path or an expression; +- a string comparison: `{ "equal": [path, { "const": "closed" }] }` or `notEqual`, with the constant on either side, or `{ "in": [path, ["open", "pending"]] }`, whose values are two or more distinct strings. The path names a string property, typically one with an `enum`. A string on its own is a path, so a constant is written as `{ "const": ... }`, while the values an `in` lists are literals and need no wrapper. Strings are only compared for equality, never ordered or used in arithmetic. A string property the document leaves out equals no constant, so `notEqual` holds for it and `equal` and `in` do not; `present` and `absent` test it directly. When the property declares an `enum`, every constant compared with it must be one of the enum's values, so a misspelling is refused at registration rather than making the rule quietly never hold; - `{ "present": path }`, holding if the document holds the property, and `{ "absent": path }`, holding if it leaves it out (a property set to null counts as left out). An operand reads a property the document leaves out as 0, so only these tell "not given" from "given as 0". They may name a property of any type, an object or a member of one included, since they read no value; - `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; - `{ "allOf": [...] }`, holding if every one of two or more conditions holds; @@ -727,11 +731,11 @@ The arithmetic is exact over `i128`. Operands are evaluated left to right, and e Conditions are checked in declared order and no further than the outcome needs: a comparison evaluates its left side, then its right; `anyOf` stops at the first condition that holds and `allOf` at the first that fails. A fault in a condition that is checked breaks the rule whatever the others would say, and `not` does not turn it into a pass. So an earlier condition guards a later one: `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` holds for a `b` of 0 without dividing by it, while the same two conditions the other way round divide by zero and break the rule. -The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `present` or `absent`, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. +The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type, every path compared with a string names a string property (and every constant compared with one that declares an `enum` is one of its values) (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `const`, every `present` or `absent`, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. Transfers, purchases and price updates change no property and are not judged. -In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, an `in`, a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and whether by value or by presence), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. +In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, an `in`, a string comparison (`TextCompare`, `TextIn`), a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and whether by value or by presence), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. ## Rules and Guidelines diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 177745675aa..36eedcf5339 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -399,7 +399,7 @@ try { ## Property constraints (`propertyConstraints`) -From protocol version 14 a document type can declare rules its documents' properties must meet, each a comparison of two integer expressions built from property paths and integer values, an `in` list of values, a `present` or `absent` test, or `anyOf`, `allOf` or `not` over such conditions: +From protocol version 14 a document type can declare rules its documents' properties must meet, each a comparison of two integer expressions built from property paths and integer values, an `in` list of values, a comparison of a string property with string constants, a `present` or `absent` test, or `anyOf`, `allOf` or `not` over such conditions: ```json "propertyConstraints": { @@ -418,11 +418,14 @@ From protocol version 14 a document type can declare rules its documents' proper "discountGivenAboveZero": { "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] }, - "tieredFee": { "in": ["fee", [0, 10, 25, 50]] } + "tieredFee": { "in": ["fee", [0, 10, 25, 50]] }, + "closedNeedsClosedAt": { + "anyOf": [{ "notEqual": ["status", { "const": "closed" }] }, { "present": "closedAt" }] + } } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 4c32046fdeb..62198d6a458 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's properties, comparisons between integer expressions, in (value membership) and present or absent tests combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's properties, comparisons between integer expressions or of a string property with const strings, in (value membership) and present or absent tests combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "referenceOperands": { @@ -40,7 +40,7 @@ } }, "propertyConstraint": { - "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right, in listing an integer expression and the values it may take, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", + "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right (equal and notEqual may instead compare the path of a string property with a const string), in listing an expression and the values it may take, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", "type": "object", "properties": { "equal": { @@ -62,7 +62,7 @@ "$ref": "#/$defs/propertyConstraintOperandPair" }, "in": { - "description": "Holds if the integer expression listed first takes one of the integer values listed second: two or more, no two alike. It says what an anyOf of equal comparisons says, in one node per value rather than three", + "description": "Holds if the expression listed first takes one of the values listed second: two or more, no two alike, all integers or all strings. With integers the expression is any integer expression; with strings it is the path of a string property, which one the document leaves out holds none of. It says what an anyOf of equal comparisons says, in one node per value rather than three", "type": "array", "prefixItems": [ { @@ -70,9 +70,18 @@ }, { "type": "array", - "items": { - "type": "integer" - }, + "anyOf": [ + { + "items": { + "type": "integer" + } + }, + { + "items": { + "type": "string" + } + } + ], "minItems": 2, "uniqueItems": true } @@ -127,7 +136,7 @@ "uniqueItems": true }, "propertyConstraintExpression": { - "description": "An integer expression of a propertyConstraints rule: an integer value; the dotted path of an integer or boolean property of the document type, whose value it takes (1 for true, 0 for false), 0 when the document leaves it out; or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two", + "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const; or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; const, a string constant compared with a string property", "type": [ "integer", "string", @@ -175,6 +184,10 @@ }, "power": { "$ref": "#/$defs/propertyConstraintOperandPair" + }, + "const": { + "description": "A string constant, only as one side of an equal or notEqual whose other side is the path of a string property: a string on its own is a path, and an integer is written as itself", + "type": "string" } }, "minProperties": 1, @@ -2051,7 +2064,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, in listing an integer expression and two or more distinct integer values it may take, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round), in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. A string property the document leaves out equals no constant, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index c9ac1d4d861..0ffb348a91c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -1950,6 +1950,7 @@ fn apply_property_constraints_v0( let reads = match read { PropertyRead::Value => "reads", PropertyRead::Presence => "tests the presence of", + PropertyRead::Text => "compares", }; match read { PropertyRead::Value => match document_type @@ -1967,6 +1968,14 @@ fn apply_property_constraints_v0( | DocumentPropertyType::I128 | DocumentPropertyType::Boolean ) => {} + // A string is compared with constants, never read as a number + Some(DocumentPropertyType::String(_)) => { + return Err(structure_error(format!( + "rule \"{name}\" reads \"{path}\", which has type string, not integer \ + or boolean: a string property is compared with a {{ \"const\": ... }} \ + by equal or notEqual, or with the strings an in lists" + ))); + } Some(other) => { return Err(structure_error(format!( "rule \"{name}\" reads \"{path}\", which has type {}, not integer or \ @@ -1992,6 +2001,27 @@ fn apply_property_constraints_v0( ))); } } + PropertyRead::Text => match document_type + .flattened_properties + .get(path) + .map(|property| &property.property_type) + { + Some(DocumentPropertyType::String(_)) => {} + Some(other) => { + return Err(structure_error(format!( + "rule \"{name}\" compares \"{path}\" with a string, but it has type \ + {}, not string", + other.name() + ))); + } + None => { + return Err(structure_error(format!( + "rule \"{name}\" compares \"{path}\" with a string, but it is not a \ + string property of the document type (a nested one is named by its \ + dotted path)" + ))); + } + }, } if is_transient(DocumentTypeRef::V2(document_type), path) { return Err(structure_error(format!( @@ -2001,6 +2031,25 @@ fn apply_property_constraints_v0( ))); } } + // A constant a string property's `enum` does not list is a typo: the + // property could never hold it + for (path, constant) in constraint.text_constants() { + let Some(property_schema) = schema_at_path(&document_type.schema, path)? else { + continue; + }; + let Some(Value::Array(members)) = property_schema.get(property_names::ENUM) else { + continue; + }; + if !members + .iter() + .any(|member| member.as_text() == Some(constant)) + { + return Err(structure_error(format!( + "rule \"{name}\" compares \"{path}\" with \"{constant}\", which is not one of \ + its enum values" + ))); + } + } } if full_validation { @@ -2032,11 +2081,12 @@ fn apply_property_constraints_v0( Ok(()) } -/// Whether the property at the dotted `path` of `schema` is declared as an -/// integer with `minimum` at least 0 and `maximum` at most `u32::MAX`, read -/// from the schema rather than from the parsed type so that the answer does -/// not depend on the contract's `sizedIntegerTypes`. `$ref`s are followed. -fn is_key_id_schema(schema: &Value, path: &str) -> Result { +/// The schema of the property at the dotted `path` of `schema`, a document +/// type's, `None` when the path names none. `$ref`s are followed. +fn schema_at_path<'a>( + schema: &'a Value, + path: &str, +) -> Result>, DataContractError> { fn resolve<'a>( root_schema: &'a Value, value: &'a Value, @@ -2052,13 +2102,24 @@ fn is_key_id_schema(schema: &Value, path: &str) -> Result Result { + let Some(current) = schema_at_path(schema, path)? else { + return Ok(false); + }; let is_integer = current.get_optional_str(property_names::TYPE)? == Some("integer"); let minimum = current.get_optional_integer::(property_names::MINIMUM)?; let maximum = current.get_optional_integer::(property_names::MAXIMUM)?; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index 9bacd4f99c9..9a60d278ad8 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -13,6 +13,7 @@ use crate::consensus::basic::basic_error::BasicError; use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; +use crate::data_contract::document_type::property_constraints::PropertyRead; use crate::data_contract::methods::validate_update::DataContractUpdateValidationMethodsV0; use crate::data_contract::DataContract; use crate::serialization::{ @@ -25,8 +26,8 @@ use serde_json::json; /// An `order` type: four required integers, an optional nested `meta` object /// with an integer `total`, a string, a number, a typed array and an integer -/// `code` to be refused as operands or listed as transient, and a boolean -/// `rush`. +/// `code` to be refused as operands or listed as transient, a boolean `rush` +/// and a string `state` with an `enum`. fn order_schema(rules: Option, transient: Option<&str>) -> serde_json::Value { let mut schema = json!({ "type": "object", @@ -54,7 +55,13 @@ fn order_schema(rules: Option, transient: Option<&str>) -> se "maxItems": 4, "position": 8 }, - "rush": { "type": "boolean", "position": 9 } + "rush": { "type": "boolean", "position": 9 }, + "state": { + "type": "string", + "enum": ["open", "closed"], + "maxLength": 10, + "position": 10 + } }, "required": ["price", "fee", "quantity", "deposit"], "additionalProperties": false @@ -237,6 +244,100 @@ fn should_let_an_operand_read_a_boolean_property() { } } +/// A string property is compared with `const` strings by `equal` and +/// `notEqual`, or with the strings an `in` lists, on both paths; a nested one by +/// its dotted path, one without an `enum` with any string. +#[test] +fn should_compare_a_string_property_with_constants() { + let rules = json!({ + "closedHasTotal": { + "anyOf": [ + { "notEqual": ["state", { "const": "closed" }] }, + { "present": "meta.total" } + ] + }, + "knownNote": { "in": ["note", ["a", "b"]] }, + "tagged": { "equal": [{ "const": "x" }, "meta.tag"] } + }); + for full_validation in [true, false] { + let document_type = parse_order(rules.clone(), full_validation) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let constraints = document_type.property_constraints(); + assert_eq!( + constraints["closedHasTotal"].property_reads(), + [ + ("state", PropertyRead::Text), + ("meta.total", PropertyRead::Presence) + ] + ); + assert_eq!(constraints["knownNote"].property_paths(), ["note"]); + assert_eq!(constraints["tagged"].property_paths(), ["meta.tag"]); + } +} + +/// A constant compared with a property that declares an `enum` must be one of +/// its values, or the property could never hold it; the property must be a +/// string, and not a transient one. On both paths. +#[test] +fn should_hold_string_comparisons_to_string_properties_and_their_enums() { + for (rules, needle) in [ + ( + json!({ "rule": { "equal": ["state", { "const": "closd" }] } }), + "rule \"rule\" compares \"state\" with \"closd\", which is not one of its enum values", + ), + ( + json!({ "rule": { "in": ["state", ["open", "shut"]] } }), + "rule \"rule\" compares \"state\" with \"shut\", which is not one of its enum values", + ), + ( + json!({ "rule": { "equal": ["price", { "const": "x" }] } }), + "rule \"rule\" compares \"price\" with a string, but it has type", + ), + ( + json!({ "rule": { "in": ["rush", ["yes", "no"]] } }), + "rule \"rule\" compares \"rush\" with a string, but it has type boolean, not string", + ), + ( + json!({ "rule": { "notEqual": ["missing", { "const": "x" }] } }), + "rule \"rule\" compares \"missing\" with a string, but it is not a string property", + ), + ( + json!({ "rule": { "equal": ["meta", { "const": "x" }] } }), + "rule \"rule\" compares \"meta\" with a string, but it is not a string property", + ), + // A string read as a number points at the string forms + ( + json!({ "rule": { "equal": ["state", "price"] } }), + "rule \"rule\" reads \"state\", which has type string, not integer or boolean: a \ + string property is compared with a { \"const\": ... }", + ), + ( + json!({ "rule": { "lessThan": ["state", { "const": "open" }] } }), + "rule \"rule\" at lessThan compares a string constant, which only equal and notEqual \ + do", + ), + ] { + for full_validation in [true, false] { + expect_structure_error(parse_order(rules.clone(), full_validation), needle); + } + } + + let schema = order_schema( + Some(json!({ "rule": { "equal": ["note", { "const": "x" }] } })), + Some("note"), + ); + for full_validation in [true, false] { + expect_structure_error( + parse_dispatched( + schema_value(schema.clone()), + PlatformVersion::latest(), + full_validation, + ), + "rule \"rule\" compares \"note\", which is transient or inside a transient object", + ); + } +} + /// A system property is not a property of the type: the meta-schema refuses /// its `$` when registering, and the parser the path when reading. #[test] @@ -637,6 +738,10 @@ fn should_check_the_grammar_with_the_meta_schema_and_the_parser() { json!({ "rule": { "in": ["price", [1, "fee"]] } }), json!({ "rule": { "in": ["price", [1, 2], 3] } }), json!({ "rule": { "in": ["price", 1] } }), + json!({ "rule": { "equal": ["state", { "const": 5 }] } }), + json!({ "rule": { "in": ["state", ["open", 2]] } }), + json!({ "rule": { "in": ["state", ["open"]] } }), + json!({ "rule": { "in": ["state", ["open", "open"]] } }), ] { let registered = parse_order(rules.clone(), true); assert!( @@ -685,6 +790,7 @@ fn should_refuse_property_constraints_before_protocol_version_14_and_ignore_them .expect("the properties") .remove("counts"); schema["properties"]["rush"]["position"] = json!(8); + schema["properties"]["state"]["position"] = json!(9); let schema = schema_value(schema); let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13"); diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 8d214ec9786..11cb7470224 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -117,9 +117,10 @@ pub(crate) mod property_names { /// See `parse_doctype_reference` in `try_from_schema`. pub const CREATOR_REFERS_TO: &str = "creatorRefersTo"; /// Doctype-level object of named rules, each a condition on the document's - /// properties (a comparison of two integer expressions, an `in` list of - /// values, a `present` or `absent` test, or an `anyOf`, `allOf` or `not` of - /// conditions) that every created or replaced document must meet. + /// properties (a comparison of two integer expressions or of a string + /// property with string constants, an `in` list of values, a `present` or + /// `absent` test, or an `anyOf`, `allOf` or `not` of conditions) that every + /// created or replaced document must meet. /// Meta-schema v3+ (protocol version 14). See `parse_property_constraints` /// in `property_constraints`. pub const PROPERTY_CONSTRAINTS: &str = "propertyConstraints"; diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index 04d46aa4828..fb6eba70325 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -2,8 +2,9 @@ //! version 14): named rules every document of the type must meet, each a //! condition on the document's properties: a comparison of two integer //! expressions, a test of whether an integer expression takes one of listed -//! values (`in`), a test of whether the document holds a property (`present`, -//! `absent`), or `anyOf`, `allOf` or `not` over conditions. +//! values (`in`), a comparison of a string property with string constants +//! (`equal`, `notEqual`, `in`), a test of whether the document holds a property +//! (`present`, `absent`), or `anyOf`, `allOf` or `not` over conditions. //! //! ```json //! "propertyConstraints": { @@ -23,7 +24,10 @@ //! "discountGivenAboveZero": { //! "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] //! }, -//! "tieredFee": { "in": ["fee", [0, 10, 25, 50]] } +//! "tieredFee": { "in": ["fee", [0, 10, 25, 50]] }, +//! "closedNeedsClosedAt": { +//! "anyOf": [{ "notEqual": ["status", { "const": "closed" }] }, { "present": "closedAt" }] +//! } //! } //! ``` //! @@ -31,7 +35,10 @@ //! property (a boolean reads as 1 for true and 0 for false), or an object with //! one key: an arithmetic operator over its operands, or `ifAbsent`, a //! property with the value it takes when the document leaves it out. A -//! property named on its own takes 0 when absent. How the arithmetic +//! property named on its own takes 0 when absent. A string constant is written +//! `{ "const": "closed" }`, since a string on its own is a path; `equal` and +//! `notEqual` compare one with a string property, and an `in` whose values +//! are strings lists them bare. How the arithmetic //! treats overflow, division and powers is set out on //! [`ConstraintExpression::evaluate`], and how conditions combine on //! [`PropertyConstraint::holds`]. @@ -69,6 +76,8 @@ const NOT: &str = "not"; const PRESENT: &str = "present"; const ABSENT: &str = "absent"; const IN: &str = "in"; +/// The operand key of a string constant: `{ "const": "closed" }`. +const CONST: &str = "const"; /// Every key an operand object may hold, for the errors. const OPERAND_KEYS: &str = "add, subtract, multiply, divide, modulo, power or ifAbsent"; @@ -302,12 +311,15 @@ pub enum PropertyRead { /// Only whether the document holds it, in a `present` or `absent`: a /// property of any type, an object included. Presence, + /// By its value, compared with string constants: a string property. + Text, } /// A rule of `propertyConstraints`, or a condition inside one: a comparison of /// two integer expressions, a test of whether an integer expression takes one -/// of listed values, a test of whether the document holds a property, or -/// `anyOf`, `allOf` or `not` over conditions. +/// of listed values, a comparison of a string property with string constants, +/// a test of whether the document holds a property, or `anyOf`, `allOf` or +/// `not` over conditions. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PropertyConstraint { /// A comparison: the two sides must compare as `comparison` says. @@ -322,6 +334,22 @@ pub enum PropertyConstraint { operand: ConstraintExpression, values: BTreeSet, }, + /// `equal` or `notEqual` between the string property at the dotted path and + /// a string constant, `{ "equal": ["status", { "const": "closed" }] }`, + /// written either way round. `comparison` is `Equal` or `NotEqual`. A + /// string property the document leaves out equals no constant. + TextCompare { + comparison: ConstraintComparison, + path: String, + value: String, + }, + /// `in` over strings: the string property at the dotted path holds one of + /// two or more distinct string constants, `{ "in": ["status", ["open", + /// "pending"]] }`. One the document leaves out holds none of them. + TextIn { + path: String, + values: BTreeSet, + }, /// `present`: the document holds the property at the dotted path. One it /// leaves out, or sets to null, is absent, as it is for an operand. Unlike /// an operand, it tells a property left out from one set to 0, and it may @@ -344,10 +372,10 @@ impl PropertyConstraint { /// comparison evaluates its left side, then its right one; `anyOf` checks /// its conditions in declared order and holds at the first that holds; /// `allOf` fails at the first that fails; `not` inverts its condition; a - /// `present` or `absent` never faults. The first fault an evaluated - /// expression meets ([`ConstraintExpression::evaluate`]) is returned - /// whatever the conditions left unevaluated would say, and `not` never - /// turns a fault into a pass. So an earlier condition guards a later + /// string comparison, `present` or `absent` never faults. The first fault + /// an evaluated expression meets ([`ConstraintExpression::evaluate`]) is + /// returned whatever the conditions left unevaluated would say, and `not` + /// never turns a fault into a pass. So an earlier condition guards a later /// one: `anyOf: [{ equal: ["b", 0] }, { equal: [{ divide: ["a", "b"] }, 2] }]` /// holds for a `b` of 0 without dividing by it, while the same two /// conditions the other way round divide by zero. @@ -364,6 +392,17 @@ impl PropertyConstraint { PropertyConstraint::In { operand, values } => { Ok(values.contains(&operand.evaluate(data)?)) } + PropertyConstraint::TextCompare { + comparison, + path, + value, + } => { + let equal = text_value(data, path) == Some(value.as_str()); + Ok(equal == (*comparison == ConstraintComparison::Equal)) + } + PropertyConstraint::TextIn { path, values } => { + Ok(text_value(data, path).is_some_and(|text| values.contains(text))) + } PropertyConstraint::Present(path) => Ok(is_present(data, path)), PropertyConstraint::Absent(path) => Ok(!is_present(data, path)), PropertyConstraint::AnyOf(conditions) => { @@ -399,15 +438,19 @@ impl PropertyConstraint { /// The nodes of the rule, counted against /// `SystemLimits::max_property_constraint_nodes`: every comparison and - /// logical operator, every `in` and each value it lists, every `present` or - /// `absent` with the property it names, every arithmetic operator and every - /// operand (an integer value, or a property with or without `ifAbsent`). + /// logical operator, every `in` and each value it lists, every string + /// constant, every `present` or `absent` with the property it names, every + /// arithmetic operator and every operand (an integer value, or a property + /// with or without `ifAbsent`). pub fn node_count(&self) -> usize { 1 + match self { PropertyConstraint::Compare { left, right, .. } => { left.node_count() + right.node_count() } PropertyConstraint::In { operand, values } => operand.node_count() + values.len(), + // The property and the constant, as a comparison of a path with a value + PropertyConstraint::TextCompare { .. } => 2, + PropertyConstraint::TextIn { values, .. } => 1 + values.len(), PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => 0, PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { conditions.iter().map(PropertyConstraint::node_count).sum() @@ -433,6 +476,33 @@ impl PropertyConstraint { reads } + /// Every string constant the rule compares a property with, as the + /// property's dotted path and the constant, in declared order. + pub fn text_constants(&self) -> Vec<(&str, &str)> { + let mut constants = Vec::new(); + self.collect_text_constants(&mut constants); + constants + } + + fn collect_text_constants<'a>(&'a self, constants: &mut Vec<(&'a str, &'a str)>) { + match self { + PropertyConstraint::TextCompare { path, value, .. } => constants.push((path, value)), + PropertyConstraint::TextIn { path, values } => { + constants.extend(values.iter().map(|value| (path.as_str(), value.as_str()))) + } + PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { + for condition in conditions { + condition.collect_text_constants(constants); + } + } + PropertyConstraint::Not(condition) => condition.collect_text_constants(constants), + PropertyConstraint::Compare { .. } + | PropertyConstraint::In { .. } + | PropertyConstraint::Present(_) + | PropertyConstraint::Absent(_) => {} + } + } + /// Where an `anyOf` or `allOf` of the rule lists the same condition twice: /// the repeat's place and the earlier one's (`anyOf[2]` and `anyOf[0]`), /// the first found in declared order, `None` when no list does. Conditions @@ -452,6 +522,8 @@ impl PropertyConstraint { let (key, conditions) = match self { PropertyConstraint::Compare { .. } | PropertyConstraint::In { .. } + | PropertyConstraint::TextCompare { .. } + | PropertyConstraint::TextIn { .. } | PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => return None, PropertyConstraint::AnyOf(conditions) => (ANY_OF, conditions), @@ -490,6 +562,8 @@ impl PropertyConstraint { right.collect_property_reads(reads); } PropertyConstraint::In { operand, .. } => operand.collect_property_reads(reads), + PropertyConstraint::TextCompare { path, .. } + | PropertyConstraint::TextIn { path, .. } => reads.push((path, PropertyRead::Text)), PropertyConstraint::Present(path) | PropertyConstraint::Absent(path) => { reads.push((path, PropertyRead::Presence)) } @@ -511,7 +585,9 @@ impl PropertyConstraint { /// object of one or more rules, each named with 1 to 64 letters, digits or /// underscores and holding one condition. A condition is an object with one /// key: a comparison of exactly two operands, `in` with an operand and a list -/// of two or more distinct integer values, `present` or `absent` with a +/// of two or more distinct integer values, `equal` or `notEqual` of a property +/// path and a `{ "const": string }`, `in` with a property path and two or +/// more distinct strings, `present` or `absent` with a /// property path, `anyOf` or `allOf` with two or more conditions, none of them /// directly the same operator (it says what one flat list says), or `not` with /// one condition that is not directly another `not`. An operand is an integer @@ -670,20 +746,42 @@ fn parse_condition( )); }; let base = at.len(); - at.push_str("[0]"); - let operand = parse_expression(operand, at, depth + 1)?; - at.truncate(base); - if !operand.reads_property() { - at.truncate(parent); - return Err(format!( - "{}reads no property, so it would hold for every document or for none", - located(at) - )); + // The values are literals, so a string among them is a string rather + // than a path, and the first one decides what the in compares + let over_strings = values + .as_array() + .and_then(|values| values.first()) + .is_some_and(|first| first.as_text().is_some()); + if over_strings { + let Some(path) = operand.as_text() else { + return Err(format!( + "at {at}[0] must be a property path: an in over strings reads a string \ + property" + )); + }; + at.push_str("[1]"); + let values = in_text_values(values, at)?; + at.truncate(base); + PropertyConstraint::TextIn { + path: path.to_string(), + values, + } + } else { + at.push_str("[0]"); + let operand = parse_expression(operand, at, depth + 1)?; + at.truncate(base); + if !operand.reads_property() { + at.truncate(parent); + return Err(format!( + "{}reads no property, so it would hold for every document or for none", + located(at) + )); + } + at.push_str("[1]"); + let values = in_values(values, at)?; + at.truncate(base); + PropertyConstraint::In { operand, values } } - at.push_str("[1]"); - let values = in_values(values, at)?; - at.truncate(base); - PropertyConstraint::In { operand, values } } // What the path names is checked against the parsed document type PRESENT | ABSENT => { @@ -708,6 +806,19 @@ fn parse_condition( condition_keys() )); }; + if let Some([left, right]) = body.as_array().map(Vec::as_slice) { + if is_const(left) || is_const(right) { + let compare = text_comparison(comparison, left, right, at)?; + at.truncate(parent); + return compare.ok_or_else(|| { + format!( + "{}reads no property, so it would hold for every document or for \ + none", + located(at) + ) + }); + } + } let (left, right) = operand_pair(body, at, depth + 1)?; if !left.reads_property() && !right.reads_property() { at.truncate(parent); @@ -785,6 +896,88 @@ fn in_values(values: &Value, at: &mut String) -> Result, String> Ok(seen.into_keys().collect()) } +/// The values an `in` over strings lists at `at` (`in[1]`): two or more +/// strings, no two alike. +fn in_text_values(values: &Value, at: &mut String) -> Result, String> { + let Some(values) = values.as_array().filter(|values| values.len() >= 2) else { + return Err(format!("at {at} must list two or more string values")); + }; + let base = at.len(); + // Each value with the index it first appears at, for the errors + let mut seen = BTreeMap::new(); + for (index, value) in values.iter().enumerate() { + // Writing to a `String` cannot fail + let _ = write!(at, "[{index}]"); + let Some(text) = value.as_text() else { + return Err(format!("at {at} must be a string, as the first value is")); + }; + if let Some(earlier) = seen.insert(text, index) { + return Err(format!( + "at {at} repeats the value at {}[{earlier}]", + &at[..base] + )); + } + at.truncate(base); + } + Ok(seen.into_keys().map(str::to_string).collect()) +} + +/// Whether `value` is a string constant operand, `{ "const": ... }`. +fn is_const(value: &Value) -> bool { + single_entry(value).is_some_and(|(key, _)| key == CONST) +} + +/// The comparison at `at` (`equal`) of `left` and `right`, at least one of +/// them a string constant: the other must be a property path, the constant a +/// string, and only `equal` and `notEqual` compare strings. `None` when both +/// are constants, a comparison that reads no property. +fn text_comparison( + comparison: ConstraintComparison, + left: &Value, + right: &Value, + at: &str, +) -> Result, String> { + if !matches!( + comparison, + ConstraintComparison::Equal | ConstraintComparison::NotEqual + ) { + return Err(format!( + "at {at} compares a string constant, which only equal and notEqual do" + )); + } + let constant = |value: &Value, index: usize| { + single_entry(value) + .and_then(|(_, constant)| constant.as_text()) + .map(str::to_string) + .ok_or_else(|| { + format!( + "at {at}[{index}].{CONST} must be a string: an integer is written as itself" + ) + }) + }; + let (path_side, path_index, constant_side, constant_index) = if is_const(right) { + (left, 0, right, 1) + } else { + (right, 1, left, 0) + }; + let value = constant(constant_side, constant_index)?; + if is_const(path_side) { + constant(path_side, path_index)?; + return Ok(None); + } + let Some(path) = path_side.as_text() else { + return Err(format!( + "at {at}[{path_index}] must be a property path: a string constant is compared with a \ + string property" + )); + }; + Ok(Some(PropertyConstraint::TextCompare { + comparison, + path: path.to_string(), + value, + })) +} + /// An operand at `at` (`lessThan[0].add[1]`), where the errors place it, /// `depth` levels into its rule. `at` is extended for the operands of an /// operator and trimmed back before a successful return. @@ -865,6 +1058,13 @@ fn parse_expression( } ConstraintExpression::Power(Box::new(base), Box::new(exponent)) } + CONST => { + at.truncate(parent); + return Err(format!( + "at {at} is a string constant, which only equal and notEqual compare, with a \ + string property, never inside an integer expression" + )); + } other => { at.truncate(parent); return Err(format!( @@ -944,6 +1144,16 @@ fn integer_value(value: &Value, at: &str) -> Result { }) } +/// The string `data` holds at `path`, `None` when the document leaves the +/// property out or holds anything but a string there, which the schema +/// validation running first refuses for a string property. +fn text_value<'a>(data: &'a Value, path: &'a str) -> Option<&'a str> { + match data.get_optional_value_at_path(path) { + Ok(Some(Value::Text(text))) => Some(text), + _ => None, + } +} + /// Whether `data` holds the property at `path`: absent exactly where /// [`property_value`] would take the `if_absent` value. fn is_present(data: &Value, path: &str) -> bool { diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index 42008d93edd..a3dcac7eb60 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -738,6 +738,195 @@ fn should_hold_an_in_when_its_operand_takes_a_listed_value() { assert_eq!(rule.property_reads(), [("kind", PropertyRead::Value)]); } +// ── strings ───────────────────────────────────────────────────────────── + +fn text_compare(comparison: ConstraintComparison, path: &str, value: &str) -> PropertyConstraint { + PropertyConstraint::TextCompare { + comparison, + path: path.to_string(), + value: value.to_string(), + } +} + +/// A string constant is a `const` object, since a string on its own is a path; a +/// comparison with one is the same whichever side it sits on. +#[test] +fn should_parse_string_comparisons() { + assert_eq!( + parse_rule_value(platform_value!({ "equal": ["status", { "const": "closed" }] })), + text_compare(ConstraintComparison::Equal, "status", "closed") + ); + assert_eq!( + parse_rule_value(platform_value!({ "notEqual": [{ "const": "closed" }, "meta.state"] })), + text_compare(ConstraintComparison::NotEqual, "meta.state", "closed") + ); + assert_eq!( + parse_rule_value(platform_value!({ "in": ["status", ["pending", "open"]] })), + PropertyConstraint::TextIn { + path: "status".to_string(), + values: BTreeSet::from(["open".to_string(), "pending".to_string()]), + } + ); + + for (condition, needle) in [ + ( + platform_value!({ "lessThan": ["status", { "const": "b" }] }), + "rule \"rule\" at lessThan compares a string constant, which only equal and notEqual \ + do", + ), + ( + platform_value!({ "equal": ["status", { "const": 5 }] }), + "rule \"rule\" at equal[1].const must be a string: an integer is written as itself", + ), + ( + platform_value!({ "equal": [{ "const": "a" }, { "const": "b" }] }), + "rule \"rule\" reads no property", + ), + ( + platform_value!({ + "anyOf": [ + { "equal": ["fee", 1] }, + { "notEqual": [{ "const": "a" }, { "const": "a" }] } + ] + }), + "rule \"rule\" at anyOf[1] reads no property", + ), + // The other side is a property, never an expression or a value + ( + platform_value!({ "equal": [{ "ifAbsent": ["status", 0] }, { "const": "a" }] }), + "rule \"rule\" at equal[0] must be a property path: a string constant is compared \ + with a string property", + ), + ( + platform_value!({ "equal": [5, { "const": "a" }] }), + "rule \"rule\" at equal[0] must be a property path", + ), + // A constant is no integer operand + ( + platform_value!({ "equal": [{ "add": ["price", { "const": "a" }] }, 1] }), + "rule \"rule\" at equal[0].add[1] is a string constant, which only equal and notEqual \ + compare", + ), + ( + platform_value!({ "in": [{ "const": "a" }, [1, 2]] }), + "rule \"rule\" at in[0] is a string constant", + ), + ( + platform_value!({ "in": [{ "add": ["status", 1] }, ["open", "closed"]] }), + "rule \"rule\" at in[0] must be a property path: an in over strings reads a string \ + property", + ), + ( + platform_value!({ "in": ["status", ["open"]] }), + "rule \"rule\" at in[1] must list two or more string values", + ), + ( + platform_value!({ "in": ["status", ["open", 2]] }), + "rule \"rule\" at in[1][1] must be a string, as the first value is", + ), + ( + platform_value!({ "in": ["status", ["open", "closed", "open"]] }), + "rule \"rule\" at in[1][2] repeats the value at in[1][0]", + ), + ( + platform_value!({ "in": ["fee", [1, "two"]] }), + "rule \"rule\" at in[1][1] must be an integer value", + ), + ] { + expect_refusal(platform_value!({ "rule": condition }), needle); + } +} + +/// A string property equals a constant when it holds that string; one the document +/// leaves out, sets to null or holds as something else equals none, so `notEqual` +/// holds for it and `in` does not. +#[test] +fn should_compare_a_string_property_with_constants() { + let equal = parse_rule_value(platform_value!({ "equal": ["status", { "const": "closed" }] })); + let not_equal = + parse_rule_value(platform_value!({ "notEqual": ["status", { "const": "closed" }] })); + let in_list = parse_rule_value(platform_value!({ "in": ["status", ["open", "closed"]] })); + for (status, is_closed, is_listed) in [ + (Some(Value::Text("closed".to_string())), true, true), + (Some(Value::Text("open".to_string())), false, true), + (Some(Value::Text("Closed".to_string())), false, false), + (Some(Value::Text(String::new())), false, false), + (Some(Value::Null), false, false), + (Some(Value::U64(1)), false, false), + (None, false, false), + ] { + let values = match &status { + Some(value) => data(&[("status", value.clone())]), + None => data(&[]), + }; + assert_eq!(equal.holds(&values), Ok(is_closed), "equal, {status:?}"); + assert_eq!( + not_equal.holds(&values), + Ok(!is_closed), + "notEqual, {status:?}" + ); + assert_eq!(in_list.holds(&values), Ok(is_listed), "in, {status:?}"); + } + + // A closed order must carry closedAt + let rule = parse_rule_value(platform_value!({ + "anyOf": [{ "notEqual": ["status", { "const": "closed" }] }, { "present": "closedAt" }] + })); + let closed = Value::Text("closed".to_string()); + assert_eq!(rule.violation(&data(&[])), None); + assert_eq!( + rule.violation(&data(&[ + ("status", closed.clone()), + ("closedAt", Value::U64(9)) + ])), + None + ); + assert_eq!( + rule.violation(&data(&[("status", closed)])), + Some(PropertyConstraintViolation::NotMet) + ); +} + +/// A string comparison is three nodes, as a comparison of a path with a value; an in over +/// strings two plus one per value. Both read their property as text, and list their +/// constants for the enum check. +#[test] +fn should_count_and_list_what_a_string_comparison_reads() { + let rule = parse_rule_value(platform_value!({ + "anyOf": [ + { "equal": ["status", { "const": "closed" }] }, + { "in": ["kind", ["b", "a", "c"]] } + ] + })); + // anyOf, equal, status, closed, in, kind, a, b, c + assert_eq!(rule.node_count(), 9); + assert_eq!( + rule.property_reads(), + [("status", PropertyRead::Text), ("kind", PropertyRead::Text)] + ); + assert_eq!( + rule.text_constants(), + [ + ("status", "closed"), + ("kind", "a"), + ("kind", "b"), + ("kind", "c") + ] + ); + // Written either way round, the same condition + let rule = parse_rule_value(platform_value!({ + "anyOf": [ + { "equal": ["status", { "const": "closed" }] }, + { "equal": ["fee", 1] }, + { "equal": [{ "const": "closed" }, "status"] } + ] + })); + assert_eq!( + rule.repeated_condition(), + Some(("anyOf[2]".to_string(), "anyOf[0]".to_string())) + ); +} + // ── present and absent ────────────────────────────────────────────────── #[test] diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 7e50f06499c..733ee158cc9 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -169,12 +169,13 @@ pub struct DocumentTypeV2 { /// The rules every created or replaced document must meet, by name, in the /// order they are checked (`propertyConstraints` keyword, protocol version /// 14): each a condition on the document's properties, a comparison of two - /// integer expressions, an `in` list of values, a `present` or `absent` - /// test, or an `anyOf`, `allOf` or `not` of conditions. Empty on document - /// types that declare none. The parser (`apply_property_constraints`) holds - /// every property an operand reads to be an integer or a boolean, and every - /// property a rule reads to be neither transient nor inside a transient - /// object. + /// integer expressions or of a string property with string constants, an + /// `in` list of values, a `present` or `absent` test, or an `anyOf`, `allOf` + /// or `not` of conditions. Empty on document types that declare none. The + /// parser (`apply_property_constraints`) holds every property an operand + /// reads to be an integer or a boolean, every property compared with strings + /// to be a string, and every property a rule reads to be neither transient + /// nor inside a transient object. pub(in crate::data_contract) property_constraints: BTreeMap, /// How many seconds after its creation (`$createdAt`) the platform deletes each /// document of the type (`ttl` keyword, protocol version 14), `None` when the diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index 15a15b1cbe0..32e5cfb15c8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -1,8 +1,8 @@ //! End-to-end coverage for the `propertyConstraints` doctype keyword (protocol //! version 14): a document type names rules its documents' integer properties -//! must meet, each a comparison of two integer expressions, an `in` list of -//! values, a `present` or `absent` test, or an `anyOf`, `allOf` or `not` of -//! such conditions. A create or replace +//! must meet, each a comparison of two integer expressions or of a string +//! property with string constants, an `in` list of values, a `present` or +//! `absent` test, or an `anyOf`, `allOf` or `not` of such conditions. A create or replace //! that breaks one is consensus-rejected with //! `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the rule //! and why, and leaves the stored document untouched. A property the document @@ -37,6 +37,8 @@ mod property_constraints_tests { /// /// * `boostCapped`: `price * ifAbsent(boost, 1) <= 100000` /// * `boostPower`: `ifAbsent(boost, 1) ^ 20 >= 1`, which overflows for a large boost + /// * `closedAtOnlyWhenClosed`: `closedAt` only on a closed or cancelled offer + /// * `closedNeedsClosedAt`: a closed offer carries `closedAt` /// * `depositCoversOrder`: `(price + fee) * quantity <= deposit` /// * `discountBelowPrice`: `discount < price`, an absent discount counting as 0 /// * `discountGivenAboveZero`: `discount` is absent or above 0 @@ -56,7 +58,14 @@ mod property_constraints_tests { "deposit": { "type": "integer", "minimum": 0, "position": 3 }, "discount": { "type": "integer", "minimum": 0, "maximum": 1000000, "position": 4 }, "boost": { "type": "integer", "minimum": 0, "maximum": 100, "position": 5 }, - "waiveFee": { "type": "boolean", "position": 6 } + "waiveFee": { "type": "boolean", "position": 6 }, + "status": { + "type": "string", + "enum": ["open", "closed", "cancelled"], + "maxLength": 9, + "position": 7 + }, + "closedAt": { "type": "integer", "minimum": 0, "position": 8 } }, "required": ["price", "fee", "quantity", "deposit"], "propertyConstraints": { @@ -69,6 +78,18 @@ mod property_constraints_tests { "boostPower": { "greaterThanOrEqual": [{ "power": [{ "ifAbsent": ["boost", 1] }, 20] }, 1] }, + "closedAtOnlyWhenClosed": { + "anyOf": [ + { "in": ["status", ["closed", "cancelled"]] }, + { "absent": "closedAt" } + ] + }, + "closedNeedsClosedAt": { + "anyOf": [ + { "notEqual": ["status", { "const": "closed" }] }, + { "present": "closedAt" } + ] + }, "depositCoversOrder": { "lessThanOrEqual": [ { "multiply": [{ "add": ["price", "fee"] }, "quantity"] }, @@ -588,6 +609,56 @@ mod property_constraints_tests { assert_eq!(fixture.stored_offers().len(), 2); } + /// A string property is compared with constants: a closed offer needs + /// `closedAt`, and only a closed or cancelled one may carry it. + #[tokio::test] + async fn should_compare_a_string_property_with_constants() { + let mut fixture = OfferFixture::new(); + let status = |value: &str| Value::Text(value.to_string()); + + let result = fixture + .create(|document| document.set("status", status("closed"))) + .await; + expect_violated( + result, + "closedNeedsClosedAt", + PropertyConstraintViolation::NotMet, + ); + + let result = fixture + .create(|document| { + document.set("status", status("open")); + document.set("closedAt", Value::U64(1000)); + }) + .await; + expect_violated( + result, + "closedAtOnlyWhenClosed", + PropertyConstraintViolation::NotMet, + ); + assert!(fixture.stored_offers().is_empty()); + + for (value, closed_at) in [ + ("closed", Some(1000)), + ("cancelled", Some(1000)), + ("open", None), + ] { + assert_matches!( + fixture + .create(|document| { + document.set("status", status(value)); + if let Some(closed_at) = closed_at { + document.set("closedAt", Value::U64(closed_at)); + } + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "{value}" + ); + } + assert_eq!(fixture.stored_offers().len(), 3); + } + #[tokio::test] async fn should_judge_a_replace_against_the_rules() { let mut fixture = OfferFixture::new(); 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 ef1d4340cba..531f3b171e9 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -55,10 +55,10 @@ pub struct SystemLimits { pub max_property_constraints: u16, /// Maximum number of nodes in one `propertyConstraints` rule: every comparison, every /// `in` and each value it lists, every `present` or `absent` and every `anyOf`, `allOf` or - /// `not`, every arithmetic operator and every operand, an integer value or a property. An - /// `ifAbsent` operand is one node, the default it gives included. Refused under full - /// validation only, like `max_property_constraints`. Read by document type parser generation 3 - /// (protocol version 14) and never reached before. + /// `not`, every arithmetic operator and every operand, an integer value, a string `const` + /// or a property. An `ifAbsent` operand is one node, the default it gives included. + /// Refused under full validation only, like `max_property_constraints`. Read by document + /// type parser generation 3 (protocol version 14) and never reached before. pub max_property_constraint_nodes: u16, /// Max size of a state transition in bytes. /// diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 7775521ccf3..fe6e40d8d3f 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1049,33 +1049,37 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `greaterThanOrEqual`) of two integer expressions built from integer /// literals, paths of integer or boolean properties (a boolean reading as /// 1 for true and 0 for false) and `add`, `subtract`, `multiply`, -/// `divide`, `modulo` and `power`; `in`, whether an integer expression takes one of -/// two or more distinct integer values; `present` or `absent` naming a -/// property of any type, whether the document holds it (the one way to -/// tell a property left out from one set to 0); `anyOf` or `allOf` over -/// two or more conditions; or `not` over one. In an operand, a property -/// the document leaves out counts as 0, or as the value of an `ifAbsent` -/// operand naming it. Arithmetic is exact `i128`: `divide` and `modulo` -/// are Euclidean (the remainder is never negative), and an overflow, a -/// zero divisor, a negative exponent or a value that is not an integer -/// refuses the document rather than wrapping. Conditions are checked in -/// declared order and no further than the outcome needs (`anyOf` stops at -/// the first that holds, `allOf` at the first that fails), a fault in one -/// that is checked refuses the document whatever the others say, and `not` -/// never turns a fault into a pass, so an earlier condition guards a later -/// one. The parser checks that every path an operand reads names an -/// integer or boolean property and every path `present` or `absent` -/// tests names a property of any type, neither transient nor inside a -/// transient object; -/// that every comparison and `in` reads a property; that no `in` lists a -/// value twice; that an `anyOf` or `allOf` holds none directly of its own -/// kind and a `not` no `not`; and that no condition or operand nests -/// deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every parse. -/// Under full validation it holds the limits -/// `SystemLimits::max_property_constraints` (16 rules) and -/// `max_property_constraint_nodes` (32 per rule, every comparison, `in`, -/// listed value, presence test and logical operator counting as one), and -/// that no `anyOf` or `allOf` lists the same condition twice. +/// `divide`, `modulo` and `power`; `in`, whether an integer expression +/// takes one of two or more distinct integer values; `equal` or `notEqual` +/// of a string property and a `{ "const": string }`, or `in` of a string +/// property and two or more distinct strings, a string the document leaves +/// out equalling no constant; `present` or `absent` naming a property of +/// any type, whether the document holds it (the one way to tell a property +/// left out from one set to 0); `anyOf` or `allOf` over two or more +/// conditions; or `not` over one. In an operand, a property the document +/// leaves out counts as 0, or as the value of an `ifAbsent` operand naming +/// it. Arithmetic is exact `i128`: `divide` and `modulo` are Euclidean (the +/// remainder is never negative), and an overflow, a zero divisor, a +/// negative exponent or a value that is not an integer refuses the +/// document rather than wrapping. Conditions are checked in declared order +/// and no further than the outcome needs (`anyOf` stops at the first that +/// holds, `allOf` at the first that fails), a fault in one that is checked +/// refuses the document whatever the others say, and `not` never turns a +/// fault into a pass, so an earlier condition guards a later one. The +/// parser checks that every path an operand reads names an integer or +/// boolean property, every path compared with strings a string property +/// (whose `enum`, if it declares one, lists every constant it is compared +/// with), and every path `present` or `absent` tests a property of any +/// type, none transient nor inside a transient object; that every +/// comparison and `in` reads a property; that no `in` lists a value twice; +/// that an `anyOf` or `allOf` holds none directly of its own kind and a +/// `not` no `not`; and that no condition or operand nests deeper than +/// `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every parse. Under full +/// validation it holds the limits `SystemLimits::max_property_constraints` +/// (16 rules) and `max_property_constraint_nodes` (32 per rule, every +/// comparison, `in`, listed value, `const`, presence test and logical +/// operator counting as one), and that no `anyOf` or `allOf` lists the +/// same condition twice. /// `DataContract::validate_document_properties` 0 (extended in place, inert /// before this version) calls `validate_property_constraints` /// (`validate_property_constraints` 0) after the schema validation, so From 5c79d12dfcf44f0ab1100d703ff48134ee8c6ce3 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 15:45:35 +0700 Subject: [PATCH 019/113] chore(release): update changelog and bump version to 4.2.0-beta.5 (#5044) Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 94 ++++++++++++++++++ Cargo.lock | 96 +++++++++---------- Cargo.toml | 2 +- package.json | 2 +- packages/app-connect-contract/package.json | 2 +- packages/bench-suite/package.json | 2 +- packages/dapi-grpc/package.json | 2 +- packages/dapi/package.json | 2 +- packages/dash-spv/package.json | 2 +- packages/dashmate/package.json | 2 +- packages/dashpay-contract/package.json | 2 +- .../document-history-contract/package.json | 2 +- packages/dpns-contract/package.json | 2 +- packages/js-dapi-client/package.json | 2 +- packages/js-dash-sdk/package.json | 2 +- packages/js-evo-sdk/package.json | 2 +- packages/js-grpc-common/package.json | 2 +- packages/keyword-search-contract/package.json | 2 +- .../package.json | 2 +- .../moderation-charters-contract/package.json | 2 +- packages/platform-test-suite/package.json | 2 +- packages/token-history-contract/package.json | 2 +- packages/wallet-lib/package.json | 2 +- packages/wallet-utils-contract/package.json | 2 +- packages/wasm-dpp/package.json | 2 +- packages/wasm-dpp2/package.json | 2 +- packages/wasm-drive-verify/package.json | 2 +- packages/wasm-sdk/package.json | 2 +- packages/withdrawals-contract/package.json | 2 +- 29 files changed, 169 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ae8423857..8f7960c9aea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,97 @@ +## [4.2.0-beta.5](https://github.com/dashpay/platform/compare/v4.2.0-beta.4...v4.2.0-beta.5) (2026-09-27) + + +### ⚠ BREAKING CHANGES + +* **platform:** string equality for enums in propertyConstraints rules (PV14) (#5042) +* **platform:** pay document ttl storage fees to the epochs the documents live in (PV14) (#5033) +* **platform:** contenders state the most they pay and are charged the join price (PV14) (#5039) +* **platform:** boolean operands in propertyConstraints rules (PV14) (#5040) +* **platform:** in, value membership in propertyConstraints rules (PV14) (#5038) +* **platform:** present and absent tests in propertyConstraints rules (PV14) (#5037) +* **platform:** anyOf, allOf and not in propertyConstraints rules (PV14) (#5036) +* **platform:** a contender's fund doubles for every 50 contenders a contest holds past 250 (PV14) (#5034) +* **drive-abci:** cap a contest at 1,000 contenders and tally every one (PV14) (#5029) +* **platform:** documents with a time to live, deleted by the platform (PV14) (#5007) +* **drive:** an evonode's token claim covers only the epochs it read (PV14) (#5015) +* **drive-abci:** claw a storage refund back from the epochs it was priced for (PV14) (#5013) +* **drive-abci:** refuse bytes after a state transition (PV14) (#5011) +* **drive-abci:** refuse a masternode vote for an identity that is not a contender (PV14) (#5002) +* **drive:** delete an ended vote poll end date only once none of its polls remain (PV14) (#4996) +* **drive-abci:** refuse a token mint or direct purchase past the i64::MAX supply ceiling (PV14) (#5000) +* **drive-abci:** allow contested documents before epoch 4 (#4995) +* **drive:** merge repeated writes of one balance in a batch so an action fee no longer loses a purchase price (#4987) +* **platform:** credit repaid identity debt to the processing fee pool (#4985) +* **dpp:** refuse immutableAllowSetting on a deletableDocument reference (PV14) (#4983) +* **drive-abci:** re-check a contract reference's owner requirement on every replace of a transferable document (PV14) (#4982) +* **drive-abci:** refuse a $creatorId key reference on a document without a creator id (PV14) (#4984) + +### Features + +* **drive-abci:** allow contested documents before epoch 4 ([#4995](https://github.com/dashpay/platform/issues/4995)) +* **platform:** a contender's fund doubles for every 50 contenders a contest holds past 250 (PV14) ([#5034](https://github.com/dashpay/platform/issues/5034)) +* **platform:** anyOf, allOf and not in propertyConstraints rules (PV14) ([#5036](https://github.com/dashpay/platform/issues/5036)) +* **platform:** boolean operands in propertyConstraints rules (PV14) ([#5040](https://github.com/dashpay/platform/issues/5040)) +* **platform:** documents with a time to live, deleted by the platform (PV14) ([#5007](https://github.com/dashpay/platform/issues/5007)) +* **platform:** in, value membership in propertyConstraints rules (PV14) ([#5038](https://github.com/dashpay/platform/issues/5038)) +* **platform:** pay document ttl storage fees to the epochs the documents live in (PV14) ([#5033](https://github.com/dashpay/platform/issues/5033)) +* **platform:** present and absent tests in propertyConstraints rules (PV14) ([#5037](https://github.com/dashpay/platform/issues/5037)) +* **platform:** string equality for enums in propertyConstraints rules (PV14) ([#5042](https://github.com/dashpay/platform/issues/5042)) +* **rs-dapi:** refuse shielded broadcasts from addresses that keep sending invalid proofs ([#5001](https://github.com/dashpay/platform/issues/5001)) + + +### Bug Fixes + +* **dpp:** refuse immutableAllowSetting on a deletableDocument reference (PV14) ([#4983](https://github.com/dashpay/platform/issues/4983)) +* **drive-abci:** cap a contest at 1,000 contenders and tally every one (PV14) ([#5029](https://github.com/dashpay/platform/issues/5029)) +* **drive-abci:** check the address input limit before verifying witnesses ([#5005](https://github.com/dashpay/platform/issues/5005)) +* **drive-abci:** claw a storage refund back from the epochs it was priced for (PV14) ([#5013](https://github.com/dashpay/platform/issues/5013)) +* **drive-abci:** re-check a contract reference's owner requirement on every replace of a transferable document (PV14) ([#4982](https://github.com/dashpay/platform/issues/4982)) +* **drive-abci:** refuse a $creatorId key reference on a document without a creator id (PV14) ([#4984](https://github.com/dashpay/platform/issues/4984)) +* **drive-abci:** refuse a masternode vote for an identity that is not a contender (PV14) ([#5002](https://github.com/dashpay/platform/issues/5002)) +* **drive-abci:** refuse a token mint or direct purchase past the i64::MAX supply ceiling (PV14) ([#5000](https://github.com/dashpay/platform/issues/5000)) +* **drive-abci:** refuse bytes after a state transition (PV14) ([#5011](https://github.com/dashpay/platform/issues/5011)) +* **drive-abci:** sign and verify vote extensions of a block accepted in another round ([#5028](https://github.com/dashpay/platform/issues/5028)) +* **drive-abci:** verify vote extensions against the withdrawals of their own round ([#5010](https://github.com/dashpay/platform/issues/5010)) +* **drive:** an evonode's token claim covers only the epochs it read (PV14) ([#5015](https://github.com/dashpay/platform/issues/5015)) +* **drive:** delete an ended vote poll end date only once none of its polls remain (PV14) ([#4996](https://github.com/dashpay/platform/issues/4996)) +* **drive:** merge repeated writes of one balance in a batch so an action fee no longer loses a purchase price ([#4987](https://github.com/dashpay/platform/issues/4987)) +* **drive:** read stored group actions without the proof decoding budget ([#5006](https://github.com/dashpay/platform/issues/5006)) +* **platform:** contenders state the most they pay and are charged the join price (PV14) ([#5039](https://github.com/dashpay/platform/issues/5039)) +* **platform:** credit repaid identity debt to the processing fee pool ([#4985](https://github.com/dashpay/platform/issues/4985)) +* **rs-sdk-ffi:** stop probing google.com and testnet quorums on every SDK build ([#5008](https://github.com/dashpay/platform/issues/5008)) +* **sdk:** don't panic in DapiClient::new on an empty address list ([#4964](https://github.com/dashpay/platform/issues/4964)) + + +### Performance Improvements + +* **drive-abci:** read shielded encrypted notes in one chunk-aligned range read ([#5030](https://github.com/dashpay/platform/issues/5030)) +* **drive:** build batch deletes without copying the pending batch ([#5004](https://github.com/dashpay/platform/issues/5004)) + + +### Tests + +* **drive:** keep setup_drive's temp directory alive while the drive is open ([#5003](https://github.com/dashpay/platform/issues/5003)) +* **swift-sdk:** run SDKMethodTests against the offline mock SDK instead of live testnet ([#5009](https://github.com/dashpay/platform/issues/5009)) + + +### Miscellaneous Chores + +* open every pr-description output with a basic explanation section ([#5031](https://github.com/dashpay/platform/issues/5031)) +* sync the pr-description skill with the PR template and title check ([#5032](https://github.com/dashpay/platform/issues/5032)) + + +### Continuous Integration + +* build release SDKs and NPM packages on self-hosted runners ([#4562](https://github.com/dashpay/platform/issues/4562)) +* re-pin PR Hygiene ([#4975](https://github.com/dashpay/platform/issues/4975)) +* re-pin PR Hygiene ([#4979](https://github.com/dashpay/platform/issues/4979)) +* re-pin PR Hygiene ([#4989](https://github.com/dashpay/platform/issues/4989)) +* re-pin PR Hygiene ([#5016](https://github.com/dashpay/platform/issues/5016)) +* re-pin PR Hygiene ([#5023](https://github.com/dashpay/platform/issues/5023)) +* re-pin PR Hygiene and wake it on a hand-over ([#5020](https://github.com/dashpay/platform/issues/5020)) +* **release:** raise NPM and Swift SDK release job timeouts ([#4974](https://github.com/dashpay/platform/issues/4974)) + ## [4.2.0-beta.4](https://github.com/dashpay/platform/compare/v4.2.0-beta.3...v4.2.0-beta.4) (2026-09-24) diff --git a/Cargo.lock b/Cargo.lock index a7f35d1b352..1f56ac26e70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,7 +159,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app-connect-contract" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "base58", "platform-value", @@ -1055,7 +1055,7 @@ dependencies = [ [[package]] name = "check-features" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "toml 0.8.23", ] @@ -1543,7 +1543,7 @@ dependencies = [ [[package]] name = "dapi-grpc" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "dash-platform-macros", "futures-core", @@ -1630,7 +1630,7 @@ dependencies = [ [[package]] name = "dash-async" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "futures", "thiserror 2.0.18", @@ -1641,7 +1641,7 @@ dependencies = [ [[package]] name = "dash-context-provider" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "dash-async", "dpp", @@ -1672,7 +1672,7 @@ dependencies = [ [[package]] name = "dash-platform-balance-checker" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "anyhow", "clap", @@ -1687,7 +1687,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "heck 0.5.0", "quote", @@ -1696,7 +1696,7 @@ dependencies = [ [[package]] name = "dash-platform-queries" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "dapi-grpc", "dash-context-provider", @@ -1713,7 +1713,7 @@ dependencies = [ [[package]] name = "dash-sdk" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "arc-swap", "assert_matches", @@ -1858,7 +1858,7 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "platform-value", "platform-version", @@ -1868,7 +1868,7 @@ dependencies = [ [[package]] name = "data-contracts" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "app-connect-contract", "base58", @@ -2073,7 +2073,7 @@ dependencies = [ [[package]] name = "document-history-contract" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "platform-value", "platform-version", @@ -2095,7 +2095,7 @@ checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" [[package]] name = "dpns-contract" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "platform-value", "platform-version", @@ -2105,7 +2105,7 @@ dependencies = [ [[package]] name = "dpp" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "anyhow", "assert_matches", @@ -2164,7 +2164,7 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "proc-macro2", "quote", @@ -2173,7 +2173,7 @@ dependencies = [ [[package]] name = "drive" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "arc-swap", "assert_matches", @@ -2216,7 +2216,7 @@ dependencies = [ [[package]] name = "drive-abci" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "arc-swap", "assert_matches", @@ -2278,7 +2278,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "dapi-grpc", "dash-context-provider", @@ -4080,7 +4080,7 @@ dependencies = [ [[package]] name = "json-schema-compatibility-validator" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "assert_matches", "json-patch", @@ -4223,7 +4223,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "base58", "platform-value", @@ -4414,7 +4414,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "platform-value", "platform-version", @@ -4615,7 +4615,7 @@ dependencies = [ [[package]] name = "moderation-charters-contract" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "base58", "platform-value", @@ -5198,7 +5198,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "platform-encryption" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "aes", "cbc", @@ -5210,7 +5210,7 @@ dependencies = [ [[package]] name = "platform-serialization" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "grovedb-bincode", "platform-version", @@ -5218,7 +5218,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "proc-macro2", "quote", @@ -5228,7 +5228,7 @@ dependencies = [ [[package]] name = "platform-value" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "base64 0.22.1", "bs58", @@ -5247,7 +5247,7 @@ dependencies = [ [[package]] name = "platform-value-convertible" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "quote", "syn 2.0.117", @@ -5255,7 +5255,7 @@ dependencies = [ [[package]] name = "platform-version" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "grovedb-bincode", "grovedb-version", @@ -5265,7 +5265,7 @@ dependencies = [ [[package]] name = "platform-versioning" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "proc-macro2", "quote", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "platform-wallet" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "arc-swap", "async-trait", @@ -5314,7 +5314,7 @@ dependencies = [ [[package]] name = "platform-wallet-ffi" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "anyhow", "async-trait", @@ -5342,7 +5342,7 @@ dependencies = [ [[package]] name = "platform-wallet-storage" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "apple-native-keyring-store", "argon2", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "rs-dapi" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "async-trait", "axum 0.8.9", @@ -6409,7 +6409,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "backon", "chrono", @@ -6434,7 +6434,7 @@ dependencies = [ [[package]] name = "rs-dash-event-bus" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "metrics", "tokio", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "rs-sdk-ffi" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "async-trait", "bs58", @@ -6501,7 +6501,7 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "arc-swap", "dash-async", @@ -6521,7 +6521,7 @@ dependencies = [ [[package]] name = "rs-unified-sdk-ffi" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "dash-network", "key-wallet-ffi", @@ -6531,7 +6531,7 @@ dependencies = [ [[package]] name = "rs-unified-sdk-jni" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "android_logger", "dash-network", @@ -7273,7 +7273,7 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "simple-signer" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "async-trait", "base64 0.22.1", @@ -7410,7 +7410,7 @@ dependencies = [ [[package]] name = "strategy-tests" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "dpp", "drive", @@ -7812,7 +7812,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "platform-value", "platform-version", @@ -8655,7 +8655,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "platform-value", "platform-version", @@ -8798,7 +8798,7 @@ checksum = "a8145dd1593bf0fb137dbfa85b8be79ec560a447298955877804640e40c2d6ea" [[package]] name = "wasm-dpp" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "anyhow", "async-trait", @@ -8822,7 +8822,7 @@ dependencies = [ [[package]] name = "wasm-dpp2" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "anyhow", "async-trait", @@ -8841,7 +8841,7 @@ dependencies = [ [[package]] name = "wasm-drive-verify" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "base64 0.22.1", "bs58", @@ -8896,7 +8896,7 @@ dependencies = [ [[package]] name = "wasm-sdk" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "base64 0.22.1", "bip39", @@ -9407,7 +9407,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index e8727211b7b..9985ecfe4bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,5 +149,5 @@ opt-level = 3 [workspace.package] -version = "4.2.0-beta.4" +version = "4.2.0-beta.5" rust-version = "1.98" diff --git a/package.json b/package.json index fbedbcc4059..9f83ca0040a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/platform", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "private": true, "scripts": { "setup": "yarn install && yarn run build && yarn run configure", diff --git a/packages/app-connect-contract/package.json b/packages/app-connect-contract/package.json index d43d3d4df05..cc5459b8ded 100644 --- a/packages/app-connect-contract/package.json +++ b/packages/app-connect-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/app-connect-contract", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "A system contract for encrypted wallet-to-app login responses", "scripts": { "lint": "eslint .", diff --git a/packages/bench-suite/package.json b/packages/bench-suite/package.json index fda6ab3e6d6..8f6cd58bde2 100644 --- a/packages/bench-suite/package.json +++ b/packages/bench-suite/package.json @@ -1,7 +1,7 @@ { "name": "@dashevo/bench-suite", "private": true, - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "Dash Platform benchmark tool", "scripts": { "bench": "node ./bin/bench.js", diff --git a/packages/dapi-grpc/package.json b/packages/dapi-grpc/package.json index 3b278e77b29..c59aa6a8225 100644 --- a/packages/dapi-grpc/package.json +++ b/packages/dapi-grpc/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dapi-grpc", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "DAPI GRPC definition file and generated clients", "browser": "browser.js", "main": "node.js", diff --git a/packages/dapi/package.json b/packages/dapi/package.json index 658b3f21e6e..f2a706e3358 100644 --- a/packages/dapi/package.json +++ b/packages/dapi/package.json @@ -1,7 +1,7 @@ { "name": "@dashevo/dapi", "private": true, - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "A decentralized API for the Dash network", "scripts": { "api": "node scripts/api.js", diff --git a/packages/dash-spv/package.json b/packages/dash-spv/package.json index 7195da4b0ef..3b5acd5f425 100644 --- a/packages/dash-spv/package.json +++ b/packages/dash-spv/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dash-spv", - "version": "5.2.0-beta.4", + "version": "5.2.0-beta.5", "description": "Repository containing SPV functions used by @dashevo", "main": "index.js", "scripts": { diff --git a/packages/dashmate/package.json b/packages/dashmate/package.json index 85e8f347e12..dd8a761f7b0 100644 --- a/packages/dashmate/package.json +++ b/packages/dashmate/package.json @@ -1,6 +1,6 @@ { "name": "dashmate", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "Distribution package for Dash node installation", "scripts": { "lint": "eslint .", diff --git a/packages/dashpay-contract/package.json b/packages/dashpay-contract/package.json index 7bb8d1619e6..3a57460fbf4 100644 --- a/packages/dashpay-contract/package.json +++ b/packages/dashpay-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dashpay-contract", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "Reference contract of the DashPay DPA on Dash Evolution", "scripts": { "lint": "eslint .", diff --git a/packages/document-history-contract/package.json b/packages/document-history-contract/package.json index 138e4a2ecd1..8798146583c 100644 --- a/packages/document-history-contract/package.json +++ b/packages/document-history-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/document-history-contract", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "The document history contract", "scripts": { "lint": "eslint .", diff --git a/packages/dpns-contract/package.json b/packages/dpns-contract/package.json index 90d40930aed..fc29a8a4c88 100644 --- a/packages/dpns-contract/package.json +++ b/packages/dpns-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dpns-contract", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "A contract and helper scripts for DPNS DApp", "scripts": { "lint": "eslint .", diff --git a/packages/js-dapi-client/package.json b/packages/js-dapi-client/package.json index bf7e4233931..09f0ebc7450 100644 --- a/packages/js-dapi-client/package.json +++ b/packages/js-dapi-client/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dapi-client", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "Client library used to access Dash DAPI endpoints", "main": "lib/index.js", "contributors": [ diff --git a/packages/js-dash-sdk/package.json b/packages/js-dash-sdk/package.json index 8faee5202bd..b490bc242d0 100644 --- a/packages/js-dash-sdk/package.json +++ b/packages/js-dash-sdk/package.json @@ -1,6 +1,6 @@ { "name": "dash", - "version": "7.2.0-beta.4", + "version": "7.2.0-beta.5", "description": "Dash library for JavaScript/TypeScript ecosystem (Wallet, DAPI, Primitives, BLS, ...)", "main": "build/index.js", "unpkg": "dist/dash.min.js", diff --git a/packages/js-evo-sdk/package.json b/packages/js-evo-sdk/package.json index 77535cc5dd6..5e74a229639 100644 --- a/packages/js-evo-sdk/package.json +++ b/packages/js-evo-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/evo-sdk", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "type": "module", "main": "./dist/evo-sdk.module.js", "types": "./dist/sdk.d.ts", diff --git a/packages/js-grpc-common/package.json b/packages/js-grpc-common/package.json index 24000661775..169782bc4ca 100644 --- a/packages/js-grpc-common/package.json +++ b/packages/js-grpc-common/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/grpc-common", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "Common GRPC library", "main": "index.js", "scripts": { diff --git a/packages/keyword-search-contract/package.json b/packages/keyword-search-contract/package.json index 4e61a9c2ec4..f56a995d96f 100644 --- a/packages/keyword-search-contract/package.json +++ b/packages/keyword-search-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/keyword-search-contract", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "A contract that allows searching for contracts", "scripts": { "lint": "eslint .", diff --git a/packages/masternode-reward-shares-contract/package.json b/packages/masternode-reward-shares-contract/package.json index 21f0e656b6a..07be9ed94d5 100644 --- a/packages/masternode-reward-shares-contract/package.json +++ b/packages/masternode-reward-shares-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/masternode-reward-shares-contract", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "A contract and helper scripts for reward sharing", "scripts": { "lint": "eslint .", diff --git a/packages/moderation-charters-contract/package.json b/packages/moderation-charters-contract/package.json index 43ddf490133..5c5ca2d2f96 100644 --- a/packages/moderation-charters-contract/package.json +++ b/packages/moderation-charters-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/moderation-charters-contract", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "A system contract for the charters of elected moderation teams", "scripts": { "lint": "eslint .", diff --git a/packages/platform-test-suite/package.json b/packages/platform-test-suite/package.json index 55c2fc82bcf..77b1b2856b0 100644 --- a/packages/platform-test-suite/package.json +++ b/packages/platform-test-suite/package.json @@ -1,7 +1,7 @@ { "name": "@dashevo/platform-test-suite", "private": true, - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "Dash Network end-to-end tests", "scripts": { "test": "yarn exec bin/test.sh", diff --git a/packages/token-history-contract/package.json b/packages/token-history-contract/package.json index 295ba7ac0df..1cb6fcfa112 100644 --- a/packages/token-history-contract/package.json +++ b/packages/token-history-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/token-history-contract", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "The token history contract", "scripts": { "lint": "eslint .", diff --git a/packages/wallet-lib/package.json b/packages/wallet-lib/package.json index 6d948bce823..fff2260d787 100644 --- a/packages/wallet-lib/package.json +++ b/packages/wallet-lib/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wallet-lib", - "version": "11.2.0-beta.4", + "version": "11.2.0-beta.5", "description": "Light wallet library for Dash", "main": "src/index.js", "unpkg": "dist/wallet-lib.min.js", diff --git a/packages/wallet-utils-contract/package.json b/packages/wallet-utils-contract/package.json index 5a707958951..121ca97bfd8 100644 --- a/packages/wallet-utils-contract/package.json +++ b/packages/wallet-utils-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wallet-utils-contract", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "A contract and helper scripts for Wallet DApp", "scripts": { "lint": "eslint .", diff --git a/packages/wasm-dpp/package.json b/packages/wasm-dpp/package.json index 51df9103f5a..2c855e301a5 100644 --- a/packages/wasm-dpp/package.json +++ b/packages/wasm-dpp/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wasm-dpp", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "The JavaScript implementation of the Dash Platform Protocol", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/wasm-dpp2/package.json b/packages/wasm-dpp2/package.json index f4f58426894..8a5b19d6aa6 100644 --- a/packages/wasm-dpp2/package.json +++ b/packages/wasm-dpp2/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wasm-dpp2", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "type": "module", "main": "./dist/dpp.js", "types": "./dist/dpp.d.ts", diff --git a/packages/wasm-drive-verify/package.json b/packages/wasm-drive-verify/package.json index a524e9f5bb5..2419715720c 100644 --- a/packages/wasm-drive-verify/package.json +++ b/packages/wasm-drive-verify/package.json @@ -3,7 +3,7 @@ "collaborators": [ "Dash Core Group " ], - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "license": "MIT", "description": "WASM bindings for Drive verify functions", "repository": { diff --git a/packages/wasm-sdk/package.json b/packages/wasm-sdk/package.json index 6d652e217f3..3dd732583a0 100644 --- a/packages/wasm-sdk/package.json +++ b/packages/wasm-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wasm-sdk", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "type": "module", "main": "./dist/sdk.js", "types": "./dist/sdk.d.ts", diff --git a/packages/withdrawals-contract/package.json b/packages/withdrawals-contract/package.json index 083c3836f56..ef8eed56d58 100644 --- a/packages/withdrawals-contract/package.json +++ b/packages/withdrawals-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/withdrawals-contract", - "version": "4.2.0-beta.4", + "version": "4.2.0-beta.5", "description": "Data Contract to manipulate and track withdrawals", "scripts": { "build": "", From d5a5875b82d7681e2c9b01fecd2c9caaa31f78ca Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 15:51:12 +0700 Subject: [PATCH 020/113] feat(platform)!: compare two string properties in propertyConstraints rules (PV14) (#5045) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/documents.md | 2 +- packages/js-evo-sdk/README.md | 2 +- .../document/v3/document-meta.json | 8 +- .../class_methods/try_from_schema/mod.rs | 20 ++- .../v3/property_constraints_tests.rs | 61 ++++++++- .../src/data_contract/document_type/mod.rs | 8 +- .../document_type/property_constraints/mod.rs | 127 ++++++++++++++---- .../property_constraints/tests.rs | 106 ++++++++++++++- .../src/data_contract/document_type/v2/mod.rs | 14 +- .../tests/document/property_constraints.rs | 73 ++++++++-- .../rs-platform-version/src/version/v14.rs | 14 +- 11 files changed, 366 insertions(+), 69 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 03dd117afa7..9e386f3e96e 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -711,7 +711,7 @@ The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeas - a comparison, `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` or `greaterThanOrEqual`, listing the left and the right expression; - `{ "in": [expression, [values]] }`, holding if the integer expression takes one of two or more distinct integer values. It says what an `anyOf` of `equal`s says, in one node per value instead of three, so a set of up to 30 values fits the node limit where the `anyOf` fits 10. A value is a literal, never a path or an expression; -- a string comparison: `{ "equal": [path, { "const": "closed" }] }` or `notEqual`, with the constant on either side, or `{ "in": [path, ["open", "pending"]] }`, whose values are two or more distinct strings. The path names a string property, typically one with an `enum`. A string on its own is a path, so a constant is written as `{ "const": ... }`, while the values an `in` lists are literals and need no wrapper. Strings are only compared for equality, never ordered or used in arithmetic. A string property the document leaves out equals no constant, so `notEqual` holds for it and `equal` and `in` do not; `present` and `absent` test it directly. When the property declares an `enum`, every constant compared with it must be one of the enum's values, so a misspelling is refused at registration rather than making the rule quietly never hold; +- a string comparison: `{ "equal": [path, { "const": "closed" }] }` or `notEqual`, with the constant on either side; `{ "notEqual": ["fromCurrency", "toCurrency"] }`, two bare paths that both name string properties, which compares their strings; or `{ "in": [path, ["open", "pending"]] }`, whose values are two or more distinct strings. The path names a string property, typically one with an `enum`. A string on its own is a path, so a constant is written as `{ "const": ... }`, while the values an `in` lists are literals and need no wrapper. Strings are only compared for equality, never ordered or used in arithmetic. A string property the document leaves out equals no constant and no other string property, not even one also left out, so `notEqual` holds for it and `equal` and `in` do not; `present` and `absent` test it directly. When the property declares an `enum`, every constant compared with it must be one of the enum's values, so a misspelling is refused at registration rather than making the rule quietly never hold; - `{ "present": path }`, holding if the document holds the property, and `{ "absent": path }`, holding if it leaves it out (a property set to null counts as left out). An operand reads a property the document leaves out as 0, so only these tell "not given" from "given as 0". They may name a property of any type, an object or a member of one included, since they read no value; - `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; - `{ "allOf": [...] }`, holding if every one of two or more conditions holds; diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 36eedcf5339..b3840eaba02 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -425,7 +425,7 @@ From protocol version 14 a document type can declare rules its documents' proper } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 62198d6a458..4b637dc86e5 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's properties, comparisons between integer expressions or of a string property with const strings, in (value membership) and present or absent tests combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's properties, comparisons between integer expressions, of a string property with const strings or of two string properties, in (value membership) and present or absent tests combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "referenceOperands": { @@ -40,7 +40,7 @@ } }, "propertyConstraint": { - "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right (equal and notEqual may instead compare the path of a string property with a const string), in listing an expression and the values it may take, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", + "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right (equal and notEqual may instead compare the path of a string property with a const string or with the path of another string property), in listing an expression and the values it may take, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", "type": "object", "properties": { "equal": { @@ -136,7 +136,7 @@ "uniqueItems": true }, "propertyConstraintExpression": { - "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const; or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; const, a string constant compared with a string property", + "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; const, a string constant compared with a string property", "type": [ "integer", "string", @@ -2064,7 +2064,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round), in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. A string property the document leaves out equals no constant, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. A string property the document leaves out equals no constant and no other string property, not even one also left out, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 0ffb348a91c..bc30bf51782 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -1938,7 +1938,20 @@ fn apply_property_constraints_v0( full_validation: bool, platform_version: &PlatformVersion, ) -> Result<(), DataContractError> { - let constraints = parse_property_constraints(&document_type.schema, document_type_name)?; + let flattened_properties = &document_type.flattened_properties; + let is_string_property = |path: &str| { + matches!( + flattened_properties + .get(path) + .map(|property| &property.property_type), + Some(DocumentPropertyType::String(_)) + ) + }; + let constraints = parse_property_constraints( + &document_type.schema, + document_type_name, + &is_string_property, + )?; let structure_error = |message: String| { DataContractError::InvalidContractStructure(format!( "document type \"{document_type_name}\" propertyConstraints {message}" @@ -1972,8 +1985,9 @@ fn apply_property_constraints_v0( Some(DocumentPropertyType::String(_)) => { return Err(structure_error(format!( "rule \"{name}\" reads \"{path}\", which has type string, not integer \ - or boolean: a string property is compared with a {{ \"const\": ... }} \ - by equal or notEqual, or with the strings an in lists" + or boolean: a string property is compared, by equal or notEqual, \ + with a {{ \"const\": ... }} or another string property, or with the \ + strings an in lists" ))); } Some(other) => { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index 9a60d278ad8..01f97ad627a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -309,7 +309,8 @@ fn should_hold_string_comparisons_to_string_properties_and_their_enums() { ( json!({ "rule": { "equal": ["state", "price"] } }), "rule \"rule\" reads \"state\", which has type string, not integer or boolean: a \ - string property is compared with a { \"const\": ... }", + string property is compared, by equal or notEqual, with a { \"const\": ... } or \ + another string property", ), ( json!({ "rule": { "lessThan": ["state", { "const": "open" }] } }), @@ -338,6 +339,64 @@ fn should_hold_string_comparisons_to_string_properties_and_their_enums() { } } +/// Two bare paths naming string properties compare the strings, by `equal` or +/// `notEqual` only, on both paths; one string and one integer property stay an +/// integer comparison, refused for its string. +#[test] +fn should_compare_two_string_properties() { + let rules = json!({ + "noteIsNotTag": { "notEqual": ["note", "meta.tag"] }, + "stateIsNote": { "equal": ["state", "note"] } + }); + for full_validation in [true, false] { + let document_type = parse_order(rules.clone(), full_validation) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let constraints = document_type.property_constraints(); + assert_eq!( + constraints["noteIsNotTag"].property_reads(), + [ + ("note", PropertyRead::Text), + ("meta.tag", PropertyRead::Text) + ] + ); + assert_eq!( + constraints["stateIsNote"].property_reads(), + [("state", PropertyRead::Text), ("note", PropertyRead::Text)] + ); + + expect_structure_error( + parse_order( + json!({ "rule": { "lessThan": ["note", "state"] } }), + full_validation, + ), + "rule \"rule\" at lessThan compares two string properties, which only equal and \ + notEqual do", + ); + expect_structure_error( + parse_order( + json!({ "rule": { "equal": ["note", "price"] } }), + full_validation, + ), + "rule \"rule\" reads \"note\", which has type string, not integer or boolean", + ); + } + + let schema = order_schema( + Some(json!({ "rule": { "notEqual": ["state", "note"] } })), + Some("note"), + ); + for full_validation in [true, false] { + expect_structure_error( + parse_dispatched( + schema_value(schema.clone()), + PlatformVersion::latest(), + full_validation, + ), + "rule \"rule\" compares \"note\", which is transient or inside a transient object", + ); + } +} + /// A system property is not a property of the type: the meta-schema refuses /// its `$` when registering, and the parser the path when reading. #[test] diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 11cb7470224..d2707b755c6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -117,10 +117,10 @@ pub(crate) mod property_names { /// See `parse_doctype_reference` in `try_from_schema`. pub const CREATOR_REFERS_TO: &str = "creatorRefersTo"; /// Doctype-level object of named rules, each a condition on the document's - /// properties (a comparison of two integer expressions or of a string - /// property with string constants, an `in` list of values, a `present` or - /// `absent` test, or an `anyOf`, `allOf` or `not` of conditions) that every - /// created or replaced document must meet. + /// properties (a comparison of two integer expressions, of a string + /// property with string constants or of two string properties, an `in` + /// list of values, a `present` or `absent` test, or an `anyOf`, `allOf` or + /// `not` of conditions) that every created or replaced document must meet. /// Meta-schema v3+ (protocol version 14). See `parse_property_constraints` /// in `property_constraints`. pub const PROPERTY_CONSTRAINTS: &str = "propertyConstraints"; diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index fb6eba70325..d401946befe 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -3,8 +3,9 @@ //! condition on the document's properties: a comparison of two integer //! expressions, a test of whether an integer expression takes one of listed //! values (`in`), a comparison of a string property with string constants -//! (`equal`, `notEqual`, `in`), a test of whether the document holds a property -//! (`present`, `absent`), or `anyOf`, `allOf` or `not` over conditions. +//! (`equal`, `notEqual`, `in`) or with another string property (`equal`, +//! `notEqual`), a test of whether the document holds a property (`present`, +//! `absent`), or `anyOf`, `allOf` or `not` over conditions. //! //! ```json //! "propertyConstraints": { @@ -37,8 +38,9 @@ //! property with the value it takes when the document leaves it out. A //! property named on its own takes 0 when absent. A string constant is written //! `{ "const": "closed" }`, since a string on its own is a path; `equal` and -//! `notEqual` compare one with a string property, and an `in` whose values -//! are strings lists them bare. How the arithmetic +//! `notEqual` compare one with a string property, or two bare paths naming +//! string properties with each other, and an `in` whose values are strings +//! lists them bare. How the arithmetic //! treats overflow, division and powers is set out on //! [`ConstraintExpression::evaluate`], and how conditions combine on //! [`PropertyConstraint::holds`]. @@ -343,6 +345,16 @@ pub enum PropertyConstraint { path: String, value: String, }, + /// `equal` or `notEqual` between the string properties at two dotted + /// paths, `{ "notEqual": ["fromCurrency", "toCurrency"] }`: two bare paths + /// that both name string properties. `comparison` is `Equal` or `NotEqual`. + /// A string property the document leaves out equals no string, not even + /// another one it leaves out. + TextCompareProperties { + comparison: ConstraintComparison, + left: String, + right: String, + }, /// `in` over strings: the string property at the dotted path holds one of /// two or more distinct string constants, `{ "in": ["status", ["open", /// "pending"]] }`. One the document leaves out holds none of them. @@ -400,6 +412,17 @@ impl PropertyConstraint { let equal = text_value(data, path) == Some(value.as_str()); Ok(equal == (*comparison == ConstraintComparison::Equal)) } + PropertyConstraint::TextCompareProperties { + comparison, + left, + right, + } => { + let equal = matches!( + (text_value(data, left), text_value(data, right)), + (Some(left), Some(right)) if left == right + ); + Ok(equal == (*comparison == ConstraintComparison::Equal)) + } PropertyConstraint::TextIn { path, values } => { Ok(text_value(data, path).is_some_and(|text| values.contains(text))) } @@ -449,7 +472,8 @@ impl PropertyConstraint { } PropertyConstraint::In { operand, values } => operand.node_count() + values.len(), // The property and the constant, as a comparison of a path with a value - PropertyConstraint::TextCompare { .. } => 2, + PropertyConstraint::TextCompare { .. } + | PropertyConstraint::TextCompareProperties { .. } => 2, PropertyConstraint::TextIn { values, .. } => 1 + values.len(), PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => 0, PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { @@ -498,6 +522,7 @@ impl PropertyConstraint { PropertyConstraint::Not(condition) => condition.collect_text_constants(constants), PropertyConstraint::Compare { .. } | PropertyConstraint::In { .. } + | PropertyConstraint::TextCompareProperties { .. } | PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => {} } @@ -523,6 +548,7 @@ impl PropertyConstraint { PropertyConstraint::Compare { .. } | PropertyConstraint::In { .. } | PropertyConstraint::TextCompare { .. } + | PropertyConstraint::TextCompareProperties { .. } | PropertyConstraint::TextIn { .. } | PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => return None, @@ -564,6 +590,10 @@ impl PropertyConstraint { PropertyConstraint::In { operand, .. } => operand.collect_property_reads(reads), PropertyConstraint::TextCompare { path, .. } | PropertyConstraint::TextIn { path, .. } => reads.push((path, PropertyRead::Text)), + PropertyConstraint::TextCompareProperties { left, right, .. } => { + reads.push((left, PropertyRead::Text)); + reads.push((right, PropertyRead::Text)); + } PropertyConstraint::Present(path) | PropertyConstraint::Absent(path) => { reads.push((path, PropertyRead::Presence)) } @@ -579,32 +609,36 @@ impl PropertyConstraint { /// Reads the `propertyConstraints` keyword of a document type's `schema`: /// every rule by its name, in name order, the order a document is checked -/// against them. Empty when the schema declares none. +/// against them. Empty when the schema declares none. `is_string_property` +/// tells which dotted paths name string properties of the document type: a +/// comparison of two bare paths naming string properties compares strings, +/// any other comparison of two expressions integers. /// /// The rules of the declaration's shape are checked here, on every parse: an /// object of one or more rules, each named with 1 to 64 letters, digits or /// underscores and holding one condition. A condition is an object with one /// key: a comparison of exactly two operands, `in` with an operand and a list /// of two or more distinct integer values, `equal` or `notEqual` of a property -/// path and a `{ "const": string }`, `in` with a property path and two or -/// more distinct strings, `present` or `absent` with a -/// property path, `anyOf` or `allOf` with two or more conditions, none of them -/// directly the same operator (it says what one flat list says), or `not` with -/// one condition that is not directly another `not`. An operand is an integer -/// value, a property path, or an object with one key: `ifAbsent` with a path -/// and an integer value, `add` or `multiply` with two or more operands, or -/// `subtract`, `divide`, `modulo` or `power` with exactly two. An integer value -/// may be spelled as a float with no fractional part, as the meta-schema's -/// `integer` type admits one. A literal 0 divisor, a literal negative exponent, -/// a comparison or `in` that reads no property, which would hold for every -/// document or for none, and a condition or operand deeper than -/// [`MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH`] are refused. -/// What the paths name is checked against the parsed document type, and the -/// limits and that no list repeats a condition under full validation, by -/// parser generation 3. +/// path and a `{ "const": string }` or of two string properties, `in` with a +/// property path and two or more distinct strings, `present` or `absent` with +/// a property path, `anyOf` or `allOf` with two or more conditions, none of +/// them directly the same operator (it says what one flat list says), or +/// `not` with one condition that is not directly another `not`. An operand is +/// an integer value, a property path, or an object with one key: `ifAbsent` +/// with a path and an integer value, `add` or `multiply` with two or more +/// operands, or `subtract`, `divide`, `modulo` or `power` with exactly two. An +/// integer value may be spelled as a float with no fractional part, as the +/// meta-schema's `integer` type admits one. A literal 0 divisor, a literal +/// negative exponent, a comparison or `in` that reads no property, which would +/// hold for every document or for none, an ordering comparison of strings, +/// and a condition or operand deeper than +/// [`MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH`] are refused. What the paths name is +/// checked against the parsed document type, and the limits and that no list +/// repeats a condition under full validation, by parser generation 3. pub fn parse_property_constraints( schema: &Value, document_type_name: &str, + is_string_property: &dyn Fn(&str) -> bool, ) -> Result, DataContractError> { let structure_error = |message: String| { DataContractError::InvalidContractStructure(format!( @@ -641,7 +675,7 @@ pub fn parse_property_constraints( }; // Where a condition or an operand sits in the rule (`anyOf[1].lessThan[0]`), // grown and trimmed in place as the parse descends and only read into an error - let constraint = parse_condition(rule, &mut String::new(), 0) + let constraint = parse_condition(rule, &mut String::new(), 0, is_string_property) .map_err(|message| structure_error(format!("rule \"{name}\" {message}")))?; if constraints.insert(name.to_string(), constraint).is_some() { return Err(structure_error(format!("declares rule \"{name}\" twice"))); @@ -712,6 +746,7 @@ fn parse_condition( value: &Value, at: &mut String, depth: usize, + is_string_property: &dyn Fn(&str) -> bool, ) -> Result { if depth > MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH { return Err(format!( @@ -728,8 +763,20 @@ fn parse_condition( }; let parent = enter(at, key); let condition = match key { - ANY_OF => PropertyConstraint::AnyOf(condition_list(body, key, at, depth + 1)?), - ALL_OF => PropertyConstraint::AllOf(condition_list(body, key, at, depth + 1)?), + ANY_OF => PropertyConstraint::AnyOf(condition_list( + body, + key, + at, + depth + 1, + is_string_property, + )?), + ALL_OF => PropertyConstraint::AllOf(condition_list( + body, + key, + at, + depth + 1, + is_string_property, + )?), NOT => { if single_entry(body).is_some_and(|(inner, _)| inner == NOT) { return Err(format!( @@ -737,7 +784,12 @@ fn parse_condition( condition inside it says: declare that condition" )); } - PropertyConstraint::Not(Box::new(parse_condition(body, at, depth + 1)?)) + PropertyConstraint::Not(Box::new(parse_condition( + body, + at, + depth + 1, + is_string_property, + )?)) } IN => { let Some([operand, values]) = body.as_array().map(Vec::as_slice) else { @@ -818,6 +870,26 @@ fn parse_condition( ) }); } + // Two bare paths naming string properties compare the strings + if let (Some(left), Some(right)) = (left.as_text(), right.as_text()) { + if is_string_property(left) && is_string_property(right) { + if !matches!( + comparison, + ConstraintComparison::Equal | ConstraintComparison::NotEqual + ) { + return Err(format!( + "at {at} compares two string properties, which only equal and \ + notEqual do" + )); + } + at.truncate(parent); + return Ok(PropertyConstraint::TextCompareProperties { + comparison, + left: left.to_string(), + right: right.to_string(), + }); + } + } } let (left, right) = operand_pair(body, at, depth + 1)?; if !left.reads_property() && !right.reads_property() { @@ -847,6 +919,7 @@ fn condition_list( key: &str, at: &mut String, depth: usize, + is_string_property: &dyn Fn(&str) -> bool, ) -> Result, String> { let Some(values) = conditions.as_array().filter(|values| values.len() >= 2) else { return Err(format!("at {at} must list two or more conditions")); @@ -862,7 +935,7 @@ fn condition_list( says: list its conditions in the outer {key}" )); } - parsed.push(parse_condition(value, at, depth)?); + parsed.push(parse_condition(value, at, depth, is_string_property)?); at.truncate(base); } Ok(parsed) diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index a3dcac7eb60..be533ae29a8 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -2,10 +2,18 @@ use super::*; use platform_value::platform_value; use platform_version::version::PLATFORM_VERSIONS; +/// The paths the unit tests treat as string properties; every other path is an +/// integer one. +const STRING_PROPERTIES: [&str; 4] = ["status", "from", "to", "meta.state"]; + +fn is_string_property(path: &str) -> bool { + STRING_PROPERTIES.contains(&path) +} + /// The rules of a schema whose `propertyConstraints` is `declaration`. fn parse(declaration: Value) -> Result, DataContractError> { let schema = platform_value!({ "type": "object", "propertyConstraints": declaration }); - parse_property_constraints(&schema, "order") + parse_property_constraints(&schema, "order", &is_string_property) } /// The one rule of a declaration naming it `rule`. @@ -166,15 +174,19 @@ fn should_parse_every_operator_comparison_and_the_if_absent_operand() { #[test] fn should_read_nothing_from_a_schema_without_the_keyword() { let schema = platform_value!({ "type": "object" }); - assert!(parse_property_constraints(&schema, "order") - .expect("parses") - .is_empty()); - // A schema that is not an object is the core parser's to refuse assert!( - parse_property_constraints(&Value::Text("x".to_string()), "order") + parse_property_constraints(&schema, "order", &is_string_property) .expect("parses") .is_empty() ); + // A schema that is not an object is the core parser's to refuse + assert!(parse_property_constraints( + &Value::Text("x".to_string()), + "order", + &is_string_property + ) + .expect("parses") + .is_empty()); } #[test] @@ -927,6 +939,88 @@ fn should_count_and_list_what_a_string_comparison_reads() { ); } +/// Two bare paths that both name string properties compare the strings; anything else +/// between two expressions stays an integer comparison. +#[test] +fn should_parse_a_comparison_of_two_string_properties() { + assert_eq!( + parse_rule_value(platform_value!({ "equal": ["from", "to"] })), + PropertyConstraint::TextCompareProperties { + comparison: ConstraintComparison::Equal, + left: "from".to_string(), + right: "to".to_string(), + } + ); + assert_eq!( + parse_rule_value(platform_value!({ "notEqual": ["status", "meta.state"] })), + PropertyConstraint::TextCompareProperties { + comparison: ConstraintComparison::NotEqual, + left: "status".to_string(), + right: "meta.state".to_string(), + } + ); + // A string and an integer property, or a path inside an expression, stay integer + // comparisons, which the document type check refuses for the string + assert!(matches!( + parse_rule_value(platform_value!({ "equal": ["from", "price"] })), + PropertyConstraint::Compare { .. } + )); + assert!(matches!( + parse_rule_value(platform_value!({ "equal": [{ "ifAbsent": ["from", 0] }, "to"] })), + PropertyConstraint::Compare { .. } + )); + + expect_refusal( + platform_value!({ + "rule": { "anyOf": [{ "equal": ["fee", 1] }, { "lessThan": ["from", "to"] }] } + }), + "rule \"rule\" at anyOf[1].lessThan compares two string properties, which only equal and \ + notEqual do", + ); +} + +/// Two string properties are equal when the document holds the same string in both; one +/// it leaves out equals no string, not even another one it leaves out. +#[test] +fn should_compare_two_string_properties() { + let equal = parse_rule_value(platform_value!({ "equal": ["from", "to"] })); + let not_equal = parse_rule_value(platform_value!({ "notEqual": ["from", "to"] })); + let text = |value: &str| Value::Text(value.to_string()); + for (from, to, same) in [ + (Some(text("USD")), Some(text("USD")), true), + (Some(text("USD")), Some(text("EUR")), false), + (Some(text("USD")), Some(text("usd")), false), + (Some(text("")), Some(text("")), true), + (Some(text("USD")), None, false), + (None, None, false), + (Some(Value::Null), Some(Value::Null), false), + (Some(Value::U64(1)), Some(Value::U64(1)), false), + ] { + let mut entries = Vec::new(); + if let Some(from) = &from { + entries.push(("from", from.clone())); + } + if let Some(to) = &to { + entries.push(("to", to.clone())); + } + let values = data(&entries); + assert_eq!(equal.holds(&values), Ok(same), "equal, {from:?} {to:?}"); + assert_eq!( + not_equal.holds(&values), + Ok(!same), + "notEqual, {from:?} {to:?}" + ); + } + + // equal, from, to + assert_eq!(equal.node_count(), 3); + assert_eq!( + equal.property_reads(), + [("from", PropertyRead::Text), ("to", PropertyRead::Text)] + ); + assert!(equal.text_constants().is_empty()); +} + // ── present and absent ────────────────────────────────────────────────── #[test] diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 733ee158cc9..b1ad11d591c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -169,13 +169,13 @@ pub struct DocumentTypeV2 { /// The rules every created or replaced document must meet, by name, in the /// order they are checked (`propertyConstraints` keyword, protocol version /// 14): each a condition on the document's properties, a comparison of two - /// integer expressions or of a string property with string constants, an - /// `in` list of values, a `present` or `absent` test, or an `anyOf`, `allOf` - /// or `not` of conditions. Empty on document types that declare none. The - /// parser (`apply_property_constraints`) holds every property an operand - /// reads to be an integer or a boolean, every property compared with strings - /// to be a string, and every property a rule reads to be neither transient - /// nor inside a transient object. + /// integer expressions, of a string property with string constants or of + /// two string properties, an `in` list of values, a `present` or `absent` + /// test, or an `anyOf`, `allOf` or `not` of conditions. Empty on document + /// types that declare none. The parser (`apply_property_constraints`) holds + /// every property an operand reads to be an integer or a boolean, every + /// property compared with strings to be a string, and every property a + /// rule reads to be neither transient nor inside a transient object. pub(in crate::data_contract) property_constraints: BTreeMap, /// How many seconds after its creation (`$createdAt`) the platform deletes each /// document of the type (`ttl` keyword, protocol version 14), `None` when the diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index 32e5cfb15c8..f65b8f299fd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -1,12 +1,12 @@ //! End-to-end coverage for the `propertyConstraints` doctype keyword (protocol -//! version 14): a document type names rules its documents' integer properties -//! must meet, each a comparison of two integer expressions or of a string -//! property with string constants, an `in` list of values, a `present` or -//! `absent` test, or an `anyOf`, `allOf` or `not` of such conditions. A create or replace -//! that breaks one is consensus-rejected with -//! `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the rule -//! and why, and leaves the stored document untouched. A property the document -//! leaves out counts as 0, or as its `ifAbsent` value. +//! version 14): a document type names rules its documents' properties must +//! meet, each a comparison of two integer expressions, of a string property +//! with string constants or of two string properties, an `in` list of values, +//! a `present` or `absent` test, or an `anyOf`, `allOf` or `not` of such +//! conditions. A create or replace that breaks one is consensus-rejected with +//! `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the +//! rule and why, and leaves the stored document untouched. A property the +//! document leaves out counts as 0 in an operand, or as its `ifAbsent` value. use super::*; @@ -45,6 +45,7 @@ mod property_constraints_tests { /// * `feeWaivedOnlyWithDiscount`: `!(fee == 0 && discount == 0)` /// * `feeWaivedOrAtLeastTen`: `fee == 0 || fee >= 10` /// * `perUnitDeposit`: `deposit / quantity >= 1`, which divides by zero for no quantity + /// * `settlesInAnotherCurrency`: a `settleIn` currency, when given, is not `currency` /// * `tieredFee`: `fee` is one of 0, 10, 25 or 50 /// * `waivedFeeIsZero`: `waiveFee * fee == 0`, the boolean reading as 1 or 0 fn offer_schema() -> Value { @@ -65,7 +66,19 @@ mod property_constraints_tests { "maxLength": 9, "position": 7 }, - "closedAt": { "type": "integer", "minimum": 0, "position": 8 } + "closedAt": { "type": "integer", "minimum": 0, "position": 8 }, + "currency": { + "type": "string", + "enum": ["USD", "EUR", "DASH"], + "maxLength": 4, + "position": 9 + }, + "settleIn": { + "type": "string", + "enum": ["USD", "EUR", "DASH"], + "maxLength": 4, + "position": 10 + } }, "required": ["price", "fee", "quantity", "deposit"], "propertyConstraints": { @@ -109,6 +122,9 @@ mod property_constraints_tests { "perUnitDeposit": { "greaterThanOrEqual": [{ "divide": ["deposit", "quantity"] }, 1] }, + "settlesInAnotherCurrency": { + "anyOf": [{ "absent": "settleIn" }, { "notEqual": ["settleIn", "currency"] }] + }, "tieredFee": { "in": ["fee", [0, 10, 25, 50]] }, "waivedFeeIsZero": { "equal": [{ "multiply": ["waiveFee", "fee"] }, 0] } }, @@ -659,6 +675,45 @@ mod property_constraints_tests { assert_eq!(fixture.stored_offers().len(), 3); } + /// Two string properties compare their strings: an offer may not settle in + /// the currency it is priced in. A currency it leaves out equals none. + #[tokio::test] + async fn should_compare_two_string_properties() { + let mut fixture = OfferFixture::new(); + let currency = |value: &str| Value::Text(value.to_string()); + + let result = fixture + .create(|document| { + document.set("currency", currency("USD")); + document.set("settleIn", currency("USD")); + }) + .await; + expect_violated( + result, + "settlesInAnotherCurrency", + PropertyConstraintViolation::NotMet, + ); + assert!(fixture.stored_offers().is_empty()); + + assert_matches!( + fixture + .create(|document| { + document.set("currency", currency("USD")); + document.set("settleIn", currency("DASH")); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + // No price currency: the settlement currency differs from it + assert_matches!( + fixture + .create(|document| document.set("settleIn", currency("EUR"))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 2); + } + #[tokio::test] async fn should_judge_a_replace_against_the_rules() { let mut fixture = OfferFixture::new(); diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index fe6e40d8d3f..98245f4bac7 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1051,9 +1051,10 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// 1 for true and 0 for false) and `add`, `subtract`, `multiply`, /// `divide`, `modulo` and `power`; `in`, whether an integer expression /// takes one of two or more distinct integer values; `equal` or `notEqual` -/// of a string property and a `{ "const": string }`, or `in` of a string -/// property and two or more distinct strings, a string the document leaves -/// out equalling no constant; `present` or `absent` naming a property of +/// of a string property and a `{ "const": string }` or of two bare paths +/// naming string properties, or `in` of a string property and two or more +/// distinct strings, a string the document leaves out equalling no +/// constant and no other string; `present` or `absent` naming a property of /// any type, whether the document holds it (the one way to tell a property /// left out from one set to 0); `anyOf` or `allOf` over two or more /// conditions; or `not` over one. In an operand, a property the document @@ -1071,9 +1072,10 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// (whose `enum`, if it declares one, lists every constant it is compared /// with), and every path `present` or `absent` tests a property of any /// type, none transient nor inside a transient object; that every -/// comparison and `in` reads a property; that no `in` lists a value twice; -/// that an `anyOf` or `allOf` holds none directly of its own kind and a -/// `not` no `not`; and that no condition or operand nests deeper than +/// comparison and `in` reads a property; that strings are only compared +/// for equality; that no `in` lists a value twice; that an `anyOf` or +/// `allOf` holds none directly of its own kind and a `not` no `not`; and +/// that no condition or operand nests deeper than /// `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every parse. Under full /// validation it holds the limits `SystemLimits::max_property_constraints` /// (16 rules) and `max_property_constraint_nodes` (32 per rule, every From 3840c2fab89cd9d3a9940aae3e144d3f22cd8e41 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 16:19:17 +0700 Subject: [PATCH 021/113] feat(platform)!: string ifAbsent defaults in propertyConstraints rules (PV14) (#5046) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/documents.md | 4 +- packages/js-evo-sdk/README.md | 2 +- .../document/v3/document-meta.json | 10 +- .../class_methods/try_from_schema/mod.rs | 36 +- .../v3/property_constraints_tests.rs | 61 +++- .../document_type/property_constraints/mod.rs | 313 ++++++++++++------ .../property_constraints/tests.rs | 190 ++++++++++- .../tests/document/property_constraints.rs | 47 +++ .../rs-platform-version/src/version/v14.rs | 4 +- 9 files changed, 523 insertions(+), 144 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 9e386f3e96e..10d7e8b0198 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -711,7 +711,7 @@ The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeas - a comparison, `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` or `greaterThanOrEqual`, listing the left and the right expression; - `{ "in": [expression, [values]] }`, holding if the integer expression takes one of two or more distinct integer values. It says what an `anyOf` of `equal`s says, in one node per value instead of three, so a set of up to 30 values fits the node limit where the `anyOf` fits 10. A value is a literal, never a path or an expression; -- a string comparison: `{ "equal": [path, { "const": "closed" }] }` or `notEqual`, with the constant on either side; `{ "notEqual": ["fromCurrency", "toCurrency"] }`, two bare paths that both name string properties, which compares their strings; or `{ "in": [path, ["open", "pending"]] }`, whose values are two or more distinct strings. The path names a string property, typically one with an `enum`. A string on its own is a path, so a constant is written as `{ "const": ... }`, while the values an `in` lists are literals and need no wrapper. Strings are only compared for equality, never ordered or used in arithmetic. A string property the document leaves out equals no constant and no other string property, not even one also left out, so `notEqual` holds for it and `equal` and `in` do not; `present` and `absent` test it directly. When the property declares an `enum`, every constant compared with it must be one of the enum's values, so a misspelling is refused at registration rather than making the rule quietly never hold; +- a string comparison: `{ "equal": [path, { "const": "closed" }] }` or `notEqual`, with the constant on either side; `{ "notEqual": ["fromCurrency", "toCurrency"] }`, two bare paths that both name string properties, which compares their strings; or `{ "in": [path, ["open", "pending"]] }`, whose values are two or more distinct strings. The path names a string property, typically one with an `enum`. A string on its own is a path, so a constant is written as `{ "const": ... }`, while the values an `in` lists are literals and need no wrapper. Strings are only compared for equality, never ordered or used in arithmetic. A string property the document leaves out equals no constant and no other string property, not even one also left out, so `notEqual` holds for it and `equal` and `in` do not, unless `{ "ifAbsent": [path, "open"] }` gives it a string default, which it then reads as (it may stand wherever the bare path does, and makes the comparison one of strings); `present` and `absent` test it directly. When the property declares an `enum`, every constant compared with it must be one of the enum's values, so a misspelling is refused at registration rather than making the rule quietly never hold; - `{ "present": path }`, holding if the document holds the property, and `{ "absent": path }`, holding if it leaves it out (a property set to null counts as left out). An operand reads a property the document leaves out as 0, so only these tell "not given" from "given as 0". They may name a property of any type, an object or a member of one included, since they read no value; - `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; - `{ "allOf": [...] }`, holding if every one of two or more conditions holds; @@ -721,7 +721,7 @@ Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan - an integer value (`100`; a float with no fractional part, `100.0`, reads as that integer, as the meta-schema's `integer` type admits it); - a string, the dotted path of an integer or boolean property of the document type (`"price"`, `"meta.total"`, `"waiveFee"`), whose value it takes, 0 when the document leaves the property out. A boolean reads as 1 for true and 0 for false, so `{ "equal": [{ "multiply": ["waiveFee", "fee"] }, 0] }` says a waived fee is 0; -- `{ "ifAbsent": [path, value] }`, the property's value, or `value` when the document leaves it out; +- `{ "ifAbsent": [path, value] }`, the property's value, or `value` when the document leaves it out (an integer value here; a string value gives a string property a default in a string comparison instead); - `{ "add": [...] }` or `{ "multiply": [...] }` over two or more operands; - `{ "subtract": [a, b] }`, `{ "divide": [a, b] }`, `{ "modulo": [a, b] }` or `{ "power": [a, b] }`. diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index b3840eaba02..4862fe84a4b 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -425,7 +425,7 @@ From protocol version 14 a document type can declare rules its documents' proper } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 4b637dc86e5..18297dec8f7 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -136,7 +136,7 @@ "uniqueItems": true }, "propertyConstraintExpression": { - "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; const, a string constant compared with a string property", + "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out, an integer, or a string for a string property compared with strings; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; const, a string constant compared with a string property", "type": [ "integer", "string", @@ -155,13 +155,17 @@ "then": { "properties": { "ifAbsent": { + "description": "A property path and the value it takes when the document leaves the property out: an integer for an integer or boolean property, read as an integer operand, or a string for a string property, read as one side of a comparison of strings", "type": "array", "prefixItems": [ { "$ref": "#/$defs/propertyConstraintPath" }, { - "type": "integer" + "type": [ + "integer", + "string" + ] } ], "items": false, @@ -2064,7 +2068,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. A string property the document leaves out equals no constant and no other string property, not even one also left out, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index bc30bf51782..4e5cf9052b0 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -2045,25 +2045,24 @@ fn apply_property_constraints_v0( ))); } } - // A constant a string property's `enum` does not list is a typo: the - // property could never hold it + // A constant or a default a string property's `enum` does not list is a + // typo: the property could never hold it for (path, constant) in constraint.text_constants() { - let Some(property_schema) = schema_at_path(&document_type.schema, path)? else { - continue; - }; - let Some(Value::Array(members)) = property_schema.get(property_names::ENUM) else { - continue; - }; - if !members - .iter() - .any(|member| member.as_text() == Some(constant)) - { + if !enum_admits(&document_type.schema, path, constant)? { return Err(structure_error(format!( "rule \"{name}\" compares \"{path}\" with \"{constant}\", which is not one of \ its enum values" ))); } } + for (path, default) in constraint.text_defaults() { + if !enum_admits(&document_type.schema, path, default)? { + return Err(structure_error(format!( + "rule \"{name}\" gives \"{path}\" the default \"{default}\", which is not \ + one of its enum values" + ))); + } + } } if full_validation { @@ -2095,6 +2094,19 @@ fn apply_property_constraints_v0( Ok(()) } +/// Whether the string property at the dotted `path` of `schema`, a document +/// type's, may hold `value`: always, unless it declares an `enum` that does not +/// list it. +fn enum_admits(schema: &Value, path: &str, value: &str) -> Result { + let Some(property_schema) = schema_at_path(schema, path)? else { + return Ok(true); + }; + let Some(Value::Array(members)) = property_schema.get(property_names::ENUM) else { + return Ok(true); + }; + Ok(members.iter().any(|member| member.as_text() == Some(value))) +} + /// The schema of the property at the dotted `path` of `schema`, a document /// type's, `None` when the path names none. `$ref`s are followed. fn schema_at_path<'a>( diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index 01f97ad627a..ad769ed27e6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -314,8 +314,7 @@ fn should_hold_string_comparisons_to_string_properties_and_their_enums() { ), ( json!({ "rule": { "lessThan": ["state", { "const": "open" }] } }), - "rule \"rule\" at lessThan compares a string constant, which only equal and notEqual \ - do", + "rule \"rule\" at lessThan compares strings, which only equal and notEqual do", ), ] { for full_validation in [true, false] { @@ -369,8 +368,7 @@ fn should_compare_two_string_properties() { json!({ "rule": { "lessThan": ["note", "state"] } }), full_validation, ), - "rule \"rule\" at lessThan compares two string properties, which only equal and \ - notEqual do", + "rule \"rule\" at lessThan compares strings, which only equal and notEqual do", ); expect_structure_error( parse_order( @@ -397,6 +395,59 @@ fn should_compare_two_string_properties() { } } +/// An `ifAbsent` with a string default reads a string property on both paths, +/// in `equal`, `notEqual` and `in`; the default, like a constant, must be one +/// of the property's `enum` values, and the property a string. +#[test] +fn should_give_a_string_property_a_default() { + let rules = json!({ + "stateDefaultsOpen": { + "equal": [{ "ifAbsent": ["state", "open"] }, { "const": "open" }] + }, + "noteListed": { "in": [{ "ifAbsent": ["note", "x"] }, ["x", "y"]] }, + "tagIsNotNote": { "notEqual": [{ "ifAbsent": ["meta.tag", "t"] }, "note"] } + }); + for full_validation in [true, false] { + let document_type = parse_order(rules.clone(), full_validation) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let constraints = document_type.property_constraints(); + assert_eq!( + constraints["stateDefaultsOpen"].text_defaults(), + [("state", "open")] + ); + assert_eq!( + constraints["tagIsNotNote"].property_reads(), + [ + ("meta.tag", PropertyRead::Text), + ("note", PropertyRead::Text) + ] + ); + assert_eq!(constraints["noteListed"].property_paths(), ["note"]); + } + + for (rules, needle) in [ + ( + json!({ + "rule": { "equal": [{ "ifAbsent": ["state", "opne"] }, { "const": "open" }] } + }), + "rule \"rule\" gives \"state\" the default \"opne\", which is not one of its enum \ + values", + ), + ( + json!({ "rule": { "equal": [{ "ifAbsent": ["price", "x"] }, { "const": "x" }] } }), + "rule \"rule\" compares \"price\" with a string, but it has type", + ), + ( + json!({ "rule": { "equal": ["price", { "ifAbsent": ["note", "x"] }] } }), + "rule \"rule\" compares \"price\" with a string, but it has type", + ), + ] { + for full_validation in [true, false] { + expect_structure_error(parse_order(rules.clone(), full_validation), needle); + } + } +} + /// A system property is not a property of the type: the meta-schema refuses /// its `$` when registering, and the parser the path when reading. #[test] @@ -760,7 +811,7 @@ fn should_check_the_grammar_with_the_meta_schema_and_the_parser() { json!({ "rule": { "equal": [{ "ifAbsent": ["price"] }, 1] } }), json!({ "rule": { "equal": [{ "ifAbsent": ["price", 1, 2] }, 1] } }), json!({ "rule": { "equal": [{ "ifAbsent": [1, "price"] }, 1] } }), - json!({ "rule": { "equal": [{ "ifAbsent": ["price", "fee"] }, 1] } }), + json!({ "rule": { "equal": [{ "ifAbsent": ["price", true] }, 1] } }), json!({ "bad-name": { "equal": ["price", 1] } }), json!(["price"]), json!({ "rule": { "or": [{ "equal": ["price", 1] }, { "equal": ["fee", 1] }] } }), diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index d401946befe..2280a261ded 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -40,7 +40,8 @@ //! `{ "const": "closed" }`, since a string on its own is a path; `equal` and //! `notEqual` compare one with a string property, or two bare paths naming //! string properties with each other, and an `in` whose values are strings -//! lists them bare. How the arithmetic +//! lists them bare; `{ "ifAbsent": ["status", "open"] }` gives a string +//! property compared with strings a default. How the arithmetic //! treats overflow, division and powers is set out on //! [`ConstraintExpression::evaluate`], and how conditions combine on //! [`PropertyConstraint::holds`]. @@ -317,6 +318,33 @@ pub enum PropertyRead { Text, } +/// A string property a string comparison reads: its dotted path, and the +/// string it takes when the document leaves it out, from an `ifAbsent` with a +/// string default (`{ "ifAbsent": ["status", "open"] }`). A bare path has no +/// default: a property the document leaves out then equals no string, not even +/// another one it leaves out. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextProperty { + pub path: String, + pub if_absent: Option, +} + +impl TextProperty { + /// The string a document whose properties are `data` gives the property: + /// the one it holds, or `if_absent` when it leaves the property out or + /// sets it to null (where an operand takes its `ifAbsent` value). `None` + /// for a property left out without a default, or holding anything but a + /// string, which the schema validation running first refuses for a string + /// property. + fn value<'a>(&'a self, data: &'a Value) -> Option<&'a str> { + match data.get_optional_value_at_path(&self.path) { + Ok(Some(Value::Text(text))) => Some(text), + Ok(Some(Value::Null)) | Ok(None) | Err(_) => self.if_absent.as_deref(), + Ok(Some(_)) => None, + } + } +} + /// A rule of `propertyConstraints`, or a condition inside one: a comparison of /// two integer expressions, a test of whether an integer expression takes one /// of listed values, a comparison of a string property with string constants, @@ -336,30 +364,27 @@ pub enum PropertyConstraint { operand: ConstraintExpression, values: BTreeSet, }, - /// `equal` or `notEqual` between the string property at the dotted path and - /// a string constant, `{ "equal": ["status", { "const": "closed" }] }`, - /// written either way round. `comparison` is `Equal` or `NotEqual`. A - /// string property the document leaves out equals no constant. + /// `equal` or `notEqual` between a string property and a string constant, + /// `{ "equal": ["status", { "const": "closed" }] }`, written either way + /// round. `comparison` is `Equal` or `NotEqual`. TextCompare { comparison: ConstraintComparison, - path: String, + property: TextProperty, value: String, }, - /// `equal` or `notEqual` between the string properties at two dotted - /// paths, `{ "notEqual": ["fromCurrency", "toCurrency"] }`: two bare paths - /// that both name string properties. `comparison` is `Equal` or `NotEqual`. - /// A string property the document leaves out equals no string, not even - /// another one it leaves out. + /// `equal` or `notEqual` between two string properties, + /// `{ "notEqual": ["fromCurrency", "toCurrency"] }`: two bare paths that + /// both name string properties, or an `ifAbsent` with a string default on + /// either side. `comparison` is `Equal` or `NotEqual`. TextCompareProperties { comparison: ConstraintComparison, - left: String, - right: String, + left: TextProperty, + right: TextProperty, }, - /// `in` over strings: the string property at the dotted path holds one of - /// two or more distinct string constants, `{ "in": ["status", ["open", - /// "pending"]] }`. One the document leaves out holds none of them. + /// `in` over strings: the string property holds one of two or more + /// distinct string constants, `{ "in": ["status", ["open", "pending"]] }`. TextIn { - path: String, + property: TextProperty, values: BTreeSet, }, /// `present`: the document holds the property at the dotted path. One it @@ -406,10 +431,10 @@ impl PropertyConstraint { } PropertyConstraint::TextCompare { comparison, - path, + property, value, } => { - let equal = text_value(data, path) == Some(value.as_str()); + let equal = property.value(data) == Some(value.as_str()); Ok(equal == (*comparison == ConstraintComparison::Equal)) } PropertyConstraint::TextCompareProperties { @@ -418,14 +443,14 @@ impl PropertyConstraint { right, } => { let equal = matches!( - (text_value(data, left), text_value(data, right)), + (left.value(data), right.value(data)), (Some(left), Some(right)) if left == right ); Ok(equal == (*comparison == ConstraintComparison::Equal)) } - PropertyConstraint::TextIn { path, values } => { - Ok(text_value(data, path).is_some_and(|text| values.contains(text))) - } + PropertyConstraint::TextIn { property, values } => Ok(property + .value(data) + .is_some_and(|text| values.contains(text))), PropertyConstraint::Present(path) => Ok(is_present(data, path)), PropertyConstraint::Absent(path) => Ok(!is_present(data, path)), PropertyConstraint::AnyOf(conditions) => { @@ -508,12 +533,59 @@ impl PropertyConstraint { constants } - fn collect_text_constants<'a>(&'a self, constants: &mut Vec<(&'a str, &'a str)>) { + /// Every string default an `ifAbsent` gives a string property, as the + /// property's dotted path and the default, in declared order. + pub fn text_defaults(&self) -> Vec<(&str, &str)> { + self.text_properties() + .into_iter() + .filter_map(|property| { + property + .if_absent + .as_deref() + .map(|default| (property.path.as_str(), default)) + }) + .collect() + } + + /// Every string property the rule's string comparisons read, in declared + /// order. + fn text_properties(&self) -> Vec<&TextProperty> { + let mut properties = Vec::new(); + self.collect_text_properties(&mut properties); + properties + } + + fn collect_text_properties<'a>(&'a self, properties: &mut Vec<&'a TextProperty>) { match self { - PropertyConstraint::TextCompare { path, value, .. } => constants.push((path, value)), - PropertyConstraint::TextIn { path, values } => { - constants.extend(values.iter().map(|value| (path.as_str(), value.as_str()))) + PropertyConstraint::TextCompare { property, .. } + | PropertyConstraint::TextIn { property, .. } => properties.push(property), + PropertyConstraint::TextCompareProperties { left, right, .. } => { + properties.push(left); + properties.push(right); + } + PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { + for condition in conditions { + condition.collect_text_properties(properties); + } } + PropertyConstraint::Not(condition) => condition.collect_text_properties(properties), + PropertyConstraint::Compare { .. } + | PropertyConstraint::In { .. } + | PropertyConstraint::Present(_) + | PropertyConstraint::Absent(_) => {} + } + } + + fn collect_text_constants<'a>(&'a self, constants: &mut Vec<(&'a str, &'a str)>) { + match self { + PropertyConstraint::TextCompare { + property, value, .. + } => constants.push((&property.path, value)), + PropertyConstraint::TextIn { property, values } => constants.extend( + values + .iter() + .map(|value| (property.path.as_str(), value.as_str())), + ), PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { for condition in conditions { condition.collect_text_constants(constants); @@ -588,11 +660,13 @@ impl PropertyConstraint { right.collect_property_reads(reads); } PropertyConstraint::In { operand, .. } => operand.collect_property_reads(reads), - PropertyConstraint::TextCompare { path, .. } - | PropertyConstraint::TextIn { path, .. } => reads.push((path, PropertyRead::Text)), + PropertyConstraint::TextCompare { property, .. } + | PropertyConstraint::TextIn { property, .. } => { + reads.push((&property.path, PropertyRead::Text)) + } PropertyConstraint::TextCompareProperties { left, right, .. } => { - reads.push((left, PropertyRead::Text)); - reads.push((right, PropertyRead::Text)); + reads.push((&left.path, PropertyRead::Text)); + reads.push((&right.path, PropertyRead::Text)); } PropertyConstraint::Present(path) | PropertyConstraint::Absent(path) => { reads.push((path, PropertyRead::Presence)) @@ -805,19 +879,17 @@ fn parse_condition( .and_then(|values| values.first()) .is_some_and(|first| first.as_text().is_some()); if over_strings { - let Some(path) = operand.as_text() else { + let Ok(TextSide::Property(property)) = text_side(operand, &format!("{at}[0]")) + else { return Err(format!( - "at {at}[0] must be a property path: an in over strings reads a string \ - property" + "at {at}[0] must be the path of a string property or an ifAbsent giving \ + one a string default: an in over strings reads a string property" )); }; at.push_str("[1]"); let values = in_text_values(values, at)?; at.truncate(base); - PropertyConstraint::TextIn { - path: path.to_string(), - values, - } + PropertyConstraint::TextIn { property, values } } else { at.push_str("[0]"); let operand = parse_expression(operand, at, depth + 1)?; @@ -859,7 +931,15 @@ fn parse_condition( )); }; if let Some([left, right]) = body.as_array().map(Vec::as_slice) { - if is_const(left) || is_const(right) { + // A const or an ifAbsent with a string default on either side, or + // two bare paths naming string properties, compare strings + let bare_string = |value: &Value| value.as_text().is_some_and(is_string_property); + if is_const(left) + || is_const(right) + || is_text_if_absent(left) + || is_text_if_absent(right) + || (bare_string(left) && bare_string(right)) + { let compare = text_comparison(comparison, left, right, at)?; at.truncate(parent); return compare.ok_or_else(|| { @@ -870,26 +950,6 @@ fn parse_condition( ) }); } - // Two bare paths naming string properties compare the strings - if let (Some(left), Some(right)) = (left.as_text(), right.as_text()) { - if is_string_property(left) && is_string_property(right) { - if !matches!( - comparison, - ConstraintComparison::Equal | ConstraintComparison::NotEqual - ) { - return Err(format!( - "at {at} compares two string properties, which only equal and \ - notEqual do" - )); - } - at.truncate(parent); - return Ok(PropertyConstraint::TextCompareProperties { - comparison, - left: left.to_string(), - right: right.to_string(), - }); - } - } } let (left, right) = operand_pair(body, at, depth + 1)?; if !left.reads_property() && !right.reads_property() { @@ -1000,10 +1060,70 @@ fn is_const(value: &Value) -> bool { single_entry(value).is_some_and(|(key, _)| key == CONST) } -/// The comparison at `at` (`equal`) of `left` and `right`, at least one of -/// them a string constant: the other must be a property path, the constant a -/// string, and only `equal` and `notEqual` compare strings. `None` when both -/// are constants, a comparison that reads no property. +/// Whether `value` is `{ "ifAbsent": [path, string] }`: a string property with +/// the string it takes when the document leaves it out. +fn is_text_if_absent(value: &Value) -> bool { + single_entry(value).is_some_and(|(key, operands)| { + key == IF_ABSENT + && matches!( + operands.as_array().map(Vec::as_slice), + Some([_, Value::Text(_)]) + ) + }) +} + +/// One side of a comparison of strings. +enum TextSide { + /// A `{ "const": string }`. + Constant(String), + /// A string property: a bare path, or an `ifAbsent` with a string default. + Property(TextProperty), +} + +/// The side at `at` (`equal[1]`) of a comparison of strings: a `const` +/// string, a bare path, or an `ifAbsent` with a string default. What a path +/// names is checked against the parsed document type. +fn text_side(value: &Value, at: &str) -> Result { + if is_const(value) { + return single_entry(value) + .and_then(|(_, constant)| constant.as_text()) + .map(|constant| TextSide::Constant(constant.to_string())) + .ok_or_else(|| { + format!("at {at}.{CONST} must be a string: an integer is written as itself") + }); + } + if let Some(path) = value.as_text() { + return Ok(TextSide::Property(TextProperty { + path: path.to_string(), + if_absent: None, + })); + } + if let Some((IF_ABSENT, operands)) = single_entry(value) { + match operands.as_array().map(Vec::as_slice) { + Some([Value::Text(path), Value::Text(default)]) => { + return Ok(TextSide::Property(TextProperty { + path: path.clone(), + if_absent: Some(default.clone()), + })); + } + Some([_, Value::Text(_)]) => { + return Err(format!( + "at {at}.{IF_ABSENT} must name a property path first" + )); + } + _ => {} + } + } + Err(format!( + "at {at} must be the path of a string property, an ifAbsent giving one a string \ + default, or a const: strings are compared with strings" + )) +} + +/// The comparison at `at` (`equal`) of `left` and `right`, a comparison of +/// strings: only `equal` and `notEqual` compare them, and each side is a +/// `const` string or a string property ([`text_side`]). `None` when both are +/// constants, a comparison that reads no property. fn text_comparison( comparison: ConstraintComparison, left: &Value, @@ -1015,40 +1135,29 @@ fn text_comparison( ConstraintComparison::Equal | ConstraintComparison::NotEqual ) { return Err(format!( - "at {at} compares a string constant, which only equal and notEqual do" + "at {at} compares strings, which only equal and notEqual do" )); } - let constant = |value: &Value, index: usize| { - single_entry(value) - .and_then(|(_, constant)| constant.as_text()) - .map(str::to_string) - .ok_or_else(|| { - format!( - "at {at}[{index}].{CONST} must be a string: an integer is written as itself" - ) + let left = text_side(left, &format!("{at}[0]"))?; + let right = text_side(right, &format!("{at}[1]"))?; + Ok(match (left, right) { + (TextSide::Constant(_), TextSide::Constant(_)) => None, + (TextSide::Property(property), TextSide::Constant(value)) + | (TextSide::Constant(value), TextSide::Property(property)) => { + Some(PropertyConstraint::TextCompare { + comparison, + property, + value, }) - }; - let (path_side, path_index, constant_side, constant_index) = if is_const(right) { - (left, 0, right, 1) - } else { - (right, 1, left, 0) - }; - let value = constant(constant_side, constant_index)?; - if is_const(path_side) { - constant(path_side, path_index)?; - return Ok(None); - } - let Some(path) = path_side.as_text() else { - return Err(format!( - "at {at}[{path_index}] must be a property path: a string constant is compared with a \ - string property" - )); - }; - Ok(Some(PropertyConstraint::TextCompare { - comparison, - path: path.to_string(), - value, - })) + } + (TextSide::Property(left), TextSide::Property(right)) => { + Some(PropertyConstraint::TextCompareProperties { + comparison, + left, + right, + }) + } + }) } /// An operand at `at` (`lessThan[0].add[1]`), where the errors place it, @@ -1093,6 +1202,12 @@ fn parse_expression( let Some(path) = path.as_text() else { return Err(format!("at {at} must name a property path first")); }; + if if_absent.as_text().is_some() { + return Err(format!( + "at {at} gives a string default, which only a comparison of strings \ + takes, never an integer expression" + )); + } if !is_number(if_absent) { return Err(format!("at {at} must give an integer value second")); } @@ -1217,16 +1332,6 @@ fn integer_value(value: &Value, at: &str) -> Result { }) } -/// The string `data` holds at `path`, `None` when the document leaves the -/// property out or holds anything but a string there, which the schema -/// validation running first refuses for a string property. -fn text_value<'a>(data: &'a Value, path: &'a str) -> Option<&'a str> { - match data.get_optional_value_at_path(path) { - Ok(Some(Value::Text(text))) => Some(text), - _ => None, - } -} - /// Whether `data` holds the property at `path`: absent exactly where /// [`property_value`] would take the `if_absent` value. fn is_present(data: &Value, path: &str) -> bool { diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index be533ae29a8..4b3a5c6acaa 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -264,9 +264,17 @@ fn should_refuse_a_malformed_declaration() { "at equal[0].ifAbsent must name a property path first", ), ( - platform_value!({ "rule": { "equal": [{ "ifAbsent": ["price", "fee"] }, 1] } }), + platform_value!({ "rule": { "equal": [{ "ifAbsent": ["price", true] }, 1] } }), "at equal[0].ifAbsent must give an integer value second", ), + // A string default reads a string property, which arithmetic never takes + ( + platform_value!({ + "rule": { "equal": [{ "add": [{ "ifAbsent": ["price", "fee"] }, 1] }, 1] } + }), + "at equal[0].add[0].ifAbsent gives a string default, which only a comparison of \ + strings takes, never an integer expression", + ), ( platform_value!({ "rule": { "equal": [{ "add": [1, 2] }, 3] } }), "rule \"rule\" reads no property", @@ -752,10 +760,26 @@ fn should_hold_an_in_when_its_operand_takes_a_listed_value() { // ── strings ───────────────────────────────────────────────────────────── +/// A string property read without a default. +fn text(path: &str) -> TextProperty { + TextProperty { + path: path.to_string(), + if_absent: None, + } +} + +/// A string property read with a string default, `{ "ifAbsent": [path, default] }`. +fn text_or(path: &str, default: &str) -> TextProperty { + TextProperty { + path: path.to_string(), + if_absent: Some(default.to_string()), + } +} + fn text_compare(comparison: ConstraintComparison, path: &str, value: &str) -> PropertyConstraint { PropertyConstraint::TextCompare { comparison, - path: path.to_string(), + property: text(path), value: value.to_string(), } } @@ -775,7 +799,7 @@ fn should_parse_string_comparisons() { assert_eq!( parse_rule_value(platform_value!({ "in": ["status", ["pending", "open"]] })), PropertyConstraint::TextIn { - path: "status".to_string(), + property: text("status"), values: BTreeSet::from(["open".to_string(), "pending".to_string()]), } ); @@ -783,8 +807,7 @@ fn should_parse_string_comparisons() { for (condition, needle) in [ ( platform_value!({ "lessThan": ["status", { "const": "b" }] }), - "rule \"rule\" at lessThan compares a string constant, which only equal and notEqual \ - do", + "rule \"rule\" at lessThan compares strings, which only equal and notEqual do", ), ( platform_value!({ "equal": ["status", { "const": 5 }] }), @@ -806,12 +829,12 @@ fn should_parse_string_comparisons() { // The other side is a property, never an expression or a value ( platform_value!({ "equal": [{ "ifAbsent": ["status", 0] }, { "const": "a" }] }), - "rule \"rule\" at equal[0] must be a property path: a string constant is compared \ - with a string property", + "rule \"rule\" at equal[0] must be the path of a string property, an ifAbsent giving \ + one a string default, or a const", ), ( platform_value!({ "equal": [5, { "const": "a" }] }), - "rule \"rule\" at equal[0] must be a property path", + "rule \"rule\" at equal[0] must be the path of a string property", ), // A constant is no integer operand ( @@ -825,8 +848,8 @@ fn should_parse_string_comparisons() { ), ( platform_value!({ "in": [{ "add": ["status", 1] }, ["open", "closed"]] }), - "rule \"rule\" at in[0] must be a property path: an in over strings reads a string \ - property", + "rule \"rule\" at in[0] must be the path of a string property or an ifAbsent giving \ + one a string default: an in over strings reads a string property", ), ( platform_value!({ "in": ["status", ["open"]] }), @@ -947,16 +970,16 @@ fn should_parse_a_comparison_of_two_string_properties() { parse_rule_value(platform_value!({ "equal": ["from", "to"] })), PropertyConstraint::TextCompareProperties { comparison: ConstraintComparison::Equal, - left: "from".to_string(), - right: "to".to_string(), + left: text("from"), + right: text("to"), } ); assert_eq!( parse_rule_value(platform_value!({ "notEqual": ["status", "meta.state"] })), PropertyConstraint::TextCompareProperties { comparison: ConstraintComparison::NotEqual, - left: "status".to_string(), - right: "meta.state".to_string(), + left: text("status"), + right: text("meta.state"), } ); // A string and an integer property, or a path inside an expression, stay integer @@ -974,8 +997,7 @@ fn should_parse_a_comparison_of_two_string_properties() { platform_value!({ "rule": { "anyOf": [{ "equal": ["fee", 1] }, { "lessThan": ["from", "to"] }] } }), - "rule \"rule\" at anyOf[1].lessThan compares two string properties, which only equal and \ - notEqual do", + "rule \"rule\" at anyOf[1].lessThan compares strings, which only equal and notEqual do", ); } @@ -1021,6 +1043,142 @@ fn should_compare_two_string_properties() { assert!(equal.text_constants().is_empty()); } +/// `{ "ifAbsent": [path, string] }` is a string property with a default: it makes a +/// comparison one of strings wherever it sits, in `equal`, `notEqual` and `in`. +#[test] +fn should_parse_a_string_default() { + assert_eq!( + parse_rule_value(platform_value!({ + "equal": [{ "ifAbsent": ["status", "open"] }, { "const": "open" }] + })), + PropertyConstraint::TextCompare { + comparison: ConstraintComparison::Equal, + property: text_or("status", "open"), + value: "open".to_string(), + } + ); + assert_eq!( + parse_rule_value(platform_value!({ + "notEqual": [{ "ifAbsent": ["from", "USD"] }, "to"] + })), + PropertyConstraint::TextCompareProperties { + comparison: ConstraintComparison::NotEqual, + left: text_or("from", "USD"), + right: text("to"), + } + ); + // A default makes the comparison one of strings even beside a path the unit tests + // treat as an integer; the document type check refuses that path + assert_eq!( + parse_rule_value(platform_value!({ "equal": ["price", { "ifAbsent": ["to", "x"] }] })), + PropertyConstraint::TextCompareProperties { + comparison: ConstraintComparison::Equal, + left: text("price"), + right: text_or("to", "x"), + } + ); + assert_eq!( + parse_rule_value(platform_value!({ + "in": [{ "ifAbsent": ["status", "open"] }, ["open", "closed"]] + })), + PropertyConstraint::TextIn { + property: text_or("status", "open"), + values: BTreeSet::from(["closed".to_string(), "open".to_string()]), + } + ); + + for (condition, needle) in [ + ( + platform_value!({ "lessThan": [{ "ifAbsent": ["status", "a"] }, "to"] }), + "rule \"rule\" at lessThan compares strings, which only equal and notEqual do", + ), + ( + platform_value!({ "equal": [{ "ifAbsent": [1, "a"] }, { "const": "a" }] }), + "rule \"rule\" at equal[0].ifAbsent must name a property path first", + ), + ( + platform_value!({ "equal": [{ "ifAbsent": ["status", "a"] }, 5] }), + "rule \"rule\" at equal[1] must be the path of a string property", + ), + ( + platform_value!({ "in": [{ "ifAbsent": ["status", 1] }, ["a", "b"]] }), + "rule \"rule\" at in[0] must be the path of a string property or an ifAbsent giving \ + one a string default", + ), + ( + platform_value!({ "in": [{ "ifAbsent": ["kind", "a"] }, [1, 2]] }), + "rule \"rule\" at in[0].ifAbsent gives a string default, which only a comparison of \ + strings takes", + ), + ] { + expect_refusal(platform_value!({ "rule": condition }), needle); + } +} + +/// A string default stands in for a property the document leaves out or sets to null, +/// never for one it holds; two properties left out with the same default are equal. +#[test] +fn should_read_a_string_default_for_a_property_left_out() { + let open = parse_rule_value(platform_value!({ + "equal": [{ "ifAbsent": ["status", "open"] }, { "const": "open" }] + })); + let listed = parse_rule_value(platform_value!({ + "in": [{ "ifAbsent": ["status", "open"] }, ["open", "pending"]] + })); + let text_value = |value: &str| Value::Text(value.to_string()); + for (status, is_open) in [ + (None, true), + (Some(Value::Null), true), + (Some(text_value("open")), true), + (Some(text_value("closed")), false), + // Held, so no default, and not a string, so no match + (Some(Value::U64(1)), false), + ] { + let values = match &status { + Some(value) => data(&[("status", value.clone())]), + None => data(&[]), + }; + assert_eq!(open.holds(&values), Ok(is_open), "equal, {status:?}"); + assert_eq!(listed.holds(&values), Ok(is_open), "in, {status:?}"); + } + + let same_default = parse_rule_value(platform_value!({ + "equal": [{ "ifAbsent": ["from", "USD"] }, { "ifAbsent": ["to", "USD"] }] + })); + assert_eq!(same_default.holds(&data(&[])), Ok(true)); + assert_eq!( + same_default.holds(&data(&[("to", text_value("USD"))])), + Ok(true) + ); + assert_eq!( + same_default.holds(&data(&[("to", text_value("EUR"))])), + Ok(false) + ); + // Without defaults, two properties left out are not equal + let bare = parse_rule_value(platform_value!({ "equal": ["from", "to"] })); + assert_eq!(bare.holds(&data(&[])), Ok(false)); + + // A default is part of the node it sits in, and listed for the enum check apart + // from the constants compared + assert_eq!(open.node_count(), 3); + assert_eq!(open.text_constants(), [("status", "open")]); + assert_eq!(open.text_defaults(), [("status", "open")]); + assert_eq!( + same_default.text_defaults(), + [("from", "USD"), ("to", "USD")] + ); + assert!(bare.text_defaults().is_empty()); + + // A default makes a different condition from the bare path + let rule = parse_rule_value(platform_value!({ + "anyOf": [ + { "equal": ["status", { "const": "open" }] }, + { "equal": [{ "ifAbsent": ["status", "open"] }, { "const": "open" }] } + ] + })); + assert_eq!(rule.repeated_condition(), None); +} + // ── present and absent ────────────────────────────────────────────────── #[test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index f65b8f299fd..a49a34cf607 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -42,6 +42,8 @@ mod property_constraints_tests { /// * `depositCoversOrder`: `(price + fee) * quantity <= deposit` /// * `discountBelowPrice`: `discount < price`, an absent discount counting as 0 /// * `discountGivenAboveZero`: `discount` is absent or above 0 + /// * `discountOnlyWhileOpen`: a discount only on an open offer, a status left out + /// counting as open /// * `feeWaivedOnlyWithDiscount`: `!(fee == 0 && discount == 0)` /// * `feeWaivedOrAtLeastTen`: `fee == 0 || fee >= 10` /// * `perUnitDeposit`: `deposit / quantity >= 1`, which divides by zero for no quantity @@ -113,6 +115,12 @@ mod property_constraints_tests { "discountGivenAboveZero": { "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] }, + "discountOnlyWhileOpen": { + "anyOf": [ + { "absent": "discount" }, + { "equal": [{ "ifAbsent": ["status", "open"] }, { "const": "open" }] } + ] + }, "feeWaivedOnlyWithDiscount": { "not": { "allOf": [{ "equal": ["fee", 0] }, { "equal": ["discount", 0] }] } }, @@ -714,6 +722,45 @@ mod property_constraints_tests { assert_eq!(fixture.stored_offers().len(), 2); } + /// A string default stands in for a status the offer leaves out: a discount + /// is allowed with no status, as on an open offer, but not on a closed one. + #[tokio::test] + async fn should_read_a_string_default_for_a_property_left_out() { + let mut fixture = OfferFixture::new(); + let status = |value: &str| Value::Text(value.to_string()); + + let result = fixture + .create(|document| { + document.set("discount", Value::U64(10)); + document.set("status", status("closed")); + document.set("closedAt", Value::U64(1000)); + }) + .await; + expect_violated( + result, + "discountOnlyWhileOpen", + PropertyConstraintViolation::NotMet, + ); + assert!(fixture.stored_offers().is_empty()); + + assert_matches!( + fixture + .create(|document| document.set("discount", Value::U64(10))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_matches!( + fixture + .create(|document| { + document.set("discount", Value::U64(10)); + document.set("status", status("open")); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 2); + } + #[tokio::test] async fn should_judge_a_replace_against_the_rules() { let mut fixture = OfferFixture::new(); diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 98245f4bac7..c264b1c52b3 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1054,7 +1054,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// of a string property and a `{ "const": string }` or of two bare paths /// naming string properties, or `in` of a string property and two or more /// distinct strings, a string the document leaves out equalling no -/// constant and no other string; `present` or `absent` naming a property of +/// constant and no other string unless an `ifAbsent` gives it a string +/// default (`{ "ifAbsent": ["status", "open"] }`, whose default an `enum` +/// must list too); `present` or `absent` naming a property of /// any type, whether the document holds it (the one way to tell a property /// left out from one set to 0); `anyOf` or `allOf` over two or more /// conditions; or `not` over one. In an operand, a property the document From 3b190e9f47313257e30fbfe92b9c9e30aed18441 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 16:54:17 +0700 Subject: [PATCH 022/113] feat(platform)!: identifier comparisons in propertyConstraints rules (PV14) (#5047) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/documents.md | 1 + packages/js-evo-sdk/README.md | 2 +- .../document/v3/document-meta.json | 10 +- .../class_methods/try_from_schema/mod.rs | 55 ++- .../v3/property_constraints_tests.rs | 91 ++++- .../src/data_contract/document_type/mod.rs | 12 +- .../document_type/property_constraints/mod.rs | 326 +++++++++++++++--- .../property_constraints/tests.rs | 196 ++++++++++- .../src/data_contract/document_type/v2/mod.rs | 15 +- .../tests/document/property_constraints.rs | 99 +++++- .../src/version/system_limits/mod.rs | 2 +- .../rs-platform-version/src/version/v14.rs | 32 +- 12 files changed, 718 insertions(+), 123 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 10d7e8b0198..a6d734a4729 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -712,6 +712,7 @@ The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeas - a comparison, `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` or `greaterThanOrEqual`, listing the left and the right expression; - `{ "in": [expression, [values]] }`, holding if the integer expression takes one of two or more distinct integer values. It says what an `anyOf` of `equal`s says, in one node per value instead of three, so a set of up to 30 values fits the node limit where the `anyOf` fits 10. A value is a literal, never a path or an expression; - a string comparison: `{ "equal": [path, { "const": "closed" }] }` or `notEqual`, with the constant on either side; `{ "notEqual": ["fromCurrency", "toCurrency"] }`, two bare paths that both name string properties, which compares their strings; or `{ "in": [path, ["open", "pending"]] }`, whose values are two or more distinct strings. The path names a string property, typically one with an `enum`. A string on its own is a path, so a constant is written as `{ "const": ... }`, while the values an `in` lists are literals and need no wrapper. Strings are only compared for equality, never ordered or used in arithmetic. A string property the document leaves out equals no constant and no other string property, not even one also left out, so `notEqual` holds for it and `equal` and `in` do not, unless `{ "ifAbsent": [path, "open"] }` gives it a string default, which it then reads as (it may stand wherever the bare path does, and makes the comparison one of strings); `present` and `absent` test it directly. When the property declares an `enum`, every constant compared with it must be one of the enum's values, so a misspelling is refused at registration rather than making the rule quietly never hold; +- an identifier comparison, the same three forms for identifier properties: `{ "equal": ["paymentToken", { "const": "" }] }` or `notEqual`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. Constants are base58 identifiers of 32 bytes, checked at registration, and compared by their bytes, whatever form the document gives the identifier in. An identifier property the document leaves out equals no identifier, not even another one left out; identifiers take no `ifAbsent` default and are never ordered. The writer's `$ownerId` is not an operand: `distinctFrom` already keeps an identifier property apart from it; - `{ "present": path }`, holding if the document holds the property, and `{ "absent": path }`, holding if it leaves it out (a property set to null counts as left out). An operand reads a property the document leaves out as 0, so only these tell "not given" from "given as 0". They may name a property of any type, an object or a member of one included, since they read no value; - `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; - `{ "allOf": [...] }`, holding if every one of two or more conditions holds; diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 4862fe84a4b..775afd1226f 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -425,7 +425,7 @@ From protocol version 14 a document type can declare rules its documents' proper } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 18297dec8f7..318e33bb142 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's properties, comparisons between integer expressions, of a string property with const strings or of two string properties, in (value membership) and present or absent tests combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's properties, comparisons between integer expressions, of a string or identifier property with constants or with another property of its kind, in (value membership) and present or absent tests combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "referenceOperands": { @@ -40,7 +40,7 @@ } }, "propertyConstraint": { - "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right (equal and notEqual may instead compare the path of a string property with a const string or with the path of another string property), in listing an expression and the values it may take, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", + "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right (equal and notEqual may instead compare the path of a string property with a const string or with the path of another string property, and likewise the path of an identifier property with a const base58 identifier or another identifier property), in listing an expression and the values it may take, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", "type": "object", "properties": { "equal": { @@ -62,7 +62,7 @@ "$ref": "#/$defs/propertyConstraintOperandPair" }, "in": { - "description": "Holds if the expression listed first takes one of the values listed second: two or more, no two alike, all integers or all strings. With integers the expression is any integer expression; with strings it is the path of a string property, which one the document leaves out holds none of. It says what an anyOf of equal comparisons says, in one node per value rather than three", + "description": "Holds if the expression listed first takes one of the values listed second: two or more, no two alike, all integers or all strings. With integers the expression is any integer expression; with strings it is the path of a string property, which one the document leaves out holds none of, or the path of an identifier property, the strings then being base58 identifiers. It says what an anyOf of equal comparisons says, in one node per value rather than three", "type": "array", "prefixItems": [ { @@ -190,7 +190,7 @@ "$ref": "#/$defs/propertyConstraintOperandPair" }, "const": { - "description": "A string constant, only as one side of an equal or notEqual whose other side is the path of a string property: a string on its own is a path, and an integer is written as itself", + "description": "A constant, only as one side of an equal or notEqual whose other side is the path of a string property (a string) or of an identifier property (a base58 identifier): a string on its own is a path, and an integer is written as itself", "type": "string" } }, @@ -2068,7 +2068,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 4e5cf9052b0..67fb34b92d6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -2,7 +2,7 @@ use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::class_methods::apply_required_since::apply_required_since; use crate::data_contract::document_type::class_methods::parse_typed_array::parse_typed_array; use crate::data_contract::document_type::property_constraints::{ - parse_property_constraints, PropertyRead, + parse_property_constraints, EqualityKind, PropertyRead, }; use crate::data_contract::document_type::reference_lookup::{ MAX_LOOKUP_INDEX_NAME_LENGTH, MAX_LOOKUP_KEYS, MAX_LOOKUP_PATH_LENGTH, @@ -1939,19 +1939,16 @@ fn apply_property_constraints_v0( platform_version: &PlatformVersion, ) -> Result<(), DataContractError> { let flattened_properties = &document_type.flattened_properties; - let is_string_property = |path: &str| { - matches!( - flattened_properties - .get(path) - .map(|property| &property.property_type), - Some(DocumentPropertyType::String(_)) - ) + let property_kind = |path: &str| match flattened_properties + .get(path) + .map(|property| &property.property_type) + { + Some(DocumentPropertyType::String(_)) => Some(EqualityKind::Text), + Some(DocumentPropertyType::Identifier) => Some(EqualityKind::Identifier), + _ => None, }; - let constraints = parse_property_constraints( - &document_type.schema, - document_type_name, - &is_string_property, - )?; + let constraints = + parse_property_constraints(&document_type.schema, document_type_name, &property_kind)?; let structure_error = |message: String| { DataContractError::InvalidContractStructure(format!( "document type \"{document_type_name}\" propertyConstraints {message}" @@ -1963,7 +1960,7 @@ fn apply_property_constraints_v0( let reads = match read { PropertyRead::Value => "reads", PropertyRead::Presence => "tests the presence of", - PropertyRead::Text => "compares", + PropertyRead::Text | PropertyRead::Identifier => "compares", }; match read { PropertyRead::Value => match document_type @@ -1990,6 +1987,15 @@ fn apply_property_constraints_v0( strings an in lists" ))); } + // An identifier is compared with identifiers, never read as a number + Some(DocumentPropertyType::Identifier) => { + return Err(structure_error(format!( + "rule \"{name}\" reads \"{path}\", which has type identifier, not \ + integer or boolean: an identifier property is compared, by equal or \ + notEqual, with a {{ \"const\": base58 }} or another identifier \ + property, or with the identifiers an in lists" + ))); + } Some(other) => { return Err(structure_error(format!( "rule \"{name}\" reads \"{path}\", which has type {}, not integer or \ @@ -2036,6 +2042,27 @@ fn apply_property_constraints_v0( ))); } }, + PropertyRead::Identifier => match document_type + .flattened_properties + .get(path) + .map(|property| &property.property_type) + { + Some(DocumentPropertyType::Identifier) => {} + Some(other) => { + return Err(structure_error(format!( + "rule \"{name}\" compares \"{path}\" with an identifier, but it has \ + type {}, not identifier", + other.name() + ))); + } + None => { + return Err(structure_error(format!( + "rule \"{name}\" compares \"{path}\" with an identifier, but it is \ + not an identifier property of the document type (a nested one is \ + named by its dotted path)" + ))); + } + }, } if is_transient(DocumentTypeRef::V2(document_type), path) { return Err(structure_error(format!( diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index ad769ed27e6..7843e155f59 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -26,8 +26,9 @@ use serde_json::json; /// An `order` type: four required integers, an optional nested `meta` object /// with an integer `total`, a string, a number, a typed array and an integer -/// `code` to be refused as operands or listed as transient, a boolean `rush` -/// and a string `state` with an `enum`. +/// `code` to be refused as operands or listed as transient, a boolean `rush`, +/// a string `state` with an `enum` and two identifiers, `buyerId` and +/// `sellerId`. fn order_schema(rules: Option, transient: Option<&str>) -> serde_json::Value { let mut schema = json!({ "type": "object", @@ -61,6 +62,22 @@ fn order_schema(rules: Option, transient: Option<&str>) -> se "enum": ["open", "closed"], "maxLength": 10, "position": 10 + }, + "buyerId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 11 + }, + "sellerId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 12 } }, "required": ["price", "fee", "quantity", "deposit"], @@ -448,6 +465,74 @@ fn should_give_a_string_property_a_default() { } } +/// Identifier properties compare with base58 `const`s, with each other and +/// with the identifiers an `in` lists, on both paths, by `equal` and +/// `notEqual` only; an identifier read as a number, or compared with a string +/// property, is refused. +#[test] +fn should_compare_identifier_properties() { + let token = Identifier::new([7; 32]).to_string(Encoding::Base58); + let other = Identifier::new([8; 32]).to_string(Encoding::Base58); + let rules = json!({ + "boughtWithToken": { "equal": ["buyerId", { "const": token.clone() }] }, + "buyerIsNotSeller": { "notEqual": ["buyerId", "sellerId"] }, + "knownSeller": { "in": ["sellerId", [token.clone(), other.clone()]] } + }); + for full_validation in [true, false] { + let document_type = parse_order(rules.clone(), full_validation) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let constraints = document_type.property_constraints(); + assert_eq!( + constraints["buyerIsNotSeller"].property_reads(), + [ + ("buyerId", PropertyRead::Identifier), + ("sellerId", PropertyRead::Identifier) + ] + ); + assert_eq!(constraints["boughtWithToken"].property_paths(), ["buyerId"]); + assert_eq!(constraints["knownSeller"].property_paths(), ["sellerId"]); + } + + for (rules, needle) in [ + ( + json!({ "rule": { "lessThan": ["buyerId", "sellerId"] } }), + "rule \"rule\" at lessThan compares identifiers, which only equal and notEqual do", + ), + ( + json!({ "rule": { "equal": ["note", "buyerId"] } }), + "rule \"rule\" at equal compares a string property with an identifier property", + ), + ( + json!({ "rule": { "equal": ["buyerId", "price"] } }), + "rule \"rule\" reads \"buyerId\", which has type identifier, not integer or boolean: \ + an identifier property is compared", + ), + ( + json!({ "rule": { "equal": ["buyerId", { "const": "closed" }] } }), + "rule \"rule\" at equal[1].const holds \"closed\", which is not a base58 identifier", + ), + ] { + for full_validation in [true, false] { + expect_structure_error(parse_order(rules.clone(), full_validation), needle); + } + } + + let schema = order_schema( + Some(json!({ "rule": { "notEqual": ["buyerId", "sellerId"] } })), + Some("buyerId"), + ); + for full_validation in [true, false] { + expect_structure_error( + parse_dispatched( + schema_value(schema.clone()), + PlatformVersion::latest(), + full_validation, + ), + "rule \"rule\" compares \"buyerId\", which is transient or inside a transient object", + ); + } +} + /// A system property is not a property of the type: the meta-schema refuses /// its `$` when registering, and the parser the path when reading. #[test] @@ -901,6 +986,8 @@ fn should_refuse_property_constraints_before_protocol_version_14_and_ignore_them .remove("counts"); schema["properties"]["rush"]["position"] = json!(8); schema["properties"]["state"]["position"] = json!(9); + schema["properties"]["buyerId"]["position"] = json!(10); + schema["properties"]["sellerId"]["position"] = json!(11); let schema = schema_value(schema); let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13"); diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index d2707b755c6..19b86c847a4 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -117,12 +117,12 @@ pub(crate) mod property_names { /// See `parse_doctype_reference` in `try_from_schema`. pub const CREATOR_REFERS_TO: &str = "creatorRefersTo"; /// Doctype-level object of named rules, each a condition on the document's - /// properties (a comparison of two integer expressions, of a string - /// property with string constants or of two string properties, an `in` - /// list of values, a `present` or `absent` test, or an `anyOf`, `allOf` or - /// `not` of conditions) that every created or replaced document must meet. - /// Meta-schema v3+ (protocol version 14). See `parse_property_constraints` - /// in `property_constraints`. + /// properties (a comparison of two integer expressions, of a string or an + /// identifier property with constants or with another property of its + /// kind, an `in` list of values, a `present` or `absent` test, or an + /// `anyOf`, `allOf` or `not` of conditions) that every created or replaced + /// document must meet. Meta-schema v3+ (protocol version 14). See + /// `parse_property_constraints` in `property_constraints`. pub const PROPERTY_CONSTRAINTS: &str = "propertyConstraints"; pub const DISTINCT_FROM: &str = "distinctFrom"; pub const CONTRACT_ID: &str = "contractId"; diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index 2280a261ded..767a84ee84a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -2,10 +2,10 @@ //! version 14): named rules every document of the type must meet, each a //! condition on the document's properties: a comparison of two integer //! expressions, a test of whether an integer expression takes one of listed -//! values (`in`), a comparison of a string property with string constants -//! (`equal`, `notEqual`, `in`) or with another string property (`equal`, -//! `notEqual`), a test of whether the document holds a property (`present`, -//! `absent`), or `anyOf`, `allOf` or `not` over conditions. +//! values (`in`), a comparison of a string or an identifier property with +//! constants (`equal`, `notEqual`, `in`) or with another property of its kind +//! (`equal`, `notEqual`), a test of whether the document holds a property +//! (`present`, `absent`), or `anyOf`, `allOf` or `not` over conditions. //! //! ```json //! "propertyConstraints": { @@ -41,7 +41,9 @@ //! `notEqual` compare one with a string property, or two bare paths naming //! string properties with each other, and an `in` whose values are strings //! lists them bare; `{ "ifAbsent": ["status", "open"] }` gives a string -//! property compared with strings a default. How the arithmetic +//! property compared with strings a default. An identifier property compares +//! the same ways, its constants written base58, without defaults. How the +//! arithmetic //! treats overflow, division and powers is set out on //! [`ConstraintExpression::evaluate`], and how conditions combine on //! [`PropertyConstraint::holds`]. @@ -60,7 +62,8 @@ mod tests; use crate::consensus::basic::document::PropertyConstraintViolation; use crate::data_contract::document_type::property_names; use crate::data_contract::errors::DataContractError; -use platform_value::{Value, ValueMapHelper}; +use platform_value::string_encoding::Encoding; +use platform_value::{Identifier, Value, ValueMapHelper}; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; @@ -316,6 +319,22 @@ pub enum PropertyRead { Presence, /// By its value, compared with string constants: a string property. Text, + /// By its value, compared with identifier constants: an identifier + /// property. + Identifier, +} + +/// What a comparison of equality compares when it is not integers: strings or +/// identifiers. [`parse_property_constraints`] asks it of every bare path on +/// either side of an `equal` or `notEqual`, and of an `in`'s operand, since +/// the declaration alone does not tell a string property, an identifier +/// property or an integer one apart. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EqualityKind { + /// A string property. + Text, + /// An identifier property. + Identifier, } /// A string property a string comparison reads: its dotted path, and the @@ -387,6 +406,30 @@ pub enum PropertyConstraint { property: TextProperty, values: BTreeSet, }, + /// `equal` or `notEqual` between the identifier property at the dotted path + /// and an identifier constant, written base58, + /// `{ "equal": ["paymentToken", { "const": "" }] }`, either way + /// round. `comparison` is `Equal` or `NotEqual`. An identifier property the + /// document leaves out equals no identifier. + IdentifierCompare { + comparison: ConstraintComparison, + path: String, + value: Identifier, + }, + /// `equal` or `notEqual` between two identifier properties: two bare paths + /// that both name identifier properties. One the document leaves out equals + /// no identifier, not even another one it leaves out. + IdentifierCompareProperties { + comparison: ConstraintComparison, + left: String, + right: String, + }, + /// `in` over identifiers: the identifier property at the dotted path holds + /// one of two or more distinct identifiers, listed base58. + IdentifierIn { + path: String, + values: BTreeSet, + }, /// `present`: the document holds the property at the dotted path. One it /// leaves out, or sets to null, is absent, as it is for an operand. Unlike /// an operand, it tells a property left out from one set to 0, and it may @@ -451,6 +494,28 @@ impl PropertyConstraint { PropertyConstraint::TextIn { property, values } => Ok(property .value(data) .is_some_and(|text| values.contains(text))), + PropertyConstraint::IdentifierCompare { + comparison, + path, + value, + } => { + let equal = identifier_value(data, path) == Some(*value); + Ok(equal == (*comparison == ConstraintComparison::Equal)) + } + PropertyConstraint::IdentifierCompareProperties { + comparison, + left, + right, + } => { + let equal = matches!( + (identifier_value(data, left), identifier_value(data, right)), + (Some(left), Some(right)) if left == right + ); + Ok(equal == (*comparison == ConstraintComparison::Equal)) + } + PropertyConstraint::IdentifierIn { path, values } => { + Ok(identifier_value(data, path).is_some_and(|value| values.contains(&value))) + } PropertyConstraint::Present(path) => Ok(is_present(data, path)), PropertyConstraint::Absent(path) => Ok(!is_present(data, path)), PropertyConstraint::AnyOf(conditions) => { @@ -498,8 +563,11 @@ impl PropertyConstraint { PropertyConstraint::In { operand, values } => operand.node_count() + values.len(), // The property and the constant, as a comparison of a path with a value PropertyConstraint::TextCompare { .. } - | PropertyConstraint::TextCompareProperties { .. } => 2, + | PropertyConstraint::TextCompareProperties { .. } + | PropertyConstraint::IdentifierCompare { .. } + | PropertyConstraint::IdentifierCompareProperties { .. } => 2, PropertyConstraint::TextIn { values, .. } => 1 + values.len(), + PropertyConstraint::IdentifierIn { values, .. } => 1 + values.len(), PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => 0, PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { conditions.iter().map(PropertyConstraint::node_count).sum() @@ -571,6 +639,9 @@ impl PropertyConstraint { PropertyConstraint::Not(condition) => condition.collect_text_properties(properties), PropertyConstraint::Compare { .. } | PropertyConstraint::In { .. } + | PropertyConstraint::IdentifierCompare { .. } + | PropertyConstraint::IdentifierCompareProperties { .. } + | PropertyConstraint::IdentifierIn { .. } | PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => {} } @@ -595,6 +666,9 @@ impl PropertyConstraint { PropertyConstraint::Compare { .. } | PropertyConstraint::In { .. } | PropertyConstraint::TextCompareProperties { .. } + | PropertyConstraint::IdentifierCompare { .. } + | PropertyConstraint::IdentifierCompareProperties { .. } + | PropertyConstraint::IdentifierIn { .. } | PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => {} } @@ -622,6 +696,9 @@ impl PropertyConstraint { | PropertyConstraint::TextCompare { .. } | PropertyConstraint::TextCompareProperties { .. } | PropertyConstraint::TextIn { .. } + | PropertyConstraint::IdentifierCompare { .. } + | PropertyConstraint::IdentifierCompareProperties { .. } + | PropertyConstraint::IdentifierIn { .. } | PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => return None, PropertyConstraint::AnyOf(conditions) => (ANY_OF, conditions), @@ -668,6 +745,14 @@ impl PropertyConstraint { reads.push((&left.path, PropertyRead::Text)); reads.push((&right.path, PropertyRead::Text)); } + PropertyConstraint::IdentifierCompare { path, .. } + | PropertyConstraint::IdentifierIn { path, .. } => { + reads.push((path, PropertyRead::Identifier)) + } + PropertyConstraint::IdentifierCompareProperties { left, right, .. } => { + reads.push((left, PropertyRead::Identifier)); + reads.push((right, PropertyRead::Identifier)); + } PropertyConstraint::Present(path) | PropertyConstraint::Absent(path) => { reads.push((path, PropertyRead::Presence)) } @@ -683,36 +768,40 @@ impl PropertyConstraint { /// Reads the `propertyConstraints` keyword of a document type's `schema`: /// every rule by its name, in name order, the order a document is checked -/// against them. Empty when the schema declares none. `is_string_property` -/// tells which dotted paths name string properties of the document type: a -/// comparison of two bare paths naming string properties compares strings, -/// any other comparison of two expressions integers. +/// against them. Empty when the schema declares none. `property_kind` tells +/// which dotted paths name string or identifier properties of the document +/// type: an `equal` or `notEqual` with a `const`, or of two bare paths naming +/// such properties, compares strings or identifiers as they decide, as does +/// an `in` listing strings; any other comparison of two expressions compares +/// integers. /// /// The rules of the declaration's shape are checked here, on every parse: an /// object of one or more rules, each named with 1 to 64 letters, digits or /// underscores and holding one condition. A condition is an object with one /// key: a comparison of exactly two operands, `in` with an operand and a list /// of two or more distinct integer values, `equal` or `notEqual` of a property -/// path and a `{ "const": string }` or of two string properties, `in` with a -/// property path and two or more distinct strings, `present` or `absent` with -/// a property path, `anyOf` or `allOf` with two or more conditions, none of -/// them directly the same operator (it says what one flat list says), or -/// `not` with one condition that is not directly another `not`. An operand is -/// an integer value, a property path, or an object with one key: `ifAbsent` -/// with a path and an integer value, `add` or `multiply` with two or more -/// operands, or `subtract`, `divide`, `modulo` or `power` with exactly two. An -/// integer value may be spelled as a float with no fractional part, as the -/// meta-schema's `integer` type admits one. A literal 0 divisor, a literal -/// negative exponent, a comparison or `in` that reads no property, which would -/// hold for every document or for none, an ordering comparison of strings, -/// and a condition or operand deeper than -/// [`MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH`] are refused. What the paths name is -/// checked against the parsed document type, and the limits and that no list -/// repeats a condition under full validation, by parser generation 3. +/// and a `{ "const": ... }` (a string, or a base58 identifier for an +/// identifier property) or of two string or two identifier properties, `in` +/// with a property and two or more distinct strings or base58 identifiers, +/// `present` or `absent` with a property path, `anyOf` or `allOf` with two or +/// more conditions, none of them directly the same operator (it says what one +/// flat list says), or `not` with one condition that is not directly another +/// `not`. An operand is an integer value, a property path, or an object with +/// one key: `ifAbsent` with a path and an integer value, `add` or `multiply` +/// with two or more operands, or `subtract`, `divide`, `modulo` or `power` +/// with exactly two; a string property may take an `ifAbsent` with a string +/// default instead. An integer value may be spelled as a float with no +/// fractional part, as the meta-schema's `integer` type admits one. A literal +/// 0 divisor, a literal negative exponent, a comparison or `in` that reads no +/// property, which would hold for every document or for none, an ordering +/// comparison of strings or identifiers, and a condition or operand deeper +/// than [`MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH`] are refused. What the paths +/// name is checked against the parsed document type, and the limits and that +/// no list repeats a condition under full validation, by parser generation 3. pub fn parse_property_constraints( schema: &Value, document_type_name: &str, - is_string_property: &dyn Fn(&str) -> bool, + property_kind: &dyn Fn(&str) -> Option, ) -> Result, DataContractError> { let structure_error = |message: String| { DataContractError::InvalidContractStructure(format!( @@ -749,7 +838,7 @@ pub fn parse_property_constraints( }; // Where a condition or an operand sits in the rule (`anyOf[1].lessThan[0]`), // grown and trimmed in place as the parse descends and only read into an error - let constraint = parse_condition(rule, &mut String::new(), 0, is_string_property) + let constraint = parse_condition(rule, &mut String::new(), 0, property_kind) .map_err(|message| structure_error(format!("rule \"{name}\" {message}")))?; if constraints.insert(name.to_string(), constraint).is_some() { return Err(structure_error(format!("declares rule \"{name}\" twice"))); @@ -820,7 +909,7 @@ fn parse_condition( value: &Value, at: &mut String, depth: usize, - is_string_property: &dyn Fn(&str) -> bool, + property_kind: &dyn Fn(&str) -> Option, ) -> Result { if depth > MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH { return Err(format!( @@ -837,20 +926,12 @@ fn parse_condition( }; let parent = enter(at, key); let condition = match key { - ANY_OF => PropertyConstraint::AnyOf(condition_list( - body, - key, - at, - depth + 1, - is_string_property, - )?), - ALL_OF => PropertyConstraint::AllOf(condition_list( - body, - key, - at, - depth + 1, - is_string_property, - )?), + ANY_OF => { + PropertyConstraint::AnyOf(condition_list(body, key, at, depth + 1, property_kind)?) + } + ALL_OF => { + PropertyConstraint::AllOf(condition_list(body, key, at, depth + 1, property_kind)?) + } NOT => { if single_entry(body).is_some_and(|(inner, _)| inner == NOT) { return Err(format!( @@ -862,7 +943,7 @@ fn parse_condition( body, at, depth + 1, - is_string_property, + property_kind, )?)) } IN => { @@ -878,7 +959,19 @@ fn parse_condition( .as_array() .and_then(|values| values.first()) .is_some_and(|first| first.as_text().is_some()); - if over_strings { + // Strings listed for an identifier property are its identifiers, base58 + let identifier_path = operand.as_text().filter(|path| { + over_strings && property_kind(path) == Some(EqualityKind::Identifier) + }); + if let Some(path) = identifier_path { + at.push_str("[1]"); + let values = in_identifier_values(values, at)?; + at.truncate(base); + PropertyConstraint::IdentifierIn { + path: path.to_string(), + values, + } + } else if over_strings { let Ok(TextSide::Property(property)) = text_side(operand, &format!("{at}[0]")) else { return Err(format!( @@ -932,15 +1025,28 @@ fn parse_condition( }; if let Some([left, right]) = body.as_array().map(Vec::as_slice) { // A const or an ifAbsent with a string default on either side, or - // two bare paths naming string properties, compare strings - let bare_string = |value: &Value| value.as_text().is_some_and(is_string_property); + // two bare paths naming string or identifier properties, compare + // strings or identifiers, as the properties named decide + let bare_kind = |value: &Value| value.as_text().and_then(property_kind); if is_const(left) || is_const(right) || is_text_if_absent(left) || is_text_if_absent(right) - || (bare_string(left) && bare_string(right)) + || (bare_kind(left).is_some() && bare_kind(right).is_some()) { - let compare = text_comparison(comparison, left, right, at)?; + let compare = match (bare_kind(left), bare_kind(right)) { + (Some(left_kind), Some(right_kind)) if left_kind != right_kind => { + return Err(format!( + "at {at} compares a string property with an identifier \ + property" + )); + } + (Some(EqualityKind::Identifier), _) + | (_, Some(EqualityKind::Identifier)) => { + identifier_comparison(comparison, left, right, at)? + } + _ => text_comparison(comparison, left, right, at)?, + }; at.truncate(parent); return compare.ok_or_else(|| { format!( @@ -979,7 +1085,7 @@ fn condition_list( key: &str, at: &mut String, depth: usize, - is_string_property: &dyn Fn(&str) -> bool, + property_kind: &dyn Fn(&str) -> Option, ) -> Result, String> { let Some(values) = conditions.as_array().filter(|values| values.len() >= 2) else { return Err(format!("at {at} must list two or more conditions")); @@ -995,7 +1101,7 @@ fn condition_list( says: list its conditions in the outer {key}" )); } - parsed.push(parse_condition(value, at, depth, is_string_property)?); + parsed.push(parse_condition(value, at, depth, property_kind)?); at.truncate(base); } Ok(parsed) @@ -1060,6 +1166,111 @@ fn is_const(value: &Value) -> bool { single_entry(value).is_some_and(|(key, _)| key == CONST) } +/// The values an `in` over identifiers lists at `at` (`in[1]`): two or more +/// base58 identifiers, no two alike. +fn in_identifier_values(values: &Value, at: &mut String) -> Result, String> { + let Some(values) = values.as_array().filter(|values| values.len() >= 2) else { + return Err(format!("at {at} must list two or more identifiers")); + }; + let base = at.len(); + // Each value with the index it first appears at, for the errors + let mut seen = BTreeMap::new(); + for (index, value) in values.iter().enumerate() { + // Writing to a `String` cannot fail + let _ = write!(at, "[{index}]"); + let identifier = identifier_constant(value, at)?; + if let Some(earlier) = seen.insert(identifier, index) { + return Err(format!( + "at {at} repeats the value at {}[{earlier}]", + &at[..base] + )); + } + at.truncate(base); + } + Ok(seen.into_keys().collect()) +} + +/// The identifier a base58 string at `at` spells. +fn identifier_constant(value: &Value, at: &str) -> Result { + let Some(text) = value.as_text() else { + return Err(format!("at {at} must be an identifier, written base58")); + }; + Identifier::from_string(text, Encoding::Base58).map_err(|_| { + format!("at {at} holds \"{text}\", which is not a base58 identifier of 32 bytes") + }) +} + +/// One side of a comparison of identifiers. +enum IdentifierSide { + /// A `{ "const": base58 }`. + Constant(Identifier), + /// The dotted path of an identifier property. + Property(String), +} + +/// The side at `at` (`equal[1]`) of a comparison of identifiers: a `const` +/// identifier, written base58, or a bare path. What a path names is checked +/// against the parsed document type. +fn identifier_side(value: &Value, at: &str) -> Result { + if is_const(value) { + let constant = single_entry(value).map_or(value, |(_, constant)| constant); + return identifier_constant(constant, &format!("{at}.{CONST}")) + .map(IdentifierSide::Constant); + } + if let Some(path) = value.as_text() { + return Ok(IdentifierSide::Property(path.to_string())); + } + if is_text_if_absent(value) { + return Err(format!( + "at {at} gives an identifier property a default, which identifiers do not take" + )); + } + Err(format!( + "at {at} must be the path of an identifier property or a const: identifiers are \ + compared with identifiers" + )) +} + +/// The comparison at `at` (`equal`) of `left` and `right`, a comparison of +/// identifiers, one side at least naming an identifier property: only `equal` +/// and `notEqual` compare them, and each side is a `const` identifier or an +/// identifier property ([`identifier_side`]). +fn identifier_comparison( + comparison: ConstraintComparison, + left: &Value, + right: &Value, + at: &str, +) -> Result, String> { + if !matches!( + comparison, + ConstraintComparison::Equal | ConstraintComparison::NotEqual + ) { + return Err(format!( + "at {at} compares identifiers, which only equal and notEqual do" + )); + } + let left = identifier_side(left, &format!("{at}[0]"))?; + let right = identifier_side(right, &format!("{at}[1]"))?; + Ok(match (left, right) { + (IdentifierSide::Constant(_), IdentifierSide::Constant(_)) => None, + (IdentifierSide::Property(path), IdentifierSide::Constant(value)) + | (IdentifierSide::Constant(value), IdentifierSide::Property(path)) => { + Some(PropertyConstraint::IdentifierCompare { + comparison, + path, + value, + }) + } + (IdentifierSide::Property(left), IdentifierSide::Property(right)) => { + Some(PropertyConstraint::IdentifierCompareProperties { + comparison, + left, + right, + }) + } + }) +} + /// Whether `value` is `{ "ifAbsent": [path, string] }`: a string property with /// the string it takes when the document leaves it out. fn is_text_if_absent(value: &Value) -> bool { @@ -1332,6 +1543,17 @@ fn integer_value(value: &Value, at: &str) -> Result { }) } +/// The identifier `data` holds at `path`, in any of the forms a document's +/// identifier takes; `None` when the document leaves the property out or holds +/// something that is no identifier there, which the schema validation running +/// first refuses for an identifier property. +fn identifier_value(data: &Value, path: &str) -> Option { + match data.get_optional_value_at_path(path) { + Ok(Some(value)) => value.to_identifier().ok(), + _ => None, + } +} + /// Whether `data` holds the property at `path`: absent exactly where /// [`property_value`] would take the `if_absent` value. fn is_present(data: &Value, path: &str) -> bool { diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index 4b3a5c6acaa..d7fe6ebc598 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -2,18 +2,27 @@ use super::*; use platform_value::platform_value; use platform_version::version::PLATFORM_VERSIONS; -/// The paths the unit tests treat as string properties; every other path is an -/// integer one. +/// The paths the unit tests treat as string properties. const STRING_PROPERTIES: [&str; 4] = ["status", "from", "to", "meta.state"]; -fn is_string_property(path: &str) -> bool { - STRING_PROPERTIES.contains(&path) +/// The paths the unit tests treat as identifier properties; every path neither lists +/// is an integer one. +const IDENTIFIER_PROPERTIES: [&str; 3] = ["buyerId", "sellerId", "meta.ownerRef"]; + +fn property_kind(path: &str) -> Option { + if STRING_PROPERTIES.contains(&path) { + Some(EqualityKind::Text) + } else if IDENTIFIER_PROPERTIES.contains(&path) { + Some(EqualityKind::Identifier) + } else { + None + } } /// The rules of a schema whose `propertyConstraints` is `declaration`. fn parse(declaration: Value) -> Result, DataContractError> { let schema = platform_value!({ "type": "object", "propertyConstraints": declaration }); - parse_property_constraints(&schema, "order", &is_string_property) + parse_property_constraints(&schema, "order", &property_kind) } /// The one rule of a declaration naming it `rule`. @@ -174,19 +183,15 @@ fn should_parse_every_operator_comparison_and_the_if_absent_operand() { #[test] fn should_read_nothing_from_a_schema_without_the_keyword() { let schema = platform_value!({ "type": "object" }); + assert!(parse_property_constraints(&schema, "order", &property_kind) + .expect("parses") + .is_empty()); + // A schema that is not an object is the core parser's to refuse assert!( - parse_property_constraints(&schema, "order", &is_string_property) + parse_property_constraints(&Value::Text("x".to_string()), "order", &property_kind) .expect("parses") .is_empty() ); - // A schema that is not an object is the core parser's to refuse - assert!(parse_property_constraints( - &Value::Text("x".to_string()), - "order", - &is_string_property - ) - .expect("parses") - .is_empty()); } #[test] @@ -1179,6 +1184,169 @@ fn should_read_a_string_default_for_a_property_left_out() { assert_eq!(rule.repeated_condition(), None); } +// ── identifiers ───────────────────────────────────────────────────────── + +/// The identifier made of 32 copies of `byte`, and its base58 spelling. +fn identifier(byte: u8) -> (Identifier, String) { + let identifier = Identifier::new([byte; 32]); + let base58 = identifier.to_string(Encoding::Base58); + (identifier, base58) +} + +/// A `const` or a listed value compared with an identifier property is a base58 +/// identifier; two bare paths naming identifier properties compare identifiers. +#[test] +fn should_parse_identifier_comparisons() { + let (a, a58) = identifier(1); + let (b, b58) = identifier(2); + assert_eq!( + parse_rule_value(platform_value!({ "equal": ["buyerId", { "const": a58.clone() }] })), + PropertyConstraint::IdentifierCompare { + comparison: ConstraintComparison::Equal, + path: "buyerId".to_string(), + value: a, + } + ); + assert_eq!( + parse_rule_value(platform_value!({ + "notEqual": [{ "const": b58.clone() }, "meta.ownerRef"] + })), + PropertyConstraint::IdentifierCompare { + comparison: ConstraintComparison::NotEqual, + path: "meta.ownerRef".to_string(), + value: b, + } + ); + assert_eq!( + parse_rule_value(platform_value!({ "notEqual": ["buyerId", "sellerId"] })), + PropertyConstraint::IdentifierCompareProperties { + comparison: ConstraintComparison::NotEqual, + left: "buyerId".to_string(), + right: "sellerId".to_string(), + } + ); + assert_eq!( + parse_rule_value(platform_value!({ "in": ["sellerId", [b58.clone(), a58.clone()]] })), + PropertyConstraint::IdentifierIn { + path: "sellerId".to_string(), + values: BTreeSet::from([a, b]), + } + ); + // An identifier property beside an integer stays an integer comparison, which the + // document type check refuses for the identifier + assert!(matches!( + parse_rule_value(platform_value!({ "equal": ["buyerId", 5] })), + PropertyConstraint::Compare { .. } + )); + + for (condition, needle) in [ + ( + platform_value!({ "lessThan": ["buyerId", "sellerId"] }), + "rule \"rule\" at lessThan compares identifiers, which only equal and notEqual do", + ), + ( + platform_value!({ "notEqual": ["buyerId", "status"] }), + "rule \"rule\" at notEqual compares a string property with an identifier property", + ), + ( + platform_value!({ "equal": ["buyerId", { "const": "not base58!" }] }), + "rule \"rule\" at equal[1].const holds \"not base58!\", which is not a base58 \ + identifier of 32 bytes", + ), + ( + platform_value!({ "equal": ["buyerId", { "const": "2" }] }), + "rule \"rule\" at equal[1].const holds \"2\", which is not a base58 identifier of 32 \ + bytes", + ), + ( + platform_value!({ "equal": ["buyerId", { "const": 5 }] }), + "rule \"rule\" at equal[1].const must be an identifier, written base58", + ), + ( + platform_value!({ "equal": [{ "ifAbsent": ["buyerId", "x"] }, "sellerId"] }), + "rule \"rule\" at equal[0] gives an identifier property a default, which identifiers \ + do not take", + ), + ( + platform_value!({ "in": ["buyerId", [a58.clone()]] }), + "rule \"rule\" at in[1] must list two or more identifiers", + ), + ( + platform_value!({ "in": ["buyerId", [a58.clone(), "bad"]] }), + "rule \"rule\" at in[1][1] holds \"bad\", which is not a base58 identifier", + ), + ( + platform_value!({ "in": ["buyerId", [a58.clone(), 2]] }), + "rule \"rule\" at in[1][1] must be an identifier, written base58", + ), + ( + platform_value!({ "in": ["buyerId", [a58.clone(), b58.clone(), a58.clone()]] }), + "rule \"rule\" at in[1][2] repeats the value at in[1][0]", + ), + ] { + expect_refusal(platform_value!({ "rule": condition }), needle); + } +} + +/// An identifier property equals a constant or another one when both hold the same 32 +/// bytes, in whichever form the document gives them; one it leaves out equals nothing. +#[test] +fn should_compare_identifier_properties() { + let (a, a58) = identifier(1); + let (b, b58) = identifier(2); + let is_a = + parse_rule_value(platform_value!({ "equal": ["buyerId", { "const": a58.clone() }] })); + let listed = parse_rule_value(platform_value!({ "in": ["buyerId", [a58, b58]] })); + for (buyer, equals_a, is_listed) in [ + (Some(Value::Identifier(a.to_buffer())), true, true), + (Some(Value::Bytes32(a.to_buffer())), true, true), + (Some(Value::Bytes(a.to_vec())), true, true), + (Some(Value::Identifier(b.to_buffer())), false, true), + (Some(Value::Identifier([3; 32])), false, false), + (Some(Value::Null), false, false), + (Some(Value::U64(1)), false, false), + (None, false, false), + ] { + let values = match &buyer { + Some(value) => data(&[("buyerId", value.clone())]), + None => data(&[]), + }; + assert_eq!(is_a.holds(&values), Ok(equals_a), "equal, {buyer:?}"); + assert_eq!(listed.holds(&values), Ok(is_listed), "in, {buyer:?}"); + } + + let distinct = parse_rule_value(platform_value!({ "notEqual": ["buyerId", "sellerId"] })); + let pair = |buyer: Option, seller: Option| { + let mut entries = Vec::new(); + if let Some(buyer) = buyer { + entries.push(("buyerId", Value::Identifier(buyer.to_buffer()))); + } + if let Some(seller) = seller { + entries.push(("sellerId", Value::Bytes32(seller.to_buffer()))); + } + data(&entries) + }; + assert_eq!(distinct.holds(&pair(Some(a), Some(b))), Ok(true)); + assert_eq!(distinct.holds(&pair(Some(a), Some(a))), Ok(false)); + assert_eq!(distinct.holds(&pair(Some(a), None)), Ok(true)); + // Two identifiers left out are not equal + assert_eq!(distinct.holds(&pair(None, None)), Ok(true)); + + // A comparison of a path with a constant is three nodes, an in two plus one per value + assert_eq!(is_a.node_count(), 3); + assert_eq!(distinct.node_count(), 3); + assert_eq!(listed.node_count(), 4); + assert_eq!( + distinct.property_reads(), + [ + ("buyerId", PropertyRead::Identifier), + ("sellerId", PropertyRead::Identifier) + ] + ); + // Identifier constants are not string constants: no enum check reads them + assert!(is_a.text_constants().is_empty()); +} + // ── present and absent ────────────────────────────────────────────────── #[test] diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index b1ad11d591c..75ede731274 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -169,13 +169,14 @@ pub struct DocumentTypeV2 { /// The rules every created or replaced document must meet, by name, in the /// order they are checked (`propertyConstraints` keyword, protocol version /// 14): each a condition on the document's properties, a comparison of two - /// integer expressions, of a string property with string constants or of - /// two string properties, an `in` list of values, a `present` or `absent` - /// test, or an `anyOf`, `allOf` or `not` of conditions. Empty on document - /// types that declare none. The parser (`apply_property_constraints`) holds - /// every property an operand reads to be an integer or a boolean, every - /// property compared with strings to be a string, and every property a - /// rule reads to be neither transient nor inside a transient object. + /// integer expressions, of a string or an identifier property with + /// constants or with another property of its kind, an `in` list of values, + /// a `present` or `absent` test, or an `anyOf`, `allOf` or `not` of + /// conditions. Empty on document types that declare none. The parser + /// (`apply_property_constraints`) holds every property an operand reads to + /// be an integer or a boolean, every property compared with strings or + /// identifiers to be of that kind, and every property a rule reads to be + /// neither transient nor inside a transient object. pub(in crate::data_contract) property_constraints: BTreeMap, /// How many seconds after its creation (`$createdAt`) the platform deletes each /// document of the type (`ttl` keyword, protocol version 14), `None` when the diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index a49a34cf607..c85cb849b18 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -1,12 +1,13 @@ //! End-to-end coverage for the `propertyConstraints` doctype keyword (protocol //! version 14): a document type names rules its documents' properties must -//! meet, each a comparison of two integer expressions, of a string property -//! with string constants or of two string properties, an `in` list of values, -//! a `present` or `absent` test, or an `anyOf`, `allOf` or `not` of such -//! conditions. A create or replace that breaks one is consensus-rejected with -//! `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the -//! rule and why, and leaves the stored document untouched. A property the -//! document leaves out counts as 0 in an operand, or as its `ifAbsent` value. +//! meet, each a comparison of two integer expressions, of a string or an +//! identifier property with constants or with another property of its kind, +//! an `in` list of values, a `present` or `absent` test, or an `anyOf`, +//! `allOf` or `not` of such conditions. A create or replace that breaks one is +//! consensus-rejected with `DocumentPropertyConstraintViolatedError` (basic +//! code 10422), naming the rule and why, and leaves the stored document +//! untouched. A property the document leaves out counts as 0 in an operand, +//! or as its `ifAbsent` value. use super::*; @@ -23,6 +24,7 @@ mod property_constraints_tests { use dpp::document::DocumentV0Setters; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::platform_value::platform_value; + use dpp::platform_value::string_encoding::Encoding; use dpp::prelude::{DataContract, Identifier, IdentityNonce}; use dpp::state_transition::StateTransition; use dpp::tests::fixtures::get_data_contract_fixture; @@ -33,6 +35,9 @@ mod property_constraints_tests { use simple_signer::signer::SimpleSigner; use std::collections::{BTreeMap, BTreeSet}; + /// The bytes repeated into the token ids `paidInAcceptedToken` lists, base58. + const ACCEPTED_TOKENS: [u8; 2] = [7, 8]; + /// A mutable `offer` type whose rules, checked in name order, are: /// /// * `boostCapped`: `price * ifAbsent(boost, 1) <= 100000` @@ -46,11 +51,15 @@ mod property_constraints_tests { /// counting as open /// * `feeWaivedOnlyWithDiscount`: `!(fee == 0 && discount == 0)` /// * `feeWaivedOrAtLeastTen`: `fee == 0 || fee >= 10` + /// * `paidInAcceptedToken`: a `paymentToken`, when given, is one of [`ACCEPTED_TOKENS`] /// * `perUnitDeposit`: `deposit / quantity >= 1`, which divides by zero for no quantity + /// * `refundGoesToPayer`: a `refundTo`, when given, is the `payerId` /// * `settlesInAnotherCurrency`: a `settleIn` currency, when given, is not `currency` /// * `tieredFee`: `fee` is one of 0, 10, 25 or 50 /// * `waivedFeeIsZero`: `waiveFee * fee == 0`, the boolean reading as 1 or 0 fn offer_schema() -> Value { + let accepted_tokens = ACCEPTED_TOKENS + .map(|byte| Value::Text(Identifier::new([byte; 32]).to_string(Encoding::Base58))); platform_value!({ "type": "object", "documentsMutable": true, @@ -80,6 +89,30 @@ mod property_constraints_tests { "enum": ["USD", "EUR", "DASH"], "maxLength": 4, "position": 10 + }, + "payerId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 11 + }, + "refundTo": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 12 + }, + "paymentToken": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 13 } }, "required": ["price", "fee", "quantity", "deposit"], @@ -127,9 +160,18 @@ mod property_constraints_tests { "feeWaivedOrAtLeastTen": { "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] }, + "paidInAcceptedToken": { + "anyOf": [ + { "absent": "paymentToken" }, + { "in": ["paymentToken", accepted_tokens] } + ] + }, "perUnitDeposit": { "greaterThanOrEqual": [{ "divide": ["deposit", "quantity"] }, 1] }, + "refundGoesToPayer": { + "anyOf": [{ "absent": "refundTo" }, { "equal": ["refundTo", "payerId"] }] + }, "settlesInAnotherCurrency": { "anyOf": [{ "absent": "settleIn" }, { "notEqual": ["settleIn", "currency"] }] }, @@ -761,6 +803,49 @@ mod property_constraints_tests { assert_eq!(fixture.stored_offers().len(), 2); } + /// Identifier properties compare with the base58 identifiers an `in` lists + /// and with each other: a payment token must be an accepted one, and a + /// refund must go to the payer. + #[tokio::test] + async fn should_compare_identifier_properties() { + let mut fixture = OfferFixture::new(); + let identifier = |byte: u8| Value::Identifier([byte; 32]); + + let result = fixture + .create(|document| document.set("paymentToken", identifier(9))) + .await; + expect_violated( + result, + "paidInAcceptedToken", + PropertyConstraintViolation::NotMet, + ); + + let result = fixture + .create(|document| { + document.set("payerId", identifier(1)); + document.set("refundTo", identifier(2)); + }) + .await; + expect_violated( + result, + "refundGoesToPayer", + PropertyConstraintViolation::NotMet, + ); + assert!(fixture.stored_offers().is_empty()); + + assert_matches!( + fixture + .create(|document| { + document.set("paymentToken", identifier(ACCEPTED_TOKENS[1])); + document.set("payerId", identifier(1)); + document.set("refundTo", identifier(1)); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 1); + } + #[tokio::test] async fn should_judge_a_replace_against_the_rules() { let mut fixture = OfferFixture::new(); 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 531f3b171e9..9c3a3596cfe 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -55,7 +55,7 @@ pub struct SystemLimits { pub max_property_constraints: u16, /// Maximum number of nodes in one `propertyConstraints` rule: every comparison, every /// `in` and each value it lists, every `present` or `absent` and every `anyOf`, `allOf` or - /// `not`, every arithmetic operator and every operand, an integer value, a string `const` + /// `not`, every arithmetic operator and every operand, an integer value, a `const` /// or a property. An `ifAbsent` operand is one node, the default it gives included. /// Refused under full validation only, like `max_property_constraints`. Read by document /// type parser generation 3 (protocol version 14) and never reached before. diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index c264b1c52b3..ca7fc5994ca 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1056,12 +1056,15 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// distinct strings, a string the document leaves out equalling no /// constant and no other string unless an `ifAbsent` gives it a string /// default (`{ "ifAbsent": ["status", "open"] }`, whose default an `enum` -/// must list too); `present` or `absent` naming a property of -/// any type, whether the document holds it (the one way to tell a property -/// left out from one set to 0); `anyOf` or `allOf` over two or more -/// conditions; or `not` over one. In an operand, a property the document -/// leaves out counts as 0, or as the value of an `ifAbsent` operand naming -/// it. Arithmetic is exact `i128`: `divide` and `modulo` are Euclidean (the +/// must list too); `equal`, `notEqual` or `in` of an identifier property +/// likewise, with base58 identifier constants or another identifier +/// property and no default, an identifier the document leaves out +/// equalling none; `present` or `absent` naming a property of any type, +/// whether the document holds it (the one way to tell a property left out +/// from one set to 0); `anyOf` or `allOf` over two or more conditions; or +/// `not` over one. In an operand, a property the document leaves out +/// counts as 0, or as the value of an `ifAbsent` operand naming it. +/// Arithmetic is exact `i128`: `divide` and `modulo` are Euclidean (the /// remainder is never negative), and an overflow, a zero divisor, a /// negative exponent or a value that is not an integer refuses the /// document rather than wrapping. Conditions are checked in declared order @@ -1070,14 +1073,15 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// refuses the document whatever the others say, and `not` never turns a /// fault into a pass, so an earlier condition guards a later one. The /// parser checks that every path an operand reads names an integer or -/// boolean property, every path compared with strings a string property -/// (whose `enum`, if it declares one, lists every constant it is compared -/// with), and every path `present` or `absent` tests a property of any -/// type, none transient nor inside a transient object; that every -/// comparison and `in` reads a property; that strings are only compared -/// for equality; that no `in` lists a value twice; that an `anyOf` or -/// `allOf` holds none directly of its own kind and a `not` no `not`; and -/// that no condition or operand nests deeper than +/// boolean property, every path compared with identifiers an identifier +/// property, every path compared with strings a string property (whose +/// `enum`, if it declares one, lists every constant it is compared with), +/// and every path `present` or `absent` tests a property of any type, none +/// transient nor inside a transient object; that every comparison and `in` +/// reads a property; that strings and identifiers are only compared for +/// equality, and never with each other; that no `in` lists a value twice; +/// that an `anyOf` or `allOf` holds none directly of its own kind and a +/// `not` no `not`; and that no condition or operand nests deeper than /// `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every parse. Under full /// validation it holds the limits `SystemLimits::max_property_constraints` /// (16 rules) and `max_property_constraint_nodes` (32 per rule, every From 4d4f38d4704f4b108c00a83d714fe0c3c9a4b5eb Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 17:17:09 +0700 Subject: [PATCH 023/113] feat(platform)!: $ownerId comparisons in propertyConstraints rules (PV14) (#5048) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/documents.md | 4 +- packages/js-evo-sdk/README.md | 2 +- .../document/v3/document-meta.json | 8 +- .../class_methods/try_from_schema/mod.rs | 8 + .../v3/property_constraints_tests.rs | 96 ++++++- .../try_from_schema/v3/typed_array_tests.rs | 4 +- .../document_type/methods/mod.rs | 43 +++- .../methods/versioned_methods.rs | 44 +++- .../document_type/property_constraints/mod.rs | 126 +++++++-- .../property_constraints/tests.rs | 241 ++++++++++++++---- .../methods/validate_document/mod.rs | 5 +- .../methods/validate_document/v0/mod.rs | 24 +- .../v0/mod.rs | 4 +- .../advanced_structure_v0/mod.rs | 7 +- .../advanced_structure_v1/mod.rs | 7 +- .../advanced_structure_v0/mod.rs | 9 +- .../advanced_structure_v0/mod.rs | 18 +- .../advanced_structure_v0/mod.rs | 7 +- .../advanced_structure_v0/mod.rs | 18 +- .../tests/document/property_constraints.rs | 237 ++++++++++++++++- .../rs-platform-version/src/version/v14.rs | 90 ++++--- .../platform/moderation_charters/requests.rs | 1 + 22 files changed, 837 insertions(+), 166 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index a6d734a4729..c02f52fc6b2 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -712,7 +712,7 @@ The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeas - a comparison, `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` or `greaterThanOrEqual`, listing the left and the right expression; - `{ "in": [expression, [values]] }`, holding if the integer expression takes one of two or more distinct integer values. It says what an `anyOf` of `equal`s says, in one node per value instead of three, so a set of up to 30 values fits the node limit where the `anyOf` fits 10. A value is a literal, never a path or an expression; - a string comparison: `{ "equal": [path, { "const": "closed" }] }` or `notEqual`, with the constant on either side; `{ "notEqual": ["fromCurrency", "toCurrency"] }`, two bare paths that both name string properties, which compares their strings; or `{ "in": [path, ["open", "pending"]] }`, whose values are two or more distinct strings. The path names a string property, typically one with an `enum`. A string on its own is a path, so a constant is written as `{ "const": ... }`, while the values an `in` lists are literals and need no wrapper. Strings are only compared for equality, never ordered or used in arithmetic. A string property the document leaves out equals no constant and no other string property, not even one also left out, so `notEqual` holds for it and `equal` and `in` do not, unless `{ "ifAbsent": [path, "open"] }` gives it a string default, which it then reads as (it may stand wherever the bare path does, and makes the comparison one of strings); `present` and `absent` test it directly. When the property declares an `enum`, every constant compared with it must be one of the enum's values, so a misspelling is refused at registration rather than making the rule quietly never hold; -- an identifier comparison, the same three forms for identifier properties: `{ "equal": ["paymentToken", { "const": "" }] }` or `notEqual`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. Constants are base58 identifiers of 32 bytes, checked at registration, and compared by their bytes, whatever form the document gives the identifier in. An identifier property the document leaves out equals no identifier, not even another one left out; identifiers take no `ifAbsent` default and are never ordered. The writer's `$ownerId` is not an operand: `distinctFrom` already keeps an identifier property apart from it; +- an identifier comparison, the same three forms for identifier properties: `{ "equal": ["paymentToken", { "const": "" }] }` or `notEqual`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. Constants are base58 identifiers of 32 bytes, checked at registration, and compared by their bytes, whatever form the document gives the identifier in. An identifier property the document leaves out equals no identifier, not even another one left out; identifiers take no `ifAbsent` default and are never ordered. `$ownerId`, the document's owner, is an identifier operand too: `{ "equal": ["authorId", "$ownerId"] }` holds the author to the owner, and `{ "in": ["$ownerId", ["", ...]] }` lets only the identities listed own a document of the type. It is no property, so `present` or an integer operand refuses it, and comparing it with itself is refused. Since a transfer and a purchase give the document a new owner, each is judged against the rules reading `$ownerId`, with the new owner, and refused when it would break one; an indexOnly type refuses such a rule, since its deletes carry no owner; - `{ "present": path }`, holding if the document holds the property, and `{ "absent": path }`, holding if it leaves it out (a property set to null counts as left out). An operand reads a property the document leaves out as 0, so only these tell "not given" from "given as 0". They may name a property of any type, an object or a member of one included, since they read no value; - `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; - `{ "allOf": [...] }`, holding if every one of two or more conditions holds; @@ -734,7 +734,7 @@ Conditions are checked in declared order and no further than the outcome needs: The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type, every path compared with a string names a string property (and every constant compared with one that declares an `enum` is one of its values) (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `const`, every `present` or `absent`, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. -Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. Transfers, purchases and price updates change no property and are not judged. +Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. `$ownerId` reads the writer, passed in by create and replace (`validate_document_properties` takes the owner; a client that does not know it passes `None`, and `$ownerId` then equals no identifier). Transfers and purchases change no property but the owner, so only the rules reading `$ownerId` are judged again, with the new owner (`DocumentTypeV0Methods::validate_property_constraints_for_new_owner`, next to the `distinctFrom` check); price updates change neither and are not judged. In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, an `in`, a string comparison (`TextCompare`, `TextIn`), a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and whether by value or by presence), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 775afd1226f..9d3cf05b3c2 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -425,7 +425,7 @@ From protocol version 14 a document type can declare rules its documents' proper } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 318e33bb142..dd5bf9b5063 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -190,7 +190,7 @@ "$ref": "#/$defs/propertyConstraintOperandPair" }, "const": { - "description": "A constant, only as one side of an equal or notEqual whose other side is the path of a string property (a string) or of an identifier property (a base58 identifier): a string on its own is a path, and an integer is written as itself", + "description": "A constant, only as one side of an equal or notEqual whose other side is the path of a string property (a string), or of an identifier property or $ownerId (a base58 identifier): a string on its own is a path, and an integer is written as itself", "type": "string" } }, @@ -201,9 +201,9 @@ } }, "propertyConstraintPath": { - "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer or boolean property when an operand reads its value, any property when present or absent tests it", + "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer or boolean property when an operand reads its value, any property when present or absent tests it. Or $ownerId, the document's owner, which only a comparison of identifiers reads", "type": "string", - "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" + "pattern": "^(\\$ownerId|[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*)$" }, "propertyConstraintOperands": { "type": "array", @@ -2068,7 +2068,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 67fb34b92d6..5929a4cf14f 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -2072,6 +2072,14 @@ fn apply_property_constraints_v0( ))); } } + // An indexOnly type's delete carries its row's values but not its owner, so + // a rule reading the owner could not be judged there + if document_type.index_only && constraint.reads_owner() { + return Err(structure_error(format!( + "rule \"{name}\" compares $ownerId, which a delete of an indexOnly document \ + does not carry" + ))); + } // A constant or a default a string property's `enum` does not list is a // typo: the property could never hold it for (path, constant) in constraint.text_constants() { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index 7843e155f59..a6cd3452326 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -13,7 +13,7 @@ use crate::consensus::basic::basic_error::BasicError; use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; -use crate::data_contract::document_type::property_constraints::PropertyRead; +use crate::data_contract::document_type::property_constraints::{PropertyConstraint, PropertyRead}; use crate::data_contract::methods::validate_update::DataContractUpdateValidationMethodsV0; use crate::data_contract::DataContract; use crate::serialization::{ @@ -533,11 +533,88 @@ fn should_compare_identifier_properties() { } } -/// A system property is not a property of the type: the meta-schema refuses -/// its `$` when registering, and the parser the path when reading. +/// `$ownerId` compares as an identifier on both paths, and reads no property; +/// an indexOnly type refuses a rule reading it, since its deletes carry no +/// owner to judge the rule with. +#[test] +fn should_compare_the_owner_and_refuse_it_on_an_index_only_type() { + let writer = Identifier::new([7; 32]).to_string(Encoding::Base58); + let other = Identifier::new([8; 32]).to_string(Encoding::Base58); + let rules = json!({ + "buyerOwns": { "equal": ["buyerId", "$ownerId"] }, + "knownWriter": { "in": ["$ownerId", [writer, other]] }, + "sellerIsNotOwner": { "notEqual": ["sellerId", "$ownerId"] } + }); + for full_validation in [true, false] { + let document_type = parse_order(rules.clone(), full_validation) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let constraints = document_type.property_constraints(); + assert_eq!(constraints["buyerOwns"].property_paths(), ["buyerId"]); + assert!(constraints["knownWriter"].property_paths().is_empty()); + assert!(constraints.values().all(PropertyConstraint::reads_owner)); + } + + let index_only = |rule: serde_json::Value| { + schema_value(json!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "indices": [{ + "name": "byTopic", + "properties": [{ "topic": "asc" }, { "authorId": "asc" }] + }], + "properties": { + "topic": { "type": "string", "maxLength": 50, "position": 0 }, + "authorId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1 + } + }, + "required": ["topic", "authorId"], + "additionalProperties": false, + "propertyConstraints": { "rule": rule } + })) + }; + for full_validation in [true, false] { + expect_structure_error( + parse_dispatched( + index_only(json!({ "equal": ["authorId", "$ownerId"] })), + PlatformVersion::latest(), + full_validation, + ), + "rule \"rule\" compares $ownerId, which a delete of an indexOnly document does not \ + carry", + ); + parse_dispatched( + index_only(json!({ + "notEqual": ["topic", { "const": "x" }] + })), + PlatformVersion::latest(), + full_validation, + ) + .unwrap_or_else(|e| panic!("a rule not reading the owner registers: {e}")); + } +} + +/// `$ownerId` is the document's owner, which every document has, not a +/// property of the type: a presence test of it is refused on both paths, and +/// any other system property the meta-schema refuses when registering. #[test] fn should_refuse_a_presence_test_of_a_system_property() { - let rules = json!({ "rule": { "present": "$ownerId" } }); + for full_validation in [true, false] { + expect_structure_error( + parse_order( + json!({ "rule": { "present": "$ownerId" } }), + full_validation, + ), + "tests the presence of \"$ownerId\", which is not a property of the document type", + ); + } + let rules = json!({ "rule": { "present": "$createdAt" } }); let registered = parse_order(rules.clone(), true); assert!( registered.as_ref().is_err_and(is_json_schema_error), @@ -545,7 +622,7 @@ fn should_refuse_a_presence_test_of_a_system_property() { ); expect_structure_error( parse_order(rules, false), - "tests the presence of \"$ownerId\", which is not a property of the document type", + "tests the presence of \"$createdAt\", which is not a property of the document type", ); } @@ -648,14 +725,19 @@ fn should_refuse_a_rule_reading_anything_but_an_integer_property() { "$ownerId", "reads \"$ownerId\", which is not an integer or boolean property", ), + ( + "$createdAt", + "reads \"$createdAt\", which is not an integer or boolean property", + ), ] { for full_validation in [true, false] { let result = parse_order( json!({ "rule": { "lessThan": [operand, "price"] } }), full_validation, ); - // The meta-schema refuses a `$` in a path when registering - if full_validation && operand.starts_with('$') { + // The meta-schema refuses a `$` in a path when registering, but for + // `$ownerId`, which only a comparison of identifiers may read + if full_validation && operand.starts_with('$') && operand != "$ownerId" { assert!( result.as_ref().is_err_and(is_json_schema_error), "{operand}: {result:?}" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs index 7aead89918c..246e6fe4da2 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs @@ -986,7 +986,7 @@ fn should_refuse_a_document_whose_typed_array_breaks_its_schema() { let contract = charter_contract(platform_version); contract - .validate_document_properties("charter", charter_properties(), platform_version) + .validate_document_properties("charter", charter_properties(), None, platform_version) .map(|result| assert!(result.is_valid(), "the base document is valid: {result:?}")) .expect("validation runs"); @@ -1023,7 +1023,7 @@ fn should_refuse_a_document_whose_typed_array_breaks_its_schema() { .expect("property applies"); let result = contract - .validate_document_properties("charter", properties, platform_version) + .validate_document_properties("charter", properties, None, platform_version) .expect("validation returns a consensus result, never an error"); let Some(ConsensusError::BasicError(BasicError::JsonSchemaError(error))) = diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index 4a8adb7f858..5e82283ec7e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -686,7 +686,9 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe /// `DocumentPropertyConstraintViolatedError` (10422), naming the rule and why (the rule /// does not hold, or evaluating it overflowed, divided by zero, raised to a negative /// power or read a value that is not an integer). A property the document - /// leaves out counts as 0, or as its `ifAbsent` value. Reads the properties alone: + /// leaves out counts as 0, or as its `ifAbsent` value, and `$ownerId` reads + /// `owner_id`, the document's owner (`None` when the caller does not know it, which + /// `$ownerId` then equals no identifier for). Reads the properties and the owner alone: /// `DataContract::validate_document_properties` runs it after the schema validation, /// so document create and replace, and every client validating a document, apply it. /// @@ -696,6 +698,7 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe fn validate_property_constraints( &self, data: &Value, + owner_id: Option, platform_version: &PlatformVersion, ) -> Result where @@ -709,7 +712,7 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe .validate_property_constraints { None => Ok(SimpleConsensusValidationResult::default()), - Some(0) => Ok(self.validate_property_constraints_v0(data)), + Some(0) => Ok(self.validate_property_constraints_v0(data, owner_id)), Some(version) => Err(ProtocolError::UnknownVersionMismatch { method: "validate_property_constraints".to_string(), known_versions: vec![0], @@ -718,6 +721,42 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe } } + /// Judges a stored document's properties, `data`, against the rules of the document + /// type's `propertyConstraints` that read `$ownerId`, with `new_owner_id` as the owner, + /// in name order: a transfer or a purchase gives the document a new owner and changes + /// nothing else, so these are the only rules it can break, and the first broken fails + /// with `DocumentPropertyConstraintViolatedError` (10422) as it would on a write. The + /// document type's other rules held when the document was written and still do. A type + /// with no rule reading `$ownerId` costs nothing, and its data is not copied. + /// + /// Versioned with [`Self::validate_property_constraints`]: `None` before protocol + /// version 14, where no parsed document type carries a rule. + fn validate_property_constraints_for_new_owner( + &self, + data: &BTreeMap, + new_owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result + where + Self: DocumentTypeV2Getters, + { + match platform_version + .dpp + .contract_versions + .document_type_versions + .methods + .validate_property_constraints + { + None => Ok(SimpleConsensusValidationResult::default()), + Some(0) => Ok(self.validate_property_constraints_for_new_owner_v0(data, new_owner_id)), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "validate_property_constraints_for_new_owner".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + fn sanitize_document_properties(&self, properties: &mut BTreeMap) { // Iterate through each property in the document for (field_name, field_value) in properties.iter_mut() { diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs index 1de35069aa3..4d0cf0aa953 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs @@ -847,12 +847,52 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa /// `validate_property_constraints` version 0: every rule of the document type's /// `propertyConstraints` is evaluated against `data` in name order, and the first one /// broken is reported. A type without rules costs nothing. - fn validate_property_constraints_v0(&self, data: &Value) -> SimpleConsensusValidationResult + fn validate_property_constraints_v0( + &self, + data: &Value, + owner_id: Option, + ) -> SimpleConsensusValidationResult where Self: DocumentTypeV2Getters, { for (name, constraint) in self.property_constraints() { - if let Some(violation) = constraint.violation(data) { + if let Some(violation) = constraint.violation(data, owner_id) { + return SimpleConsensusValidationResult::new_with_error( + DocumentPropertyConstraintViolatedError::new( + self.name().clone(), + name.clone(), + violation, + ) + .into(), + ); + } + } + SimpleConsensusValidationResult::default() + } + + /// `validate_property_constraints_for_new_owner` version 0: every rule of the document + /// type's `propertyConstraints` that reads `$ownerId` is evaluated against `data` with + /// `new_owner_id` as the owner, in name order, and the first one broken is reported. + /// The data is copied into a map value only when such a rule exists. + fn validate_property_constraints_for_new_owner_v0( + &self, + data: &BTreeMap, + new_owner_id: Identifier, + ) -> SimpleConsensusValidationResult + where + Self: DocumentTypeV2Getters, + { + let mut owner_rules = self + .property_constraints() + .iter() + .filter(|(_, constraint)| constraint.reads_owner()) + .peekable(); + if owner_rules.peek().is_none() { + return SimpleConsensusValidationResult::default(); + } + let data = Value::from(data.clone()); + for (name, constraint) in owner_rules { + if let Some(violation) = constraint.violation(&data, Some(new_owner_id)) { return SimpleConsensusValidationResult::new_with_error( DocumentPropertyConstraintViolatedError::new( self.name().clone(), diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index 767a84ee84a..21dc72728f1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -42,9 +42,9 @@ //! string properties with each other, and an `in` whose values are strings //! lists them bare; `{ "ifAbsent": ["status", "open"] }` gives a string //! property compared with strings a default. An identifier property compares -//! the same ways, its constants written base58, without defaults. How the -//! arithmetic -//! treats overflow, division and powers is set out on +//! the same ways, its constants written base58, without defaults, and so does +//! `$ownerId`, the document's owner, which a transfer or a purchase changes. +//! How the arithmetic treats overflow, division and powers is set out on //! [`ConstraintExpression::evaluate`], and how conditions combine on //! [`PropertyConstraint::holds`]. //! @@ -62,6 +62,7 @@ mod tests; use crate::consensus::basic::document::PropertyConstraintViolation; use crate::data_contract::document_type::property_names; use crate::data_contract::errors::DataContractError; +use crate::document::property_names::OWNER_ID; use platform_value::string_encoding::Encoding; use platform_value::{Identifier, Value, ValueMapHelper}; use std::collections::{BTreeMap, BTreeSet}; @@ -446,7 +447,10 @@ pub enum PropertyConstraint { } impl PropertyConstraint { - /// Whether a document whose properties are `data` meets the condition. + /// Whether a document whose properties are `data`, owned by `owner_id`, + /// meets the condition. `owner_id` is what `$ownerId` reads; `None` for a + /// document whose owner the caller does not know, which `$ownerId` then + /// equals no identifier for. /// /// Evaluated left to right, and no further than the outcome needs: a /// comparison evaluates its left side, then its right one; `anyOf` checks @@ -459,7 +463,11 @@ impl PropertyConstraint { /// one: `anyOf: [{ equal: ["b", 0] }, { equal: [{ divide: ["a", "b"] }, 2] }]` /// holds for a `b` of 0 without dividing by it, while the same two /// conditions the other way round divide by zero. - pub fn holds(&self, data: &Value) -> Result { + pub fn holds( + &self, + data: &Value, + owner_id: Option, + ) -> Result { match self { PropertyConstraint::Compare { comparison, @@ -499,7 +507,7 @@ impl PropertyConstraint { path, value, } => { - let equal = identifier_value(data, path) == Some(*value); + let equal = identifier_value(data, owner_id, path) == Some(*value); Ok(equal == (*comparison == ConstraintComparison::Equal)) } PropertyConstraint::IdentifierCompareProperties { @@ -508,19 +516,23 @@ impl PropertyConstraint { right, } => { let equal = matches!( - (identifier_value(data, left), identifier_value(data, right)), + ( + identifier_value(data, owner_id, left), + identifier_value(data, owner_id, right) + ), (Some(left), Some(right)) if left == right ); Ok(equal == (*comparison == ConstraintComparison::Equal)) } PropertyConstraint::IdentifierIn { path, values } => { - Ok(identifier_value(data, path).is_some_and(|value| values.contains(&value))) + Ok(identifier_value(data, owner_id, path) + .is_some_and(|value| values.contains(&value))) } PropertyConstraint::Present(path) => Ok(is_present(data, path)), PropertyConstraint::Absent(path) => Ok(!is_present(data, path)), PropertyConstraint::AnyOf(conditions) => { for condition in conditions { - if condition.holds(data)? { + if condition.holds(data, owner_id)? { return Ok(true); } } @@ -528,21 +540,26 @@ impl PropertyConstraint { } PropertyConstraint::AllOf(conditions) => { for condition in conditions { - if !condition.holds(data)? { + if !condition.holds(data, owner_id)? { return Ok(false); } } Ok(true) } - PropertyConstraint::Not(condition) => Ok(!condition.holds(data)?), + PropertyConstraint::Not(condition) => Ok(!condition.holds(data, owner_id)?), } } - /// Why a document whose properties are `data` breaks the rule, `None` when - /// it meets it: the first fault met on the way ([`Self::holds`]), or - /// [`PropertyConstraintViolation::NotMet`] when the rule evaluates to false. - pub fn violation(&self, data: &Value) -> Option { - match self.holds(data) { + /// Why a document whose properties are `data`, owned by `owner_id`, breaks + /// the rule, `None` when it meets it: the first fault met on the way + /// ([`Self::holds`]), or [`PropertyConstraintViolation::NotMet`] when the + /// rule evaluates to false. + pub fn violation( + &self, + data: &Value, + owner_id: Option, + ) -> Option { + match self.holds(data, owner_id) { Ok(true) => None, Ok(false) => Some(PropertyConstraintViolation::NotMet), Err(violation) => Some(violation), @@ -593,6 +610,30 @@ impl PropertyConstraint { reads } + /// Whether the rule compares the document's owner, `$ownerId`: then a + /// transfer or a purchase, which changes the owner and nothing else, is + /// judged against it too. + pub fn reads_owner(&self) -> bool { + match self { + PropertyConstraint::IdentifierCompare { path, .. } + | PropertyConstraint::IdentifierIn { path, .. } => path == OWNER_ID, + PropertyConstraint::IdentifierCompareProperties { left, right, .. } => { + left == OWNER_ID || right == OWNER_ID + } + PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { + conditions.iter().any(PropertyConstraint::reads_owner) + } + PropertyConstraint::Not(condition) => condition.reads_owner(), + PropertyConstraint::Compare { .. } + | PropertyConstraint::In { .. } + | PropertyConstraint::TextCompare { .. } + | PropertyConstraint::TextCompareProperties { .. } + | PropertyConstraint::TextIn { .. } + | PropertyConstraint::Present(_) + | PropertyConstraint::Absent(_) => false, + } + } + /// Every string constant the rule compares a property with, as the /// property's dotted path and the constant, in declared order. pub fn text_constants(&self) -> Vec<(&str, &str)> { @@ -745,13 +786,19 @@ impl PropertyConstraint { reads.push((&left.path, PropertyRead::Text)); reads.push((&right.path, PropertyRead::Text)); } + // `$ownerId` is the document's owner, no property of it PropertyConstraint::IdentifierCompare { path, .. } | PropertyConstraint::IdentifierIn { path, .. } => { - reads.push((path, PropertyRead::Identifier)) + if path != OWNER_ID { + reads.push((path, PropertyRead::Identifier)) + } } PropertyConstraint::IdentifierCompareProperties { left, right, .. } => { - reads.push((left, PropertyRead::Identifier)); - reads.push((right, PropertyRead::Identifier)); + for path in [left, right] { + if path != OWNER_ID { + reads.push((path, PropertyRead::Identifier)); + } + } } PropertyConstraint::Present(path) | PropertyConstraint::Absent(path) => { reads.push((path, PropertyRead::Presence)) @@ -925,6 +972,14 @@ fn parse_condition( )); }; let parent = enter(at, key); + // `$ownerId`, the document's owner, compares as an identifier property + let kind_of = |path: &str| { + if path == OWNER_ID { + Some(EqualityKind::Identifier) + } else { + property_kind(path) + } + }; let condition = match key { ANY_OF => { PropertyConstraint::AnyOf(condition_list(body, key, at, depth + 1, property_kind)?) @@ -960,9 +1015,9 @@ fn parse_condition( .and_then(|values| values.first()) .is_some_and(|first| first.as_text().is_some()); // Strings listed for an identifier property are its identifiers, base58 - let identifier_path = operand.as_text().filter(|path| { - over_strings && property_kind(path) == Some(EqualityKind::Identifier) - }); + let identifier_path = operand + .as_text() + .filter(|path| over_strings && kind_of(path) == Some(EqualityKind::Identifier)); if let Some(path) = identifier_path { at.push_str("[1]"); let values = in_identifier_values(values, at)?; @@ -1027,7 +1082,7 @@ fn parse_condition( // A const or an ifAbsent with a string default on either side, or // two bare paths naming string or identifier properties, compare // strings or identifiers, as the properties named decide - let bare_kind = |value: &Value| value.as_text().and_then(property_kind); + let bare_kind = |value: &Value| value.as_text().and_then(kind_of); if is_const(left) || is_const(right) || is_text_if_absent(left) @@ -1261,6 +1316,12 @@ fn identifier_comparison( value, }) } + (IdentifierSide::Property(left), IdentifierSide::Property(right)) if left == right => { + return Err(format!( + "at {at} compares \"{left}\" with itself, so it would hold for every document or \ + for none" + )); + } (IdentifierSide::Property(left), IdentifierSide::Property(right)) => { Some(PropertyConstraint::IdentifierCompareProperties { comparison, @@ -1361,6 +1422,13 @@ fn text_comparison( value, }) } + (TextSide::Property(left), TextSide::Property(right)) if left == right => { + return Err(format!( + "at {at} compares \"{}\" with itself, so it would hold for every document or for \ + none", + left.path + )); + } (TextSide::Property(left), TextSide::Property(right)) => { Some(PropertyConstraint::TextCompareProperties { comparison, @@ -1544,10 +1612,14 @@ fn integer_value(value: &Value, at: &str) -> Result { } /// The identifier `data` holds at `path`, in any of the forms a document's -/// identifier takes; `None` when the document leaves the property out or holds -/// something that is no identifier there, which the schema validation running -/// first refuses for an identifier property. -fn identifier_value(data: &Value, path: &str) -> Option { +/// identifier takes, or `owner_id` for `$ownerId`; `None` when the document +/// leaves the property out or holds something that is no identifier there, +/// which the schema validation running first refuses for an identifier +/// property. +fn identifier_value(data: &Value, owner_id: Option, path: &str) -> Option { + if path == OWNER_ID { + return owner_id; + } match data.get_optional_value_at_path(path) { Ok(Some(value)) => value.to_identifier().ok(), _ => None, diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index d7fe6ebc598..fd4edbb2ed2 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -737,24 +737,27 @@ fn should_hold_an_in_when_its_operand_takes_a_listed_value() { let rule = parse_rule_value(platform_value!({ "in": ["kind", [1, 3, 7]] })); for (kind, holds) in [(1, true), (3, true), (7, true), (2, false), (8, false)] { assert_eq!( - rule.holds(&data(&[("kind", Value::U64(kind))])), + rule.holds(&data(&[("kind", Value::U64(kind))]), None), Ok(holds), "kind {kind}" ); } // An absent operand reads as 0 - assert_eq!(rule.holds(&data(&[])), Ok(false)); + assert_eq!(rule.holds(&data(&[]), None), Ok(false)); let with_zero = parse_rule_value(platform_value!({ "in": ["kind", [0, 1]] })); - assert_eq!(with_zero.holds(&data(&[])), Ok(true)); + assert_eq!(with_zero.holds(&data(&[]), None), Ok(true)); let divided = parse_rule_value(platform_value!({ "in": [{ "divide": [10, "kind"] }, [2, 5]] })); - assert_eq!(divided.violation(&data(&[("kind", Value::U64(5))])), None); assert_eq!( - divided.violation(&data(&[("kind", Value::U64(3))])), + divided.violation(&data(&[("kind", Value::U64(5))]), None), + None + ); + assert_eq!( + divided.violation(&data(&[("kind", Value::U64(3))]), None), Some(PropertyConstraintViolation::NotMet) ); assert_eq!( - divided.violation(&data(&[("kind", Value::U64(0))])), + divided.violation(&data(&[("kind", Value::U64(0))]), None), Some(PropertyConstraintViolation::DivisionByZero) ); @@ -899,13 +902,21 @@ fn should_compare_a_string_property_with_constants() { Some(value) => data(&[("status", value.clone())]), None => data(&[]), }; - assert_eq!(equal.holds(&values), Ok(is_closed), "equal, {status:?}"); assert_eq!( - not_equal.holds(&values), + equal.holds(&values, None), + Ok(is_closed), + "equal, {status:?}" + ); + assert_eq!( + not_equal.holds(&values, None), Ok(!is_closed), "notEqual, {status:?}" ); - assert_eq!(in_list.holds(&values), Ok(is_listed), "in, {status:?}"); + assert_eq!( + in_list.holds(&values, None), + Ok(is_listed), + "in, {status:?}" + ); } // A closed order must carry closedAt @@ -913,16 +924,16 @@ fn should_compare_a_string_property_with_constants() { "anyOf": [{ "notEqual": ["status", { "const": "closed" }] }, { "present": "closedAt" }] })); let closed = Value::Text("closed".to_string()); - assert_eq!(rule.violation(&data(&[])), None); + assert_eq!(rule.violation(&data(&[]), None), None); assert_eq!( - rule.violation(&data(&[ - ("status", closed.clone()), - ("closedAt", Value::U64(9)) - ])), + rule.violation( + &data(&[("status", closed.clone()), ("closedAt", Value::U64(9))]), + None + ), None ); assert_eq!( - rule.violation(&data(&[("status", closed)])), + rule.violation(&data(&[("status", closed)]), None), Some(PropertyConstraintViolation::NotMet) ); } @@ -1031,9 +1042,13 @@ fn should_compare_two_string_properties() { entries.push(("to", to.clone())); } let values = data(&entries); - assert_eq!(equal.holds(&values), Ok(same), "equal, {from:?} {to:?}"); assert_eq!( - not_equal.holds(&values), + equal.holds(&values, None), + Ok(same), + "equal, {from:?} {to:?}" + ); + assert_eq!( + not_equal.holds(&values, None), Ok(!same), "notEqual, {from:?} {to:?}" ); @@ -1143,25 +1158,25 @@ fn should_read_a_string_default_for_a_property_left_out() { Some(value) => data(&[("status", value.clone())]), None => data(&[]), }; - assert_eq!(open.holds(&values), Ok(is_open), "equal, {status:?}"); - assert_eq!(listed.holds(&values), Ok(is_open), "in, {status:?}"); + assert_eq!(open.holds(&values, None), Ok(is_open), "equal, {status:?}"); + assert_eq!(listed.holds(&values, None), Ok(is_open), "in, {status:?}"); } let same_default = parse_rule_value(platform_value!({ "equal": [{ "ifAbsent": ["from", "USD"] }, { "ifAbsent": ["to", "USD"] }] })); - assert_eq!(same_default.holds(&data(&[])), Ok(true)); + assert_eq!(same_default.holds(&data(&[]), None), Ok(true)); assert_eq!( - same_default.holds(&data(&[("to", text_value("USD"))])), + same_default.holds(&data(&[("to", text_value("USD"))]), None), Ok(true) ); assert_eq!( - same_default.holds(&data(&[("to", text_value("EUR"))])), + same_default.holds(&data(&[("to", text_value("EUR"))]), None), Ok(false) ); // Without defaults, two properties left out are not equal let bare = parse_rule_value(platform_value!({ "equal": ["from", "to"] })); - assert_eq!(bare.holds(&data(&[])), Ok(false)); + assert_eq!(bare.holds(&data(&[]), None), Ok(false)); // A default is part of the node it sits in, and listed for the enum check apart // from the constants compared @@ -1311,8 +1326,8 @@ fn should_compare_identifier_properties() { Some(value) => data(&[("buyerId", value.clone())]), None => data(&[]), }; - assert_eq!(is_a.holds(&values), Ok(equals_a), "equal, {buyer:?}"); - assert_eq!(listed.holds(&values), Ok(is_listed), "in, {buyer:?}"); + assert_eq!(is_a.holds(&values, None), Ok(equals_a), "equal, {buyer:?}"); + assert_eq!(listed.holds(&values, None), Ok(is_listed), "in, {buyer:?}"); } let distinct = parse_rule_value(platform_value!({ "notEqual": ["buyerId", "sellerId"] })); @@ -1326,11 +1341,11 @@ fn should_compare_identifier_properties() { } data(&entries) }; - assert_eq!(distinct.holds(&pair(Some(a), Some(b))), Ok(true)); - assert_eq!(distinct.holds(&pair(Some(a), Some(a))), Ok(false)); - assert_eq!(distinct.holds(&pair(Some(a), None)), Ok(true)); + assert_eq!(distinct.holds(&pair(Some(a), Some(b)), None), Ok(true)); + assert_eq!(distinct.holds(&pair(Some(a), Some(a)), None), Ok(false)); + assert_eq!(distinct.holds(&pair(Some(a), None), None), Ok(true)); // Two identifiers left out are not equal - assert_eq!(distinct.holds(&pair(None, None)), Ok(true)); + assert_eq!(distinct.holds(&pair(None, None), None), Ok(true)); // A comparison of a path with a constant is three nodes, an in two plus one per value assert_eq!(is_a.node_count(), 3); @@ -1347,6 +1362,107 @@ fn should_compare_identifier_properties() { assert!(is_a.text_constants().is_empty()); } +// ── $ownerId ─────────────────────────────────────────────────────────── + +/// `$ownerId`, the document's owner, is an identifier operand: beside an identifier +/// property, a base58 constant, or as the operand of an `in` over identifiers. +#[test] +fn should_parse_the_owner_as_an_identifier_operand() { + let (a, a58) = identifier(1); + let (b, b58) = identifier(2); + assert_eq!( + parse_rule_value(platform_value!({ "equal": ["buyerId", "$ownerId"] })), + PropertyConstraint::IdentifierCompareProperties { + comparison: ConstraintComparison::Equal, + left: "buyerId".to_string(), + right: "$ownerId".to_string(), + } + ); + assert_eq!( + parse_rule_value(platform_value!({ "notEqual": [{ "const": a58.clone() }, "$ownerId"] })), + PropertyConstraint::IdentifierCompare { + comparison: ConstraintComparison::NotEqual, + path: "$ownerId".to_string(), + value: a, + } + ); + assert_eq!( + parse_rule_value(platform_value!({ "in": ["$ownerId", [a58.clone(), b58.clone()]] })), + PropertyConstraint::IdentifierIn { + path: "$ownerId".to_string(), + values: BTreeSet::from([a, b]), + } + ); + + for (condition, needle) in [ + ( + platform_value!({ "equal": ["$ownerId", "$ownerId"] }), + "rule \"rule\" at equal compares \"$ownerId\" with itself, so it would hold for every \ + document or for none", + ), + ( + platform_value!({ "notEqual": ["buyerId", "buyerId"] }), + "rule \"rule\" at notEqual compares \"buyerId\" with itself", + ), + ( + platform_value!({ "equal": ["status", "status"] }), + "rule \"rule\" at equal compares \"status\" with itself", + ), + ( + platform_value!({ "lessThan": ["$ownerId", "buyerId"] }), + "rule \"rule\" at lessThan compares identifiers, which only equal and notEqual do", + ), + ( + platform_value!({ "equal": ["$ownerId", "status"] }), + "rule \"rule\" at equal compares a string property with an identifier property", + ), + ( + platform_value!({ "equal": ["$ownerId", { "const": "closed" }] }), + "rule \"rule\" at equal[1].const holds \"closed\", which is not a base58 identifier", + ), + ] { + expect_refusal(platform_value!({ "rule": condition }), needle); + } +} + +/// `$ownerId` reads the owner the caller gives, and equals nothing when it gives none; it +/// is no property, so a rule reading it lists no path for it, and says it reads the owner. +#[test] +fn should_compare_the_owner() { + let (a, a58) = identifier(1); + let (b, b58) = identifier(2); + let (c, _) = identifier(3); + let buyer_owns = parse_rule_value(platform_value!({ "equal": ["buyerId", "$ownerId"] })); + let allowed_writers = parse_rule_value(platform_value!({ "in": ["$ownerId", [a58, b58]] })); + let buyer = + |identifier: Identifier| data(&[("buyerId", Value::Identifier(identifier.to_buffer()))]); + + assert_eq!(buyer_owns.holds(&buyer(a), Some(a)), Ok(true)); + assert_eq!(buyer_owns.holds(&buyer(a), Some(b)), Ok(false)); + assert_eq!(buyer_owns.holds(&buyer(a), None), Ok(false)); + assert_eq!(buyer_owns.holds(&data(&[]), Some(a)), Ok(false)); + assert_eq!(allowed_writers.holds(&data(&[]), Some(b)), Ok(true)); + assert_eq!(allowed_writers.holds(&data(&[]), Some(c)), Ok(false)); + assert_eq!(allowed_writers.holds(&data(&[]), None), Ok(false)); + + assert_eq!( + buyer_owns.property_reads(), + [("buyerId", PropertyRead::Identifier)] + ); + assert!(allowed_writers.property_reads().is_empty()); + assert!(buyer_owns.reads_owner()); + assert!(allowed_writers.reads_owner()); + assert!(parse_rule_value(platform_value!({ + "anyOf": [{ "equal": ["fee", 1] }, { "not": { "equal": ["sellerId", "$ownerId"] } }] + })) + .reads_owner()); + assert!( + !parse_rule_value(platform_value!({ "notEqual": ["buyerId", "sellerId"] })).reads_owner() + ); + // equal, buyerId, $ownerId + assert_eq!(buyer_owns.node_count(), 3); +} + // ── present and absent ────────────────────────────────────────────────── #[test] @@ -1629,10 +1745,10 @@ fn should_report_whether_a_rule_holds_and_the_left_fault_first() { ]) }; // (10 + 2) * 3 = 36 - assert_eq!(rule.violation(&order(10, 2, 3, 36)), None); - assert_eq!(rule.violation(&order(10, 2, 3, 100)), None); + assert_eq!(rule.violation(&order(10, 2, 3, 36), None), None); + assert_eq!(rule.violation(&order(10, 2, 3, 100), None), None); assert_eq!( - rule.violation(&order(10, 2, 3, 35)), + rule.violation(&order(10, 2, 3, 35), None), Some(PropertyConstraintViolation::NotMet) ); @@ -1645,7 +1761,7 @@ fn should_report_whether_a_rule_holds_and_the_left_fault_first() { ("negative", Value::I64(-1)), ]); assert_eq!( - both_sides_fail.violation(&values), + both_sides_fail.violation(&values, None), Some(PropertyConstraintViolation::DivisionByZero) ); @@ -1690,17 +1806,21 @@ fn should_combine_conditions_with_any_of_all_of_and_not() { (1, 5, false, false), ] { let values = data(&[("a", Value::U64(a)), ("b", Value::U64(b))]); - assert_eq!(any_of.holds(&values), Ok(either), "a {a}, b {b}: anyOf"); - assert_eq!(all_of.holds(&values), Ok(both), "a {a}, b {b}: allOf"); - assert_eq!(not.holds(&values), Ok(!either), "a {a}, b {b}: not"); assert_eq!( - any_of.violation(&values), + any_of.holds(&values, None), + Ok(either), + "a {a}, b {b}: anyOf" + ); + assert_eq!(all_of.holds(&values, None), Ok(both), "a {a}, b {b}: allOf"); + assert_eq!(not.holds(&values, None), Ok(!either), "a {a}, b {b}: not"); + assert_eq!( + any_of.violation(&values, None), (!either).then_some(PropertyConstraintViolation::NotMet), "a {a}, b {b}" ); } // An absent property still counts as 0 - assert_eq!(any_of.holds(&data(&[("b", Value::U64(5))])), Ok(true)); + assert_eq!(any_of.holds(&data(&[("b", Value::U64(5))]), None), Ok(true)); } /// Conditions are checked in declared order, no further than the outcome needs, so an @@ -1722,41 +1842,41 @@ fn should_stop_at_the_outcome_and_break_the_rule_on_the_first_fault() { let values = |a: u64, b: u64| data(&[("a", Value::U64(a)), ("b", Value::U64(b))]); let zero_divisor = values(6, 0); - assert_eq!(guarded_any_of.violation(&zero_divisor), None); + assert_eq!(guarded_any_of.violation(&zero_divisor, None), None); assert_eq!( - unguarded_any_of.violation(&zero_divisor), + unguarded_any_of.violation(&zero_divisor, None), Some(PropertyConstraintViolation::DivisionByZero) ); assert_eq!( - guarded_all_of.violation(&zero_divisor), + guarded_all_of.violation(&zero_divisor, None), Some(PropertyConstraintViolation::NotMet) ); assert_eq!( - negated.violation(&zero_divisor), + negated.violation(&zero_divisor, None), Some(PropertyConstraintViolation::DivisionByZero) ); // 4 / 2 = 2, 6 / 2 = 3 for rule in [&guarded_any_of, &unguarded_any_of, &guarded_all_of] { - assert_eq!(rule.violation(&values(4, 2)), None, "{rule:?}"); + assert_eq!(rule.violation(&values(4, 2), None), None, "{rule:?}"); assert_eq!( - rule.violation(&values(6, 2)), + rule.violation(&values(6, 2), None), Some(PropertyConstraintViolation::NotMet), "{rule:?}" ); } assert_eq!( - negated.violation(&values(4, 2)), + negated.violation(&values(4, 2), None), Some(PropertyConstraintViolation::NotMet) ); - assert_eq!(negated.violation(&values(6, 2)), None); + assert_eq!(negated.violation(&values(6, 2), None), None); // An allOf stops at the first condition that fails, before a later fault let fails_before_the_fault = parse_rule_value(platform_value!({ "allOf": [{ "equal": ["a", 1] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] })); assert_eq!( - fails_before_the_fault.violation(&zero_divisor), + fails_before_the_fault.violation(&zero_divisor, None), Some(PropertyConstraintViolation::NotMet) ); } @@ -1800,8 +1920,16 @@ fn should_tell_a_property_left_out_from_one_set_to_zero() { ] { let present_rule = parse_rule_value(platform_value!({ "present": path })); let absent_rule = parse_rule_value(platform_value!({ "absent": path })); - assert_eq!(present_rule.holds(&values), Ok(present), "present {path}"); - assert_eq!(absent_rule.holds(&values), Ok(!present), "absent {path}"); + assert_eq!( + present_rule.holds(&values, None), + Ok(present), + "present {path}" + ); + assert_eq!( + absent_rule.holds(&values, None), + Ok(!present), + "absent {path}" + ); } // Optional, but above zero when given: an operand alone reads a discount left out @@ -1809,10 +1937,13 @@ fn should_tell_a_property_left_out_from_one_set_to_zero() { let rule = parse_rule_value(platform_value!({ "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] })); - assert_eq!(rule.violation(&data(&[])), None); - assert_eq!(rule.violation(&data(&[("discount", Value::U64(5))])), None); + assert_eq!(rule.violation(&data(&[]), None), None); + assert_eq!( + rule.violation(&data(&[("discount", Value::U64(5))]), None), + None + ); assert_eq!( - rule.violation(&data(&[("discount", Value::U64(0))])), + rule.violation(&data(&[("discount", Value::U64(0))]), None), Some(PropertyConstraintViolation::NotMet) ); } @@ -1849,10 +1980,10 @@ fn should_read_a_boolean_as_one_or_zero() { })); let order = |waived: bool, fee: u64| data(&[("waived", Value::Bool(waived)), ("fee", Value::U64(fee))]); - assert_eq!(rule.violation(&order(true, 0)), None); - assert_eq!(rule.violation(&order(false, 10)), None); + assert_eq!(rule.violation(&order(true, 0), None), None); + assert_eq!(rule.violation(&order(false, 10), None), None); assert_eq!( - rule.violation(&order(true, 10)), + rule.violation(&order(true, 10), None), Some(PropertyConstraintViolation::NotMet) ); } diff --git a/packages/rs-dpp/src/data_contract/methods/validate_document/mod.rs b/packages/rs-dpp/src/data_contract/methods/validate_document/mod.rs index acf0bc2b34a..3473b952130 100644 --- a/packages/rs-dpp/src/data_contract/methods/validate_document/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/validate_document/mod.rs @@ -1,5 +1,5 @@ use crate::prelude::DataContract; -use platform_value::Value; +use platform_value::{Identifier, Value}; use platform_version::version::PlatformVersion; mod v0; @@ -34,6 +34,7 @@ impl DataContractDocumentValidationMethodsV0 for DataContract { &self, name: &str, properties: Value, + owner_id: Option, platform_version: &PlatformVersion, ) -> Result { match platform_version @@ -42,7 +43,7 @@ impl DataContractDocumentValidationMethodsV0 for DataContract { .methods .validate_document { - 0 => self.validate_document_properties_v0(name, properties, platform_version), + 0 => self.validate_document_properties_v0(name, properties, owner_id, platform_version), version => Err(ProtocolError::UnknownVersionMismatch { method: "DataContract::validate_document_properties".to_string(), known_versions: vec![0], diff --git a/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs b/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs index ad1f5719adb..b4cf536bca7 100644 --- a/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs @@ -16,7 +16,7 @@ use crate::data_contract::DataContract; use crate::document::{Document, DocumentV0Getters}; use crate::validation::SimpleConsensusValidationResult; use crate::ProtocolError; -use platform_value::Value; +use platform_value::{Identifier, Value}; use platform_version::version::PlatformVersion; use std::ops::Deref; @@ -28,10 +28,16 @@ pub trait DataContractDocumentValidationMethodsV0 { platform_version: &PlatformVersion, ) -> Result; + /// Validates a document's properties, `value`, against its document type: the + /// schema, the string byte caps and the `propertyConstraints` rules. `owner_id` is + /// the document's owner, what a rule's `$ownerId` reads; `None` when the caller does + /// not know it, which `$ownerId` then equals no identifier for. Consensus passes the + /// writer on create and replace. fn validate_document_properties( &self, name: &str, value: Value, + owner_id: Option, platform_version: &PlatformVersion, ) -> Result; } @@ -42,6 +48,7 @@ impl DataContract { &self, name: &str, value: Value, + owner_id: Option, platform_version: &PlatformVersion, ) -> Result { let Some(document_type) = self.document_type_optional_for_name(name) else { @@ -110,7 +117,7 @@ impl DataContract { // schema error keeps precedence and every value a rule reads is known to be an // integer. let property_constraints_result = - document_type.validate_property_constraints(&value, platform_version)?; + document_type.validate_property_constraints(&value, owner_id, platform_version)?; let json_value = match value.try_into_validating_json() { Ok(json_value) => json_value, @@ -159,7 +166,12 @@ impl DataContract { platform_version: &PlatformVersion, ) -> Result { // Validate user defined properties - self.validate_document_properties_v0(name, document.properties().into(), platform_version) + self.validate_document_properties_v0( + name, + document.properties().into(), + Some(document.owner_id()), + platform_version, + ) } } @@ -207,7 +219,7 @@ mod tests { ); let result = data_contract - .validate_document_properties("noTimeDocument", value, platform_version) + .validate_document_properties("noTimeDocument", value, None, platform_version) .expect("validation should return a consensus result"); let Some(ConsensusError::BasicError(BasicError::ValueError(ValueError { .. }))) = @@ -240,7 +252,7 @@ mod tests { ); let result = data_contract - .validate_document_properties("noTimeDocument", value, platform_version) + .validate_document_properties("noTimeDocument", value, None, platform_version) .expect("validation should return a consensus result"); assert!(matches!( @@ -261,7 +273,7 @@ mod tests { )]); let result = data_contract - .validate_document_properties("noTimeDocument", value, platform_version) + .validate_document_properties("noTimeDocument", value, None, platform_version) .expect("validation should return a consensus result"); assert!( diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index a862f9dbdd0..ab3e8cccc5b 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -3566,7 +3566,7 @@ mod shielded_profile_schema_tests { let properties = platform_value!({ "shieldedAddress": Value::Bytes(vec![0; length]) }); let result = contract - .validate_document_properties("profile", properties, pv) + .validate_document_properties("profile", properties, None, pv) .unwrap(); assert_eq!( result.is_valid(), @@ -3578,6 +3578,7 @@ mod shielded_profile_schema_tests { .validate_document_properties( "profile", platform_value!({"shieldedAddress": "not bytes"}), + None, pv, ) .unwrap(); @@ -3586,6 +3587,7 @@ mod shielded_profile_schema_tests { .validate_document_properties( "profile", platform_value!({"displayName": "Alice"}), + None, pv, ) .unwrap(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v0/mod.rs index 15420223eb5..f4629441e5a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v0/mod.rs @@ -106,7 +106,12 @@ impl DocumentCreateTransitionActionStructureValidationV0 for DocumentCreateTrans // Validate user defined properties data_contract - .validate_document_properties(document_type_name, self.data().into(), platform_version) + .validate_document_properties( + document_type_name, + self.data().into(), + Some(owner_id), + platform_version, + ) .map_err(Error::Protocol) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs index 59535d4e838..ef14d3dec19 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs @@ -140,7 +140,12 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans // Validate user defined properties let result = data_contract - .validate_document_properties(document_type_name, self.data().into(), platform_version) + .validate_document_properties( + document_type_name, + self.data().into(), + Some(owner_id), + platform_version, + ) .map_err(Error::Protocol)?; if !result.is_valid() { return Ok(result); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_index_only_delete_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_index_only_delete_transition_action/advanced_structure_v0/mod.rs index dc5375c30e3..bd4b2acf8db 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_index_only_delete_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_index_only_delete_transition_action/advanced_structure_v0/mod.rs @@ -122,8 +122,15 @@ impl DocumentIndexOnlyDeleteTransitionActionStructureValidationV0 // validate it with the same contract validator creates use, which // enforces required properties, value types, and rejects unknown // keys (system fields included, since the user schema admits none). + // The delete carries no owner, and needs none: an indexOnly type + // refuses a `propertyConstraints` rule reading `$ownerId`. data_contract - .validate_document_properties(document_type_name, user_data.into(), platform_version) + .validate_document_properties( + document_type_name, + user_data.into(), + None, + platform_version, + ) .map_err(Error::Protocol) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs index 74ef284a7f9..34efa777cd8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs @@ -65,12 +65,28 @@ impl DocumentPurchaseTransitionActionStructureValidationV0 for DocumentPurchaseT // document must differ from the new owner, which the action already carries on the // document. The data was schema-validated when it was written, so every value // compared is a 32-byte identifier. - document_type + let distinct_from_result = document_type .validate_distinct_from_properties( self.document().properties(), self.document().owner_id(), platform_version, ) + .map_err(Error::Protocol)?; + if !distinct_from_result.is_valid() { + return Ok(distinct_from_result); + } + + // Added in place at protocol version 14, inert for every earlier version this + // generation serves: `validate_property_constraints` is `None` there, so the call + // returns an empty result. From 14, the new owner is judged against the rules of + // `propertyConstraints` that read `$ownerId`: the stored properties met every rule + // when they were written, and the owner is all this action changes. + document_type + .validate_property_constraints_for_new_owner( + self.document().properties(), + self.document().owner_id(), + platform_version, + ) .map_err(Error::Protocol) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs index c62af0551ee..adcec146298 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs @@ -49,7 +49,12 @@ impl DocumentReplaceTransitionActionStructureValidationV0 for DocumentReplaceTra // Validate user defined properties let result = data_contract - .validate_document_properties(document_type_name, self.data().into(), platform_version) + .validate_document_properties( + document_type_name, + self.data().into(), + Some(owner_id), + platform_version, + ) .map_err(Error::Protocol)?; if !result.is_valid() { return Ok(result); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs index d60f9d0e901..299d2264145 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs @@ -51,12 +51,28 @@ impl DocumentTransferTransitionActionStructureValidationV0 for DocumentTransferT // document must differ from the new owner, which the action already carries on the // document. The data was schema-validated when it was written, so every value // compared is a 32-byte identifier. - document_type + let distinct_from_result = document_type .validate_distinct_from_properties( self.document().properties(), self.document().owner_id(), platform_version, ) + .map_err(Error::Protocol)?; + if !distinct_from_result.is_valid() { + return Ok(distinct_from_result); + } + + // Added in place at protocol version 14, inert for every earlier version this + // generation serves: `validate_property_constraints` is `None` there, so the call + // returns an empty result. From 14, the new owner is judged against the rules of + // `propertyConstraints` that read `$ownerId`: the stored properties met every rule + // when they were written, and the owner is all this action changes. + document_type + .validate_property_constraints_for_new_owner( + self.document().properties(), + self.document().owner_id(), + platform_version, + ) .map_err(Error::Protocol) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index c85cb849b18..c95a72d02a2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -22,6 +22,7 @@ mod property_constraints_tests { use dpp::data_contract::schema::DataContractSchemaMethodsV0; use dpp::document::Document; use dpp::document::DocumentV0Setters; + use dpp::fee::Credits; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::platform_value::platform_value; use dpp::platform_value::string_encoding::Encoding; @@ -182,6 +183,40 @@ mod property_constraints_tests { }) } + /// A mutable, transferable and purchasable `offer` type with the integers + /// [`set_valid_offer`] fills and one rule, `sellerIsOwner`: a `sellerId`, + /// when given, is the offer's owner, `$ownerId`, so a transfer or a purchase + /// of an offer naming its seller is refused. + fn owned_offer_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "transferable": 1, + "tradeMode": 1, + "properties": { + "price": { "type": "integer", "minimum": 0, "position": 0 }, + "fee": { "type": "integer", "minimum": 0, "position": 1 }, + "quantity": { "type": "integer", "minimum": 0, "position": 2 }, + "deposit": { "type": "integer", "minimum": 0, "position": 3 }, + "sellerId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 4 + } + }, + "required": ["price", "fee", "quantity", "deposit"], + "propertyConstraints": { + "sellerIsOwner": { + "anyOf": [{ "absent": "sellerId" }, { "equal": ["sellerId", "$ownerId"] }] + } + }, + "additionalProperties": false + }) + } + /// An offer that meets every rule: (100 + 10) * 2 = 220. fn set_valid_offer(document: &mut Document) { document.set("price", Value::U64(100)); @@ -209,6 +244,11 @@ mod property_constraints_tests { impl OfferFixture { fn new() -> Self { + Self::with_schema(offer_schema()) + } + + /// The fixture with its `offer` type declared by `schema`. + fn with_schema(schema: Value) -> Self { let platform_version = PlatformVersion::latest(); let mut platform = TestPlatformBuilder::new() .build_with_mock_rpc() @@ -223,13 +263,7 @@ mod property_constraints_tests { ) .data_contract_owned(); contract - .set_document_schema( - "offer", - offer_schema(), - true, - &mut Vec::new(), - platform_version, - ) + .set_document_schema("offer", schema, true, &mut Vec::new(), platform_version) .expect("expected to add the offer document type"); platform .drive @@ -387,6 +421,139 @@ mod property_constraints_tests { result } + /// Transfers the stored offer to `recipient`. On success the fixture's + /// document becomes the transferred version. + async fn transfer(&mut self, recipient: Identifier) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let mut transferred = self + .document + .clone() + .expect("a document must have been created first"); + transferred + .increment_revision() + .expect("expected the revision to increment"); + + let transition = { + let offer_type = self + .contract + .document_type_for_name("offer") + .expect("expected the offer document type"); + BatchTransition::new_document_transfer_transition_from_document( + transferred.clone(), + offer_type, + recipient, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the transfer transition") + }; + self.next_nonce += 1; + + let result = self.process(&transition); + if matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ) { + transferred.set_owner_id(recipient); + self.document = Some(transferred); + } + result + } + + /// Puts the stored offer up for sale at `price`. + async fn set_price(&mut self, price: Credits) { + let platform_version = PlatformVersion::latest(); + let mut priced = self + .document + .clone() + .expect("a document must have been created first"); + priced + .increment_revision() + .expect("expected the revision to increment"); + + let transition = { + let offer_type = self + .contract + .document_type_for_name("offer") + .expect("expected the offer document type"); + BatchTransition::new_document_update_price_transition_from_document( + priced.clone(), + offer_type, + price, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the update price transition") + }; + self.next_nonce += 1; + + assert_matches!( + self.process(&transition), + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "setting the price must succeed" + ); + self.document = Some(priced); + } + + /// A second funded identity on the fixture's platform. + fn other_identity(&mut self, seed: u64) -> (Identity, SimpleSigner, IdentityPublicKey) { + setup_identity(&mut self.platform, seed, dash_to_credits!(0.5)) + } + + /// `buyer` purchases the stored offer at `price` (its first transition, + /// so nonce 1). + async fn purchase_by( + &mut self, + buyer: &(Identity, SimpleSigner, IdentityPublicKey), + price: Credits, + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let (buyer_identity, buyer_signer, buyer_key) = buyer; + let mut bought = self + .document + .clone() + .expect("a document must have been created first"); + bought + .increment_revision() + .expect("expected the revision to increment"); + + let transition = { + let offer_type = self + .contract + .document_type_for_name("offer") + .expect("expected the offer document type"); + BatchTransition::new_document_purchase_transition_from_document( + bought, + offer_type, + buyer_identity.id(), + price, + buyer_key, + 1, + 0, + None, + buyer_signer, + platform_version, + None, + ) + .await + .expect("expected the purchase transition") + }; + + self.process(&transition) + } + fn stored_offers(&self) -> Vec { let platform_version = PlatformVersion::latest(); let query = DriveDocumentQuery::from_sql_expr( @@ -846,6 +1013,62 @@ mod property_constraints_tests { assert_eq!(fixture.stored_offers().len(), 1); } + /// A rule reading `$ownerId` holds the offer's seller to its owner: on a + /// create, and on a transfer or a purchase, which change the owner. + #[tokio::test] + async fn should_compare_the_owner_on_create_transfer_and_purchase() { + let mut fixture = OfferFixture::with_schema(owned_offer_schema()); + let owner = fixture.identity.id(); + + let result = fixture + .create(|document| document.set("sellerId", Value::Identifier([4; 32]))) + .await; + expect_violated(result, "sellerIsOwner", PropertyConstraintViolation::NotMet); + + assert_matches!( + fixture + .create(|document| document.set("sellerId", Value::Identifier(owner.to_buffer()))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + // Transferred, the offer would name a seller that no longer owns it + let (recipient, _, _) = fixture.other_identity(961); + let result = fixture.transfer(recipient.id()).await; + expect_violated(result, "sellerIsOwner", PropertyConstraintViolation::NotMet); + + // Bought, likewise + fixture.set_price(1000).await; + let buyer = fixture.other_identity(962); + let result = fixture.purchase_by(&buyer, 1000).await; + expect_violated(result, "sellerIsOwner", PropertyConstraintViolation::NotMet); + + let stored = fixture.stored_offers(); + assert_eq!(stored.len(), 1); + assert_eq!( + stored[0].owner_id(), + owner, + "the refused actions leave the owner" + ); + } + + /// An offer naming no seller moves freely: the rule reading `$ownerId` holds + /// whoever owns it. + #[tokio::test] + async fn should_transfer_an_offer_the_owner_rules_allow() { + let mut fixture = OfferFixture::with_schema(owned_offer_schema()); + assert_matches!( + fixture.create(|_| {}).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let (recipient, _, _) = fixture.other_identity(963); + assert_matches!( + fixture.transfer(recipient.id()).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers()[0].owner_id(), recipient.id()); + } + #[tokio::test] async fn should_judge_a_replace_against_the_rules() { let mut fixture = OfferFixture::new(); diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index ca7fc5994ca..5c0236287d8 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1057,49 +1057,55 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// constant and no other string unless an `ifAbsent` gives it a string /// default (`{ "ifAbsent": ["status", "open"] }`, whose default an `enum` /// must list too); `equal`, `notEqual` or `in` of an identifier property -/// likewise, with base58 identifier constants or another identifier -/// property and no default, an identifier the document leaves out -/// equalling none; `present` or `absent` naming a property of any type, -/// whether the document holds it (the one way to tell a property left out -/// from one set to 0); `anyOf` or `allOf` over two or more conditions; or -/// `not` over one. In an operand, a property the document leaves out -/// counts as 0, or as the value of an `ifAbsent` operand naming it. -/// Arithmetic is exact `i128`: `divide` and `modulo` are Euclidean (the -/// remainder is never negative), and an overflow, a zero divisor, a -/// negative exponent or a value that is not an integer refuses the -/// document rather than wrapping. Conditions are checked in declared order -/// and no further than the outcome needs (`anyOf` stops at the first that -/// holds, `allOf` at the first that fails), a fault in one that is checked -/// refuses the document whatever the others say, and `not` never turns a -/// fault into a pass, so an earlier condition guards a later one. The -/// parser checks that every path an operand reads names an integer or -/// boolean property, every path compared with identifiers an identifier -/// property, every path compared with strings a string property (whose -/// `enum`, if it declares one, lists every constant it is compared with), -/// and every path `present` or `absent` tests a property of any type, none -/// transient nor inside a transient object; that every comparison and `in` -/// reads a property; that strings and identifiers are only compared for -/// equality, and never with each other; that no `in` lists a value twice; -/// that an `anyOf` or `allOf` holds none directly of its own kind and a -/// `not` no `not`; and that no condition or operand nests deeper than -/// `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every parse. Under full -/// validation it holds the limits `SystemLimits::max_property_constraints` -/// (16 rules) and `max_property_constraint_nodes` (32 per rule, every -/// comparison, `in`, listed value, `const`, presence test and logical -/// operator counting as one), and that no `anyOf` or `allOf` lists the -/// same condition twice. +/// or of `$ownerId`, the document's owner, likewise, with base58 +/// identifier constants or another identifier operand and no default, an +/// identifier the document leaves out equalling none; `present` or +/// `absent` naming a property of any type, whether the document holds it +/// (the one way to tell a property left out from one set to 0); `anyOf` or +/// `allOf` over two or more conditions; or `not` over one. In an operand, a +/// property the document leaves out counts as 0, or as the value of an +/// `ifAbsent` operand naming it. Arithmetic is exact `i128`: `divide` and +/// `modulo` are Euclidean (the remainder is never negative), and an +/// overflow, a zero divisor, a negative exponent or a value that is not an +/// integer refuses the document rather than wrapping. Conditions are +/// checked in declared order and no further than the outcome needs +/// (`anyOf` stops at the first that holds, `allOf` at the first that +/// fails), a fault in one that is checked refuses the document whatever the +/// others say, and `not` never turns a fault into a pass, so an earlier +/// condition guards a later one. The parser checks that every path an +/// operand reads names an integer or boolean property, every path compared +/// with identifiers an identifier property, every path compared with +/// strings a string property (whose `enum`, if it declares one, lists every +/// constant it is compared with), and every path `present` or `absent` +/// tests a property of any type, none transient nor inside a transient +/// object; that every comparison and `in` reads a property or the owner; +/// that nothing is compared with itself; that strings and identifiers are +/// only compared for equality, and never with each other; that no `in` +/// lists a value twice; that an `anyOf` or `allOf` holds none directly of +/// its own kind and a `not` no `not`; that an indexOnly type, whose deletes +/// carry no owner, reads no `$ownerId`; and that no condition or operand +/// nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every +/// parse. Under full validation it holds the limits +/// `SystemLimits::max_property_constraints` (16 rules) and +/// `max_property_constraint_nodes` (32 per rule, every comparison, `in`, +/// listed value, `const`, presence test and logical operator counting as +/// one), and that no `anyOf` or `allOf` lists the same condition twice. /// `DataContract::validate_document_properties` 0 (extended in place, inert -/// before this version) calls `validate_property_constraints` -/// (`validate_property_constraints` 0) after the schema validation, so -/// document create and replace, and any client validating a document, -/// refuse a broken rule with `DocumentPropertyConstraintViolatedError` -/// (10422), naming the rule and why. The rules read no state and change -/// nothing stored. They are fixed when the document type is created: a -/// changed `propertyConstraints` is an incompatible schema change on -/// update. The moderation charters contract declares its first one: a -/// `submittedCharter`'s `rewardSplit` members add up to 100, replacing the -/// charter-specific check, whose error 11001 keeps its place in -/// `BasicError` but is never produced. +/// before this version, and taking the document's owner for `$ownerId`) +/// calls `validate_property_constraints` (`validate_property_constraints` +/// 0) after the schema validation, so document create and replace, and any +/// client validating a document, refuse a broken rule with +/// `DocumentPropertyConstraintViolatedError` (10422), naming the rule and +/// why. A transfer and a purchase, which give the document a new owner, +/// are judged against the rules reading `$ownerId` with that owner +/// (`validate_property_constraints_for_new_owner`, beside `distinctFrom` in +/// their structure validation, in place and inert before this version). +/// The rules read no state and change nothing stored. They are fixed when +/// the document type is created: a changed `propertyConstraints` is an +/// incompatible schema change on update. The moderation charters contract +/// declares its first one: a `submittedCharter`'s `rewardSplit` members add +/// up to 100, replacing the charter-specific check, whose error 11001 +/// keeps its place in `BasicError` but is never produced. /// /// 40. **Elected moderation teams moderate from their stored charter**: seating /// writes nothing. Awarding the contest of item 37 writes the winning diff --git a/packages/rs-sdk/src/platform/moderation_charters/requests.rs b/packages/rs-sdk/src/platform/moderation_charters/requests.rs index 7eae0c8718e..da40ec85cb4 100644 --- a/packages/rs-sdk/src/platform/moderation_charters/requests.rs +++ b/packages/rs-sdk/src/platform/moderation_charters/requests.rs @@ -412,6 +412,7 @@ mod tests { .validate_document_properties( &request.document_type_name, Value::from(properties.clone()), + None, platform_version, ) .expect("runs"); From b33aba1ae951d680a84c00a17349b764c1af1bbc Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 21:25:28 +0700 Subject: [PATCH 024/113] refactor(dpp): restore shipped registration_cost v1 index parsing (#5055) Co-authored-by: Claude Opus 5.5 --- .../methods/registration_cost/v1/mod.rs | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/methods/registration_cost/v1/mod.rs b/packages/rs-dpp/src/data_contract/methods/registration_cost/v1/mod.rs index 44a7e6fd047..6dce1253630 100644 --- a/packages/rs-dpp/src/data_contract/methods/registration_cost/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/registration_cost/v1/mod.rs @@ -3,7 +3,7 @@ use crate::data_contract::accessors::v1::DataContractV1Getters; use crate::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use crate::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; -use crate::data_contract::document_type::{Index, IndexGrammarAdmissions}; +use crate::data_contract::document_type::Index; use crate::data_contract::serialized_version::DataContractInSerializationFormat; use crate::fee::Credits; use crate::prelude::DataContract; @@ -112,25 +112,7 @@ impl DataContractInSerializationFormat { ) { for index_value in index_values { if let Ok(index_value_map) = index_value.to_map() { - // Same keyword gates the document type parser - // applies, read from the one shared generation → - // admission mapping. Without them a PV14 index - // carrying `rankedCountable` &co. or `timeRange` - // would fail to parse here and be billed nothing, - // while the identical index parses fine during - // validation — the fee must cover every index the - // contract actually registers. - let admissions = IndexGrammarAdmissions::for_schema_generation( - platform_version - .dpp - .contract_versions - .document_type_versions - .schema - .document_type_schema, - ); - if let Ok(index) = - Index::try_from_value_map(index_value_map.as_slice(), admissions) - { + if let Ok(index) = Index::try_from(index_value_map.as_slice()) { let base_index_fee = if index.contested_index.is_some() { fee_version.document_type_base_contested_index_registration_fee } else if index.unique { From f0b7108a989f3b858bb08c9d6bfa8dc11746fc6f Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 21:28:25 +0700 Subject: [PATCH 025/113] refactor(drive): create once-per-identity claim trees in insert_contract v2 (#5056) Co-authored-by: Claude Opus 5.5 --- .../contract/insert/insert_contract/v1/mod.rs | 15 -------- .../contract/insert/insert_contract/v2/mod.rs | 34 ++++++++++++++++++- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v1/mod.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v1/mod.rs index afbc243b51f..bc6062b4079 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v1/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v1/mod.rs @@ -20,7 +20,6 @@ use crate::util::object_size_info::{DriveKeyInfo, PathKeyElementInfo}; use dpp::data_contract::accessors::v1::DataContractV1Getters; use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; -use dpp::data_contract::associated_token::token_distribution_rules::accessors::v1::TokenDistributionRulesV1Getters; use dpp::serialization::{PlatformSerializable, PlatformSerializableWithPlatformVersion}; use dpp::tokens::contract_info::TokenContractInfo; use dpp::tokens::status::TokenStatus; @@ -308,20 +307,6 @@ impl Drive { )?; } - if token_config - .distribution_rules() - .once_per_identity_distribution() - .is_some() - { - self.add_once_per_identity_distribution( - token_id.to_buffer(), - estimated_costs_only_with_layer_info, - &mut batch_operations, - transaction, - platform_version, - )?; - } - let path_holding_total_token_supply = total_tokens_root_supply_path_vec(); if token_config.base_supply() > 0 { diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v2/mod.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v2/mod.rs index a1af8d5018e..255e85cfec8 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v2/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v2/mod.rs @@ -1,9 +1,13 @@ use crate::drive::Drive; +use crate::error::contract::DataContractError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::util::storage_flags::StorageFlags; use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::accessors::v1::DataContractV1Getters; +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dpp::data_contract::associated_token::token_distribution_rules::accessors::v1::TokenDistributionRulesV1Getters; use dpp::data_contract::config::v2::DataContractConfigGettersV2; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::DataContract; @@ -88,7 +92,9 @@ impl Drive { Ok(()) } - /// The generation 1 operations, then the moderation list trees the config declares. + /// The generation 1 operations, then the once-per-identity claims subtree of every token + /// that has a once-per-identity distribution, then the moderation list trees the config + /// declares. fn insert_contract_operations_v2( &self, contract_element: Element, @@ -113,6 +119,32 @@ impl Drive { platform_version, )?; + // The claims subtree of every token whose rules carry a once-per-identity + // distribution. Only protocol version 14 admits those rules, so generation 1, which + // protocol versions 9-13 select, does not create it. + for (token_pos, token_config) in contract.tokens() { + if token_config + .distribution_rules() + .once_per_identity_distribution() + .is_none() + { + continue; + } + let token_id = contract.token_id(*token_pos).ok_or(Error::DataContract( + DataContractError::CorruptedDataContract(format!( + "data contract has a token at position {}, but can not find it", + token_pos + )), + ))?; + self.add_once_per_identity_distribution( + token_id.to_buffer(), + estimated_costs_only_with_layer_info, + &mut batch_operations, + transaction, + platform_version, + )?; + } + if let Some(moderation) = contract.config().moderation() { self.insert_contract_moderation_trees_operations( contract.id().to_buffer(), From 3e6e2584d66f3551ba12484f056a0779db548b9c Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 21:57:42 +0700 Subject: [PATCH 026/113] fix(dpp): parse nested required and transient entries by prefix (#5050) Co-authored-by: Claude Opus 5.5 --- .../class_methods/try_from_schema/mod.rs | 141 ++++++++++++++++-- 1 file changed, 130 insertions(+), 11 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 5929a4cf14f..b7aaaf3cde6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -293,26 +293,32 @@ fn insert_values_nested( // reintroduce a nested-property sort — even a correct one — nor a panicking // `position` read here. - // Create a new set with the prefix removed from the keys + // Create a new set with the prefix removed from the keys: an entry for + // a member of this object is the object's name, one separator byte, then + // the member's own entry. + // + // Every protocol version reaches this helper, so the match stays + // output-identical to the byte-offset slice it replaced: `str::get` + // returns that same slice wherever the slice was valid, and `None` (no + // member entry) elsewhere. Requiring the separator to be '.' would change + // how some schemas that parse today are read, so it needs a new + // generation. let stripped_required: BTreeSet = known_required .iter() .filter_map(|key| { - if key.starts_with(&property_key) && key.len() > property_key.len() { - Some(key[property_key.len() + 1..].to_string()) - } else { - None - } + key.strip_prefix(property_key.as_str()) + .and_then(|rest| rest.get(1..)) + .map(str::to_string) }) .collect(); + // Matched exactly like `stripped_required` above let stripped_transient: BTreeSet = known_transient .iter() .filter_map(|key| { - if key.starts_with(&property_key) && key.len() > property_key.len() { - Some(key[property_key.len() + 1..].to_string()) - } else { - None - } + key.strip_prefix(property_key.as_str()) + .and_then(|rest| rest.get(1..)) + .map(str::to_string) }) .collect(); @@ -5358,4 +5364,117 @@ mod tests { 1 ); } + + // ================================================================ + // required and transient entries of object members + // ================================================================ + + /// A document type with one object property, `profile`, whose own + /// `required` list names its `name` member; `required` and `transient` + /// are the document type's top-level lists. + fn object_members_schema(required: &[&str], transient: &[&str]) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "profile": { + "type": "object", + "position": 0, + "properties": { + "name": {"type": "string", "position": 0, "maxLength": 60}, + "bio": {"type": "string", "position": 1, "maxLength": 60}, + }, + "required": ["name"], + "additionalProperties": false + }, + }, + "required": required, + "transient": transient, + "additionalProperties": false + }) + } + + /// The members of the `profile` object of [`object_members_schema`]. + fn profile_members(document_type: &DocumentType) -> &IndexMap { + let DocumentPropertyType::Object(members) = + &document_type.properties()["profile"].property_type + else { + panic!("profile should parse as an object"); + }; + members + } + + #[test] + fn should_match_required_entries_to_object_members_by_prefix() { + for platform_version in [ + PlatformVersion::latest(), + PlatformVersion::get(13).expect("platform version 13 should exist"), + ] { + let expected = try_document_type_from_schema_on_version( + object_members_schema(&["profile"], &[]), + platform_version, + ) + .expect("should parse"); + let document_type = try_document_type_from_schema_on_version( + object_members_schema(&["profile", "profileé"], &[]), + platform_version, + ) + .expect("an entry naming no member should parse"); + + // Every member stays as the object's own list declares it + let members = profile_members(&document_type); + assert!(members["name"].required); + assert!(!members["bio"].required); + assert_eq!(document_type.properties(), expected.properties()); + assert_eq!( + document_type.flattened_properties(), + expected.flattened_properties() + ); + } + + try_document_type_from_schema_full_validation(object_members_schema( + &["profile", "profileé"], + &[], + )) + .expect("an entry naming no member should pass full validation"); + } + + #[test] + fn should_match_transient_entries_to_object_members_by_prefix() { + for platform_version in [ + PlatformVersion::latest(), + PlatformVersion::get(13).expect("platform version 13 should exist"), + ] { + let expected = try_document_type_from_schema_on_version( + object_members_schema(&["profile"], &[]), + platform_version, + ) + .expect("should parse"); + let document_type = try_document_type_from_schema_on_version( + object_members_schema(&["profile"], &["profileé"]), + platform_version, + ) + .expect("an entry naming no member should parse"); + + let members = profile_members(&document_type); + assert!(!members["name"].transient); + assert!(!members["bio"].transient); + assert_eq!(document_type.properties(), expected.properties()); + assert_eq!( + document_type.flattened_properties(), + expected.flattened_properties() + ); + } + + // From protocol version 14 the validating parse holds every transient + // entry to naming a top-level property + let err = try_document_type_from_schema_full_validation(object_members_schema( + &["profile"], + &["profileé"], + )) + .expect_err("a transient entry naming no top-level property should be refused"); + assert!( + err.to_string().contains("not a top-level property"), + "expected the transient entry refusal, got: {err}" + ); + } } From 1b30651da7a672adb5e28167605cbb49563c77cb Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 21:59:59 +0700 Subject: [PATCH 027/113] feat(sdk): propertyConstraints discovery and pre-check in the JS SDK (#5051) Co-authored-by: Claude Opus 5.5 --- packages/js-evo-sdk/README.md | 16 + .../document_type_property_constraints.rs | 338 ++++++++++++++++++ packages/wasm-dpp2/src/data_contract/mod.rs | 5 + packages/wasm-dpp2/src/data_contract/model.rs | 93 +++++ .../unit/DocumentPropertyConstraints.spec.ts | 296 +++++++++++++++ 5 files changed, 748 insertions(+) create mode 100644 packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs create mode 100644 packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 9d3cf05b3c2..21db109a290 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -441,6 +441,22 @@ try { } ``` +To find a broken rule before paying for a refused transition, a contract lists a document type's rules and checks a document against them with the code consensus runs. The check covers the rules alone, not the JSON schema, and reads the document's owner for `$ownerId`: + +```ts +contract.documentTypePropertyConstraints('offer'); +// [{ name: 'discountBelowPrice', rule: { lessThan: ['discount', 'price'] }, +// reads: [{ path: 'discount', kind: 'value' }, { path: 'price', kind: 'value' }], +// readsOwner: false }, ...] + +const broken = contract.checkDocumentPropertyConstraints(document); +if (broken) { + // { rule: 'discountBelowPrice', violation: 'NotMet', message: 'it does not hold' } +} +``` + +Rules come back in name order, the order consensus checks them in; `contract.documentPropertyConstraints` maps every document type that declares rules to its list. The `PropertyConstraintCondition`, `PropertyConstraintExpression` and `PropertyConstraintEqualityOperand` types spell out the rule grammar, and `violation` is one of `NotMet`, `Overflow`, `DivisionByZero`, `NegativeExponent` or `NotAnInteger`, the reason consensus would report. + ## Chained queries (provable semi-join) A `refersTo: permanentDocument` declaration also lights up the read side: a **chained query** answers `SELECT * FROM post WHERE $id IN (SELECT postId FROM like WHERE $ownerId = me)` in one verified round trip. The node returns the inner indexOnly page and the referenced documents under ONE merged proof — a single quorum-signed state root by construction — and the SDK re-derives the outer query itself and checks it against the *proven* inner values — the node cannot substitute, omit, or inject joined documents. For a `permanentDocument` join property a missing referenced document fails verification outright, since such a reference cannot dangle. For a `deletableDocument` join property a referenced document that was deleted since is proven absent: it has no entry in `outerDocuments` (so match the two halves by id, not by position) and its id is listed in `missingOuterIds`, in first-appearance order. The node still cannot pass an existing document off as deleted. diff --git a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs new file mode 100644 index 00000000000..b5d5c827a37 --- /dev/null +++ b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs @@ -0,0 +1,338 @@ +//! `propertyConstraints` rules: the conditions a document type holds every +//! created or replaced document's properties to, from protocol version 14 +//! onward. +//! +//! A rule is a condition: a comparison of integer expressions, a membership +//! test (`in`), a comparison of a string or an identifier property (or +//! `$ownerId`, the document's owner) with constants or with another property +//! of its kind, a presence test (`present`, `absent`), or `anyOf`, `allOf` or +//! `not` over conditions. Consensus evaluates every rule on each create and +//! replace, and the rules reading `$ownerId` on each transfer and purchase, +//! refusing a broken one with `DocumentPropertyConstraintViolatedError` +//! (basic code 10422). What this module adds is *discovery* ("which rules does +//! this document type declare, and what do they read?") and a *pre-check* +//! that evaluates a document against them with the very code consensus runs, +//! so an app can find a broken rule before paying for a refused transition. + +use crate::error::{WasmDppError, WasmDppResult}; +use dpp::consensus::basic::document::PropertyConstraintViolation; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::property_constraints::PropertyRead; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::platform_value::Value; +use js_sys::{Array, BigInt, Object, Reflect}; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const DOCUMENT_PROPERTY_CONSTRAINTS_TS: &'static str = r#" +/** + * An integer expression of a `propertyConstraints` rule. + * + * - a number: an integer value, a `bigint` past `Number.MAX_SAFE_INTEGER` + * (rules report such a literal as a `bigint`, exactly); + * - a string: the dotted path of an integer or boolean property, whose value + * it takes (a boolean reads as 1 for true and 0 for false), 0 when the + * document leaves the property out; + * - `ifAbsent`: a property path and the integer it takes when left out; + * - `add` and `multiply` over two or more operands, `subtract`, `divide`, + * `modulo` and `power` over exactly two. Arithmetic is exact over 128-bit + * integers; `divide` and `modulo` are Euclidean. + */ +export type PropertyConstraintExpression = + | number + | bigint + | string + | { ifAbsent: [path: string, value: number | bigint] } + | { add: PropertyConstraintExpression[] } + | { multiply: PropertyConstraintExpression[] } + | { subtract: [PropertyConstraintExpression, PropertyConstraintExpression] } + | { divide: [PropertyConstraintExpression, PropertyConstraintExpression] } + | { modulo: [PropertyConstraintExpression, PropertyConstraintExpression] } + | { power: [PropertyConstraintExpression, PropertyConstraintExpression] }; + +/** + * One side of a comparison of strings or identifiers. + * + * - a string: the dotted path of a string or an identifier property, or + * `"$ownerId"`, the document's owner, an identifier; + * - `const`: a string constant, or a base58 identifier beside an identifier + * property or `$ownerId`; + * - `ifAbsent`: a string property with the string it takes when left out. + * + * A property the document leaves out without a default equals nothing. + */ +export type PropertyConstraintEqualityOperand = + | string + | { const: string } + | { ifAbsent: [path: string, value: string] }; + +/** + * A `propertyConstraints` rule, or a condition inside one. + * + * - a comparison of two integer expressions; `equal` and `notEqual` also + * compare strings or identifiers, which are never ordered; + * - `in`: an integer expression and two or more distinct integers, or a + * string or identifier property (or `$ownerId`) and two or more distinct + * strings or base58 identifiers; + * - `present` / `absent`: whether the document holds a property of any type; + * - `anyOf` / `allOf` over two or more conditions, `not` over one. Conditions + * are checked in order and no further than the outcome needs. + */ +export type PropertyConstraintCondition = + | { equal: [PropertyConstraintExpression, PropertyConstraintExpression] | [PropertyConstraintEqualityOperand, PropertyConstraintEqualityOperand] } + | { notEqual: [PropertyConstraintExpression, PropertyConstraintExpression] | [PropertyConstraintEqualityOperand, PropertyConstraintEqualityOperand] } + | { lessThan: [PropertyConstraintExpression, PropertyConstraintExpression] } + | { lessThanOrEqual: [PropertyConstraintExpression, PropertyConstraintExpression] } + | { greaterThan: [PropertyConstraintExpression, PropertyConstraintExpression] } + | { greaterThanOrEqual: [PropertyConstraintExpression, PropertyConstraintExpression] } + | { in: [PropertyConstraintExpression, Array] | [string | { ifAbsent: [path: string, value: string] }, string[]] } + | { present: string } + | { absent: string } + | { anyOf: PropertyConstraintCondition[] } + | { allOf: PropertyConstraintCondition[] } + | { not: PropertyConstraintCondition }; + +/** + * How a rule reads a property: `value` as an integer operand, `presence` in + * `present` or `absent`, `text` compared with strings, `identifier` compared + * with identifiers. + */ +export type PropertyConstraintReadKind = 'value' | 'presence' | 'text' | 'identifier'; + +/** + * A single `propertyConstraints` rule of a document type. + */ +export type DocumentPropertyConstraint = { + /** The rule's name, its key in `propertyConstraints`. Rules are checked in name order. */ + name: string; + /** The rule as the schema declares it. */ + rule: PropertyConstraintCondition; + /** Every property the rule reads, in declared order; `$ownerId` is no property and is not listed. */ + reads: Array<{ path: string; kind: PropertyConstraintReadKind }>; + /** Whether the rule reads `$ownerId`: then a transfer or a purchase is judged against it too. */ + readsOwner: boolean; +}; + +/** + * Why a document breaks a rule, the `violation` consensus reports in + * `DocumentPropertyConstraintViolatedError` (code 10422): `NotMet` when the + * rule evaluates to false, or the fault met evaluating it. + */ +export type PropertyConstraintViolationKind = + | 'NotMet' + | 'Overflow' + | 'DivisionByZero' + | 'NegativeExponent' + | 'NotAnInteger'; + +/** + * The first rule a document breaks, as consensus would report it. + */ +export type DocumentPropertyConstraintViolation = { + /** The broken rule's name. */ + rule: string; + violation: PropertyConstraintViolationKind; + /** A readable reason, as in the consensus error's message. */ + message: string; +}; +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "Array")] + pub type DocumentPropertyConstraintArrayJs; + + #[wasm_bindgen(typescript_type = "Map>")] + pub type DocumentPropertyConstraintMapJs; + + #[wasm_bindgen(typescript_type = "DocumentPropertyConstraintViolation | undefined")] + pub type DocumentPropertyConstraintViolationJs; +} + +/// `Reflect::set` with the collection-getter error convention the other +/// document type accessors use. +fn set_field(target: &Object, key: &str, value: &JsValue, rule: &str) -> WasmDppResult<()> { + Reflect::set(target, &JsValue::from_str(key), value).map_err(|_| { + WasmDppError::generic(format!( + "unable to serialize the `{key}` field of the propertyConstraints rule '{rule}'" + )) + })?; + Ok(()) +} + +/// The name a read kind goes by in `PropertyConstraintReadKind`. +fn read_kind_name(read: PropertyRead) -> &'static str { + match read { + PropertyRead::Value => "value", + PropertyRead::Presence => "presence", + PropertyRead::Text => "text", + PropertyRead::Identifier => "identifier", + } +} + +/// The name a violation goes by in `PropertyConstraintViolationKind`, the +/// variant's own. +fn violation_name(violation: PropertyConstraintViolation) -> &'static str { + match violation { + PropertyConstraintViolation::NotMet => "NotMet", + PropertyConstraintViolation::Overflow => "Overflow", + PropertyConstraintViolation::DivisionByZero => "DivisionByZero", + PropertyConstraintViolation::NegativeExponent => "NegativeExponent", + PropertyConstraintViolation::NotAnInteger => "NotAnInteger", + } +} + +/// `Number.MAX_SAFE_INTEGER`, the largest integer a JS `number` holds exactly. +const MAX_SAFE_INTEGER: i128 = (1 << 53) - 1; + +/// An integer literal of a rule: a `number` while it is exact in JavaScript, +/// a `bigint` past that. +fn integer_to_js(integer: i128) -> JsValue { + if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&integer) { + JsValue::from_f64(integer as f64) + } else { + BigInt::from(integer).into() + } +} + +/// A declared rule as JS, the JSON it was declared as. Not through +/// `serde_json`: a rule may compare with any 64-bit literal, and the JSON +/// conversion throws on one past `Number.MAX_SAFE_INTEGER`, which would hide +/// every rule of the type. +fn rule_to_js(value: &Value, rule: &str) -> WasmDppResult { + Ok(match value { + Value::Text(text) => JsValue::from_str(text), + Value::Bool(flag) => JsValue::from_bool(*flag), + Value::Null => JsValue::NULL, + Value::Float(number) => JsValue::from_f64(*number), + Value::U8(integer) => integer_to_js((*integer).into()), + Value::U16(integer) => integer_to_js((*integer).into()), + Value::U32(integer) => integer_to_js((*integer).into()), + Value::U64(integer) => integer_to_js((*integer).into()), + Value::I8(integer) => integer_to_js((*integer).into()), + Value::I16(integer) => integer_to_js((*integer).into()), + Value::I32(integer) => integer_to_js((*integer).into()), + Value::I64(integer) => integer_to_js((*integer).into()), + Value::I128(integer) => integer_to_js(*integer), + Value::U128(integer) => match i128::try_from(*integer) { + Ok(integer) => integer_to_js(integer), + Err(_) => BigInt::from(*integer).into(), + }, + Value::Array(items) => { + let array = Array::new(); + for item in items { + array.push(&rule_to_js(item, rule)?); + } + array.into() + } + Value::Map(entries) => { + let object = Object::new(); + for (key, entry) in entries { + let key = key.as_text().ok_or_else(|| { + WasmDppError::generic(format!( + "the propertyConstraints rule '{rule}' has a key that is not a string" + )) + })?; + set_field(&object, key, &rule_to_js(entry, rule)?, rule)?; + } + object.into() + } + other => { + return Err(WasmDppError::generic(format!( + "the propertyConstraints rule '{rule}' holds {other}, which is not JSON" + ))); + } + }) +} + +/// Collect every `propertyConstraints` rule of one document type, in name +/// order, the order consensus checks them in. +/// +/// The parsed rules give the name, the reads and whether the owner is read; +/// the rule itself is the schema's declaration, which is what an app wrote, +/// with integer literals past `Number.MAX_SAFE_INTEGER` as `bigint`. +pub(crate) fn property_constraints_for_document_type( + document_type: DocumentTypeRef<'_>, +) -> WasmDppResult { + let rules = Array::new(); + let declarations = document_type + .schema() + .get_optional_value("propertyConstraints") + .ok() + .flatten(); + + for (name, constraint) in document_type.property_constraints() { + let object = Object::new(); + set_field(&object, "name", &JsValue::from_str(name), name)?; + + let declared = declarations + .and_then(|declarations| declarations.get_optional_value(name).ok().flatten()) + .ok_or_else(|| { + WasmDppError::generic(format!( + "the propertyConstraints rule '{name}' is missing from the document type's \ + schema" + )) + })?; + set_field(&object, "rule", &rule_to_js(declared, name)?, name)?; + + let reads = Array::new(); + for (path, read) in constraint.property_reads() { + let entry = Object::new(); + set_field(&entry, "path", &JsValue::from_str(path), name)?; + set_field( + &entry, + "kind", + &JsValue::from_str(read_kind_name(read)), + name, + )?; + reads.push(&entry); + } + set_field(&object, "reads", &reads, name)?; + set_field( + &object, + "readsOwner", + &JsValue::from_bool(constraint.reads_owner()), + name, + )?; + rules.push(&object); + } + + Ok(rules) +} + +/// The first rule of `document_type`'s `propertyConstraints` that `document` +/// breaks, in name order, as consensus judges a create or replace: its +/// properties, and its owner for `$ownerId`. `undefined` when it meets them +/// all. +pub(crate) fn check_property_constraints( + document_type: DocumentTypeRef<'_>, + document: &Document, +) -> WasmDppResult { + let constraints = document_type.property_constraints(); + if constraints.is_empty() { + return Ok(JsValue::UNDEFINED); + } + let data = Value::from(document.properties().clone()); + for (name, constraint) in constraints { + if let Some(violation) = constraint.violation(&data, Some(document.owner_id())) { + let object = Object::new(); + set_field(&object, "rule", &JsValue::from_str(name), name)?; + set_field( + &object, + "violation", + &JsValue::from_str(violation_name(violation)), + name, + )?; + set_field( + &object, + "message", + &JsValue::from_str(&violation.to_string()), + name, + )?; + return Ok(object.into()); + } + } + Ok(JsValue::UNDEFINED) +} diff --git a/packages/wasm-dpp2/src/data_contract/mod.rs b/packages/wasm-dpp2/src/data_contract/mod.rs index a205ca2023c..340856f3800 100644 --- a/packages/wasm-dpp2/src/data_contract/mod.rs +++ b/packages/wasm-dpp2/src/data_contract/mod.rs @@ -3,6 +3,7 @@ pub mod document; pub mod document_type_distinct_from; pub mod document_type_encryption; pub mod document_type_immutability; +pub mod document_type_property_constraints; pub mod document_type_reference; pub mod document_type_typed_arrays; pub mod model; @@ -19,6 +20,10 @@ pub use document_type_encryption::{ pub use document_type_immutability::{ DocumentTypeImmutablePropertiesJs, DocumentTypeImmutablePropertiesMapJs, }; +pub use document_type_property_constraints::{ + DocumentPropertyConstraintArrayJs, DocumentPropertyConstraintMapJs, + DocumentPropertyConstraintViolationJs, +}; pub use document_type_reference::{ DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs, }; diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index 3770d792f10..be6e7cbcb48 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -1,3 +1,4 @@ +use crate::data_contract::DocumentWasm; use crate::data_contract::document_type_distinct_from::{ DocumentPropertyDistinctFromArrayJs, DocumentPropertyDistinctFromMapJs, distinct_from_for_document_type, @@ -10,6 +11,11 @@ use crate::data_contract::document_type_immutability::{ DocumentTypeImmutablePropertiesJs, DocumentTypeImmutablePropertiesMapJs, immutable_properties_for_document_type, }; +use crate::data_contract::document_type_property_constraints::{ + DocumentPropertyConstraintArrayJs, DocumentPropertyConstraintMapJs, + DocumentPropertyConstraintViolationJs, check_property_constraints, + property_constraints_for_document_type, +}; use crate::data_contract::document_type_reference::{ DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs, references_for_document_type, }; @@ -44,6 +50,7 @@ use dpp::data_contract::serialized_version::DataContractInSerializationFormat; use dpp::data_contract::{ DataContract, GroupContractPosition, TokenConfiguration, TokenContractPosition, }; +use dpp::platform_value::string_encoding::Encoding; use dpp::platform_value::string_encoding::Encoding::{Base64, Hex}; use dpp::platform_value::string_encoding::{decode, encode}; use dpp::platform_value::{Value, ValueMap}; @@ -814,6 +821,92 @@ impl DataContractWasm { Ok(JsValue::from(map).into()) } + /// All `propertyConstraints` rules of one document type, in name order, + /// the order consensus checks them in: each rule's name, the rule as the + /// schema declares it, every property it reads and how, and whether it + /// reads `$ownerId` (then a transfer or a purchase is judged against it + /// too). + /// + /// Returns an empty array when the document type declares none. Throws + /// when the contract has no document type by that name, so "no such + /// type" and "no rules" stay distinguishable. + /// + /// The keyword is only parsed from protocol version 14 onward. A + /// contract deserialized against an earlier platform version reports + /// none, which is exactly what consensus enforced at that version, while + /// `toJSON()` still shows the raw keyword either way. + #[wasm_bindgen(js_name = "documentTypePropertyConstraints")] + pub fn document_type_property_constraints( + &self, + #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, + ) -> WasmDppResult { + let document_type = self + .0 + .document_type_optional_for_name(document_type_name.as_str()) + .ok_or_else(|| { + WasmDppError::invalid_argument(format!( + "document type '{document_type_name}' not found in contract" + )) + })?; + + let rules = property_constraints_for_document_type(document_type)?; + Ok(JsValue::from(rules).into()) + } + + /// Every document type that declares `propertyConstraints`, keyed by + /// document type name. + /// + /// Document types with no rules are omitted, so an empty `Map` means + /// "this contract declares no propertyConstraints at all". + #[wasm_bindgen(getter = "documentPropertyConstraints")] + pub fn document_property_constraints(&self) -> WasmDppResult { + let map = js_sys::Map::new(); + + for (name, document_type) in self.0.document_types() { + let rules = property_constraints_for_document_type(document_type.as_ref())?; + if rules.length() > 0 { + map.set(&JsValue::from_str(name), &rules.into()); + } + } + + Ok(JsValue::from(map).into()) + } + + /// The first `propertyConstraints` rule `document` breaks, in name order, + /// evaluated with the same code consensus runs on a create or replace: its + /// properties, and its owner for `$ownerId`. `undefined` when it meets + /// every rule of its document type. + /// + /// A pre-check, so an app can refuse a document before paying for a + /// transition consensus would refuse with + /// `DocumentPropertyConstraintViolatedError` (code 10422). It judges the + /// rules alone, not the document's JSON schema. Throws when the document + /// belongs to another contract or names a document type this one lacks. + #[wasm_bindgen(js_name = "checkDocumentPropertyConstraints")] + pub fn check_document_property_constraints( + &self, + document: &DocumentWasm, + ) -> WasmDppResult { + let document_contract_id: Identifier = document.data_contract_id.into(); + if document_contract_id != self.0.id() { + return Err(WasmDppError::invalid_argument(format!( + "the document belongs to contract {}, not this one", + document_contract_id.to_string(Encoding::Base58) + ))); + } + let document_type_name = &document.document_type_name; + let document_type = self + .0 + .document_type_optional_for_name(document_type_name) + .ok_or_else(|| { + WasmDppError::invalid_argument(format!( + "document type '{document_type_name}' not found in contract" + )) + })?; + + Ok(check_property_constraints(document_type, &document.document)?.into()) + } + /// All `encryptedFor` declarations of one document type, in schema /// property order: which byte array properties are encrypted, for whom, /// under which key ids and under which scheme. diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts new file mode 100644 index 00000000000..b18dae30a63 --- /dev/null +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts @@ -0,0 +1,296 @@ +/** + * Verifies the `propertyConstraints` surface introduced with protocol version + * 14. + * + * A document type names rules its documents' properties must meet: integer + * comparisons and `in`, string and identifier comparisons (with `$ownerId`), + * `present` / `absent`, and `anyOf` / `allOf` / `not` over them. Consensus + * refuses a broken rule with code 10422. What the JS layer offers is + * discovery (which rules a type declares and what they read) and a pre-check + * that evaluates a document with the code consensus runs. + */ +import { expect } from './helpers/chai.ts'; +import { initWasm, wasm } from '../../dist/dpp.compressed.js'; + +let PlatformVersion: typeof wasm.PlatformVersion; + +before(async () => { + await initWasm(); + ({ PlatformVersion } = wasm); +}); + +const ownerId = 'CXH2kZCATjvDTnQAPVg28EgPg9WySUvwvnR5ZkmNqY5i'; +const otherId = '9tSsCqKHTZ8ro16MydChSxgHBukFW36eMLJKKRtebJEn'; + +const identifierProperty = (position: number) => ({ + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', + position, +}); + +/** + * An `offer` declaring one rule of each family, next to a `plain` type + * declaring none. + */ +const schemas = { + offer: { + type: 'object', + properties: { + price: { type: 'integer', minimum: 0, position: 0 }, + fee: { type: 'integer', minimum: 0, position: 1 }, + discount: { type: 'integer', minimum: 0, position: 2 }, + status: { + type: 'string', enum: ['open', 'closed'], maxLength: 10, position: 3, + }, + closedAt: { type: 'integer', minimum: 0, position: 4 }, + sellerId: identifierProperty(5), + }, + required: ['price', 'fee'], + additionalProperties: false, + propertyConstraints: { + closedNeedsClosedAt: { + anyOf: [{ notEqual: ['status', { const: 'closed' }] }, { present: 'closedAt' }], + }, + discountBelowPrice: { lessThan: ['discount', 'price'] }, + perUnitFee: { greaterThanOrEqual: [{ divide: ['price', 'fee'] }, 1] }, + sellerIsOwner: { + anyOf: [{ absent: 'sellerId' }, { equal: ['sellerId', '$ownerId'] }], + }, + tieredFee: { in: ['fee', [1, 10, 25]] }, + }, + }, + plain: { + type: 'object', + properties: { + message: { type: 'string', position: 0, maxLength: 64 }, + }, + additionalProperties: false, + }, +}; + +function buildContract(contractSchemas: Record, platformVersion = 14) { + return new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: contractSchemas, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(platformVersion), + }); +} + +function offer( + contract: InstanceType, + properties: Record, + owner = ownerId, +) { + return new wasm.Document({ + properties: { price: 100, fee: 10, ...properties }, + documentTypeName: 'offer', + dataContractId: contract.id, + ownerId: owner, + revision: BigInt(1), + }); +} + +describe('DataContract: propertyConstraints (v14)', () => { + describe('documentTypePropertyConstraints()', () => { + it('should list every rule in name order with what it reads', () => { + const contract = buildContract(schemas); + + expect(contract.documentTypePropertyConstraints('offer')).to.deep.equal([ + { + name: 'closedNeedsClosedAt', + rule: schemas.offer.propertyConstraints.closedNeedsClosedAt, + reads: [ + { path: 'status', kind: 'text' }, + { path: 'closedAt', kind: 'presence' }, + ], + readsOwner: false, + }, + { + name: 'discountBelowPrice', + rule: schemas.offer.propertyConstraints.discountBelowPrice, + reads: [ + { path: 'discount', kind: 'value' }, + { path: 'price', kind: 'value' }, + ], + readsOwner: false, + }, + { + name: 'perUnitFee', + rule: schemas.offer.propertyConstraints.perUnitFee, + reads: [ + { path: 'price', kind: 'value' }, + { path: 'fee', kind: 'value' }, + ], + readsOwner: false, + }, + { + name: 'sellerIsOwner', + rule: schemas.offer.propertyConstraints.sellerIsOwner, + reads: [ + { path: 'sellerId', kind: 'presence' }, + { path: 'sellerId', kind: 'identifier' }, + ], + readsOwner: true, + }, + { + name: 'tieredFee', + rule: schemas.offer.propertyConstraints.tieredFee, + reads: [{ path: 'fee', kind: 'value' }], + readsOwner: false, + }, + ]); + }); + + it('should return an empty array for a document type declaring none', () => { + const contract = buildContract(schemas); + + expect(contract.documentTypePropertyConstraints('plain')).to.deep.equal([]); + }); + + it('should throw for an unknown document type', () => { + const contract = buildContract(schemas); + + expect(() => contract.documentTypePropertyConstraints('doesNotExist')).to.throw(/not found/); + }); + + it('should key the types declaring rules in documentPropertyConstraints', () => { + const contract = buildContract(schemas); + const byType = contract.documentPropertyConstraints; + + expect([...byType.keys()]).to.deep.equal(['offer']); + expect(byType.get('offer')).to.have.length(5); + }); + + it('should report integer literals past Number.MAX_SAFE_INTEGER exactly, as bigint', () => { + const big = 9007199254740993n; // 2 ** 53 + 1, which a number rounds + const rules = { + balanceBelowCap: { lessThan: [{ ifAbsent: ['balance', -big] }, big] }, + knownTier: { in: ['tier', [1, big]] }, + }; + const contract = buildContract({ + ledger: { + type: 'object', + properties: { + balance: { type: 'integer', position: 0 }, + tier: { type: 'integer', position: 1 }, + }, + additionalProperties: false, + propertyConstraints: rules, + }, + }); + const expected = [ + { + name: 'balanceBelowCap', + rule: rules.balanceBelowCap, + reads: [{ path: 'balance', kind: 'value' }], + readsOwner: false, + }, + { + name: 'knownTier', + rule: rules.knownTier, + reads: [{ path: 'tier', kind: 'value' }], + readsOwner: false, + }, + ]; + + expect(contract.documentTypePropertyConstraints('ledger')).to.deep.equal(expected); + expect(contract.documentPropertyConstraints.get('ledger')).to.deep.equal(expected); + }); + + /** + * Parsers before protocol version 14 ignore the keyword, so a contract + * read at such a version reports no rules: exactly what consensus + * enforced there. + */ + it('should report no rules on a pre-v14 contract', () => { + const contract = buildContract(schemas, 13); + + expect(contract.documentTypePropertyConstraints('offer')).to.deep.equal([]); + expect(contract.checkDocumentPropertyConstraints(offer(contract, { discount: 200 }))) + .to.equal(undefined); + }); + }); + + describe('checkDocumentPropertyConstraints()', () => { + it('should report nothing for a document meeting every rule', () => { + const contract = buildContract(schemas); + + expect(contract.checkDocumentPropertyConstraints(offer(contract, {}))).to.equal(undefined); + expect(contract.checkDocumentPropertyConstraints(offer(contract, { + status: 'closed', closedAt: 1000, sellerId: ownerId, discount: 5, + }))).to.equal(undefined); + }); + + it('should report the first rule broken, in name order, as consensus would', () => { + const contract = buildContract(schemas); + const violationOf = (properties: Record, owner = ownerId) => ( + contract.checkDocumentPropertyConstraints(offer(contract, properties, owner)) + ); + + expect(violationOf({ status: 'closed' })).to.deep.include({ + rule: 'closedNeedsClosedAt', violation: 'NotMet', + }); + expect(violationOf({ discount: 200 })).to.deep.include({ + rule: 'discountBelowPrice', violation: 'NotMet', + }); + // A fee of 0 divides by zero before the tier rule is reached + expect(violationOf({ fee: 0 })).to.deep.include({ + rule: 'perUnitFee', violation: 'DivisionByZero', + }); + expect(violationOf({ fee: 5 })).to.deep.include({ + rule: 'tieredFee', violation: 'NotMet', + }); + expect(violationOf({ fee: 5 }).message).to.be.a('string'); + }); + + it('should read the document owner for $ownerId', () => { + const contract = buildContract(schemas); + + expect(contract.checkDocumentPropertyConstraints(offer(contract, { sellerId: otherId }))) + .to.deep.include({ rule: 'sellerIsOwner', violation: 'NotMet' }); + expect(contract.checkDocumentPropertyConstraints( + offer(contract, { sellerId: otherId }, otherId), + )).to.equal(undefined); + }); + + it('should report nothing for a document type declaring no rules', () => { + const contract = buildContract(schemas); + const document = new wasm.Document({ + properties: { message: 'hi' }, + documentTypeName: 'plain', + dataContractId: contract.id, + ownerId, + revision: BigInt(1), + }); + + expect(contract.checkDocumentPropertyConstraints(document)).to.equal(undefined); + }); + + it('should throw for a document of another contract or an unknown type', () => { + const contract = buildContract(schemas); + const foreign = new wasm.Document({ + properties: { price: 100, fee: 10 }, + documentTypeName: 'offer', + dataContractId: otherId, + ownerId, + revision: BigInt(1), + }); + const unknownType = new wasm.Document({ + properties: {}, + documentTypeName: 'doesNotExist', + dataContractId: contract.id, + ownerId, + revision: BigInt(1), + }); + + expect(() => contract.checkDocumentPropertyConstraints(foreign)).to.throw(/another contract|not this one/); + expect(() => contract.checkDocumentPropertyConstraints(unknownType)).to.throw(/not found/); + }); + }); +}); From a5a1af5e0557d81eee02c3b2cf73b34b23ad4972 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 23:03:30 +0700 Subject: [PATCH 028/113] feat(sdk): propertyConstraints rules and pre-check in the Swift SDK and iOS example app (#5064) Co-authored-by: Claude Opus 5.5 --- packages/rs-sdk-ffi/src/data_contract/mod.rs | 8 + .../src/data_contract/property_constraints.rs | 852 ++++++++++++++++++ packages/rs-sdk-ffi/src/document/create.rs | 47 +- packages/rs-sdk-ffi/src/document/helpers.rs | 62 +- packages/rs-sdk-ffi/src/document/mod.rs | 1 + .../Utils/DocumentPropertyConstraints.swift | 345 +++++++ .../Models/PersistentDocumentType.swift | 54 ++ .../Views/DocumentTypeDetailsView.swift | 119 +++ .../SwiftExampleApp/Views/DocumentsView.swift | 40 +- .../DocumentPropertyConstraintsTests.swift | 381 ++++++++ 10 files changed, 1873 insertions(+), 36 deletions(-) create mode 100644 packages/rs-sdk-ffi/src/data_contract/property_constraints.rs create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift diff --git a/packages/rs-sdk-ffi/src/data_contract/mod.rs b/packages/rs-sdk-ffi/src/data_contract/mod.rs index 24f6ea93fa1..2cd73e05d98 100644 --- a/packages/rs-sdk-ffi/src/data_contract/mod.rs +++ b/packages/rs-sdk-ffi/src/data_contract/mod.rs @@ -17,7 +17,10 @@ //! - `dash_sdk_data_contract_destroy` — destructor for handles //! produced by the query layer. //! - The query function re-exports from `queries::*`. +//! - The `propertyConstraints` rules of a document type, and the +//! pre-check of a document against them (`property_constraints`). +mod property_constraints; mod put; mod queries; mod util; @@ -55,6 +58,11 @@ pub unsafe extern "C" fn dash_sdk_data_contract_destroy(handle: *mut DataContrac } } +pub use property_constraints::{ + dash_sdk_data_contract_check_property_constraints, + dash_sdk_data_contract_get_property_constraints, +}; + // Re-export query functions pub use queries::{ dash_sdk_data_contract_fetch, dash_sdk_data_contract_fetch_history, diff --git a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs new file mode 100644 index 00000000000..43e9ed8adb5 --- /dev/null +++ b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs @@ -0,0 +1,852 @@ +//! `propertyConstraints` rules (protocol version 14): the named conditions a +//! document type holds every created or replaced document's properties to. +//! Consensus refuses a document breaking one with +//! `DocumentPropertyConstraintViolatedError` (basic code 10422), and a refused +//! state transition is still paid for. +//! +//! Two functions expose what DPP already knows about them: +//! +//! - [`dash_sdk_data_contract_get_property_constraints`]: the rules a document +//! type declares, in name order (the order consensus checks them in), each +//! with what it reads, as a JSON array; +//! - [`dash_sdk_data_contract_check_property_constraints`]: the first rule a +//! document to create breaks, judged by DPP's own +//! `DocumentType::validate_property_constraints` (the check consensus runs, +//! evaluating each rule with `PropertyConstraint::violation`), as JSON, or +//! JSON `null` when it meets them all. +//! +//! The JSON shapes are those of wasm-dpp2's `documentTypePropertyConstraints` +//! and `checkDocumentPropertyConstraints`, key for key, so every SDK reports a +//! rule and a violation alike. +//! +//! Both take the contract as its platform serialization: the bytes a client +//! keeps beside a fetched contract, which it also hands +//! `dash_sdk_add_known_contracts`. They read it at the SDK's protocol version, +//! the version the SDK builds and sends documents at, so a client refreshing +//! that version sees the rules consensus applies: before protocol version 14 a +//! document type carries none. + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; + +use dash_sdk::dpp::consensus::basic::document::PropertyConstraintViolation; +use dash_sdk::dpp::consensus::basic::BasicError; +use dash_sdk::dpp::consensus::ConsensusError; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; +use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dash_sdk::dpp::data_contract::document_type::property_constraints::PropertyRead; +use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; +use dash_sdk::dpp::document::{Document, DocumentV0Getters}; +use dash_sdk::dpp::platform_value::Value; +use dash_sdk::dpp::prelude::{DataContract, Identifier}; +use dash_sdk::dpp::serialization::PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted; +use dash_sdk::dpp::version::PlatformVersion; +use serde_json::json; + +use crate::document::{build_document_from_properties, parse_document_properties_json}; +use crate::sdk::SDKWrapper; +use crate::types::SDKHandle; +use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; + +/// The doctype keyword declaring the rules, whose entries are the rules as +/// the contract wrote them. +const PROPERTY_CONSTRAINTS_KEYWORD: &str = "propertyConstraints"; + +/// Get the `propertyConstraints` rules of a document type as a JSON array +/// +/// Each element is +/// `{ "name": string, "rule": object, "reads": [{ "path": string, "kind": string }], "readsOwner": bool }`: +/// the rule's name (its key in `propertyConstraints`), the rule exactly as the +/// document type's schema declares it, every property it reads in declared +/// order (`kind` is `"value"` for an integer operand, `"presence"` for +/// `present` / `absent`, `"text"` for a string comparison and `"identifier"` +/// for an identifier comparison; `$ownerId` is no property and is not listed), +/// and whether it reads `$ownerId`, which makes a transfer or a purchase answer +/// to it too. Rules are listed in name order, the order consensus checks them +/// in. A document type declaring none gives `[]`, and so does every document +/// type when the SDK's protocol version is below 14. +/// +/// The contract is read from its platform serialization at the SDK's protocol +/// version, as `dash_sdk_add_known_contracts` reads it, without re-validating +/// it. +/// +/// Errors: `InvalidParameter` for a null or empty argument or a document type +/// name that is not UTF-8, `SerializationError` for bytes that are not a +/// contract, `NotFound` for a document type the contract does not declare. +/// +/// # Safety +/// - `sdk_handle` must be a valid, non-null pointer to an initialized `SDKHandle`. +/// - `serialized_contract` must point to `serialized_contract_len` readable bytes. +/// - `document_type` must point to a NUL-terminated C string valid for the duration of the call. +/// - On success the result's `data` is a heap-allocated C string the caller frees with +/// `dash_sdk_string_free`; on error the caller frees `error` with `dash_sdk_error_free`. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_data_contract_get_property_constraints( + sdk_handle: *const SDKHandle, + serialized_contract: *const u8, + serialized_contract_len: usize, + document_type: *const c_char, +) -> DashSDKResult { + if sdk_handle.is_null() || serialized_contract.is_null() || document_type.is_null() { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "SDK handle, serialized contract or document type is null".to_string(), + )); + } + + // SAFETY: the caller guarantees `sdk_handle` points to a live SDKWrapper + let wrapper = &*(sdk_handle as *const SDKWrapper); + // SAFETY: non-null, and the caller guarantees NUL termination + let document_type_name = match CStr::from_ptr(document_type).to_str() { + Ok(name) => name, + Err(e) => return DashSDKResult::error(FFIError::from(e).into()), + }; + + let rules = deserialize_contract( + serialized_contract, + serialized_contract_len, + wrapper.sdk.version(), + ) + .and_then(|contract| { + let document_type = document_type_named(&contract, document_type_name)?; + property_constraints_json(document_type) + }); + json_result(rules) +} + +/// Check a document to create against its document type's `propertyConstraints` +/// +/// `properties_json` is the document's properties as `dash_sdk_document_create` +/// takes them (a JSON object keyed by property name, byte arrays as hex or +/// base64 and identifiers as base58 or hex strings), and `owner_id` the 32 +/// bytes of the identity that will own it, which `$ownerId` reads. The +/// properties are turned into the document `dash_sdk_document_create` builds, +/// with the same parsing, sanitizing and `create_document_from_data`, so every +/// value is typed as it would be sent; the document is then judged by DPP's +/// `validate_property_constraints`, the check consensus runs on a create: +/// every rule, in name order, evaluated by `PropertyConstraint::violation`. +/// Nothing but the rules is checked: not the JSON schema, not the state. +/// +/// The result is the first rule broken, as +/// `{ "rule": string, "violation": string, "message": string }`, with +/// `violation` one of `"NotMet"`, `"Overflow"`, `"DivisionByZero"`, +/// `"NegativeExponent"` and `"NotAnInteger"` and `message` the reason +/// consensus gives, or JSON `null` when the document meets every rule (always +/// so below protocol version 14). +/// +/// The contract is read from its platform serialization at the SDK's protocol +/// version, as `dash_sdk_add_known_contracts` reads it, without re-validating +/// it. +/// +/// Errors: `InvalidParameter` for a null or empty argument, text that is not +/// UTF-8, properties that are not a JSON object, or properties no document can +/// be built from; `SerializationError` for bytes that are not a contract; +/// `NotFound` for a document type the contract does not declare. +/// +/// # Safety +/// - `sdk_handle` must be a valid, non-null pointer to an initialized `SDKHandle`. +/// - `serialized_contract` must point to `serialized_contract_len` readable bytes. +/// - `document_type` and `properties_json` must point to NUL-terminated C strings valid for the +/// duration of the call. +/// - `owner_id` must point to 32 readable bytes. +/// - On success the result's `data` is a heap-allocated C string the caller frees with +/// `dash_sdk_string_free`; on error the caller frees `error` with `dash_sdk_error_free`. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_data_contract_check_property_constraints( + sdk_handle: *const SDKHandle, + serialized_contract: *const u8, + serialized_contract_len: usize, + document_type: *const c_char, + properties_json: *const c_char, + owner_id: *const u8, +) -> DashSDKResult { + if sdk_handle.is_null() + || serialized_contract.is_null() + || document_type.is_null() + || properties_json.is_null() + || owner_id.is_null() + { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "SDK handle, serialized contract, document type, properties JSON or owner ID is null" + .to_string(), + )); + } + + // SAFETY: the caller guarantees `sdk_handle` points to a live SDKWrapper + let wrapper = &*(sdk_handle as *const SDKWrapper); + // SAFETY: non-null, and the caller guarantees NUL termination + let document_type_name = match CStr::from_ptr(document_type).to_str() { + Ok(name) => name, + Err(e) => return DashSDKResult::error(FFIError::from(e).into()), + }; + // SAFETY: non-null, and the caller guarantees NUL termination + let properties_str = match CStr::from_ptr(properties_json).to_str() { + Ok(properties) => properties, + Err(e) => return DashSDKResult::error(FFIError::from(e).into()), + }; + // SAFETY: non-null, and the caller guarantees 32 readable bytes + let owner_bytes = &*(owner_id as *const [u8; 32]); + let owner_id = Identifier::new(*owner_bytes); + + // Read once, so the contract and the document are read at one version + let platform_version = wrapper.sdk.version(); + let violation = parse_document_properties_json(properties_str).and_then(|properties| { + let contract = deserialize_contract( + serialized_contract, + serialized_contract_len, + platform_version, + )?; + let document_type = document_type_named(&contract, document_type_name)?; + // The id is derived from the entropy, and no rule can read it + let document = build_document_from_properties( + document_type, + properties, + owner_id, + [0u8; 32], + platform_version, + ) + .map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + format!("Failed to build the document from its properties: {}", e), + ) + })?; + property_constraint_violation_json(document_type, &document, platform_version) + }); + json_result(violation) +} + +/// Read the contract a caller holds as its platform serialization, at +/// `platform_version` (the SDK's) and without re-validating it: the way +/// `dash_sdk_add_known_contracts` and the token transitions read one. +/// +/// # Safety +/// - `serialized_contract` must be non-null and point to `serialized_contract_len` readable bytes. +unsafe fn deserialize_contract( + serialized_contract: *const u8, + serialized_contract_len: usize, + platform_version: &PlatformVersion, +) -> Result { + if serialized_contract_len == 0 { + return Err(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "Serialized contract is empty".to_string(), + )); + } + // SAFETY: the caller guarantees `serialized_contract_len` readable bytes + let bytes = std::slice::from_raw_parts(serialized_contract, serialized_contract_len); + DataContract::versioned_deserialize_untrusted(bytes, false, platform_version).map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::SerializationError, + format!("Failed to deserialize contract: {}", e), + ) + }) +} + +/// The document type `contract` declares under `name`. +fn document_type_named<'a>( + contract: &'a DataContract, + name: &str, +) -> Result, DashSDKError> { + contract + .document_type_optional_for_name(name) + .ok_or_else(|| { + DashSDKError::new( + DashSDKErrorCode::NotFound, + format!("Document type '{}' not found in the data contract", name), + ) + }) +} + +/// Every rule of `document_type`'s `propertyConstraints`, in name order, as +/// the JSON array `dash_sdk_data_contract_get_property_constraints` returns. +/// +/// The parsed rules give the name, the reads and whether the owner is read; +/// the rule itself is the schema's declaration, what the contract wrote. +fn property_constraints_json( + document_type: DocumentTypeRef<'_>, +) -> Result { + let declarations = document_type + .schema() + .get_optional_value(PROPERTY_CONSTRAINTS_KEYWORD) + .ok() + .flatten(); + + let constraints = document_type.property_constraints(); + let mut rules = Vec::with_capacity(constraints.len()); + for (name, constraint) in constraints { + let declared = declarations + .and_then(|declarations| declarations.get_optional_value(name).ok().flatten()) + .ok_or_else(|| { + DashSDKError::new( + DashSDKErrorCode::InternalError, + format!( + "The propertyConstraints rule '{}' is missing from the document type's schema", + name + ), + ) + })?; + let rule = serde_json::to_value(declared).map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::SerializationError, + format!( + "Failed to serialize the propertyConstraints rule '{}': {}", + name, e + ), + ) + })?; + let reads: Vec = constraint + .property_reads() + .into_iter() + .map(|(path, read)| json!({ "path": path, "kind": read_kind_name(read) })) + .collect(); + rules.push(json!({ + "name": name, + "rule": rule, + "reads": reads, + "readsOwner": constraint.reads_owner(), + })); + } + Ok(serde_json::Value::Array(rules)) +} + +/// The first rule of `document_type`'s `propertyConstraints` that `document` +/// breaks, judged as consensus judges a create (its properties, and its owner +/// for `$ownerId`), as the JSON `dash_sdk_data_contract_check_property_constraints` +/// returns: JSON `null` when it meets them all. +fn property_constraint_violation_json( + document_type: DocumentTypeRef<'_>, + document: &Document, + platform_version: &PlatformVersion, +) -> Result { + let data = Value::from(document.properties().clone()); + let result = document_type + .validate_property_constraints(&data, Some(document.owner_id()), platform_version) + .map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::ProtocolError, + format!("Failed to check the propertyConstraints rules: {}", e), + ) + })?; + match result.first_error() { + None => Ok(serde_json::Value::Null), + Some(ConsensusError::BasicError(BasicError::DocumentPropertyConstraintViolatedError( + error, + ))) => Ok(json!({ + "rule": error.constraint(), + "violation": violation_name(error.violation()), + "message": error.violation().to_string(), + })), + Some(other) => Err(DashSDKError::new( + DashSDKErrorCode::InternalError, + format!( + "Unexpected error checking the propertyConstraints rules: {}", + other + ), + )), + } +} + +/// The name a read kind goes by in the rule JSON. +fn read_kind_name(read: PropertyRead) -> &'static str { + match read { + PropertyRead::Value => "value", + PropertyRead::Presence => "presence", + PropertyRead::Text => "text", + PropertyRead::Identifier => "identifier", + } +} + +/// The name a violation goes by in the violation JSON, the variant's own. +fn violation_name(violation: PropertyConstraintViolation) -> &'static str { + match violation { + PropertyConstraintViolation::NotMet => "NotMet", + PropertyConstraintViolation::Overflow => "Overflow", + PropertyConstraintViolation::DivisionByZero => "DivisionByZero", + PropertyConstraintViolation::NegativeExponent => "NegativeExponent", + PropertyConstraintViolation::NotAnInteger => "NotAnInteger", + } +} + +/// `value` as a C string result the caller frees with `dash_sdk_string_free`. +fn json_result(value: Result) -> DashSDKResult { + let value = match value { + Ok(value) => value, + Err(error) => return DashSDKResult::error(error), + }; + // JSON escapes every control character, so the text holds no NUL byte + match CString::new(value.to_string()) { + Ok(text) => DashSDKResult::success_string(text.into_raw()), + Err(e) => DashSDKResult::error(FFIError::from(e).into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dash_sdk_error_free; + use crate::test_utils::test_utils::destroy_mock_sdk_handle; + use crate::types::dash_sdk_string_free; + use dash_sdk::dpp::data_contract::DataContractFactory; + use dash_sdk::dpp::platform_value::platform_value; + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + use dash_sdk::dpp::serialization::PlatformSerializableWithPlatformVersion; + use dash_sdk::SdkBuilder; + + const OWNER: [u8; 32] = [1; 32]; + const OTHER: [u8; 32] = [2; 32]; + + /// The rules of the `offer` type, as declared: one of each family (a string + /// constant with `present`, an integer comparison, a division, `$ownerId` + /// with `absent`, and `in`), in a declaration order that is not name order. + fn offer_rules() -> serde_json::Value { + json!({ + "tieredFee": { "in": ["fee", [1, 10, 25]] }, + "discountBelowPrice": { "lessThan": ["discount", "price"] }, + "sellerIsOwner": { + "anyOf": [{ "absent": "sellerId" }, { "equal": ["sellerId", "$ownerId"] }] + }, + "closedNeedsClosedAt": { + "anyOf": [ + { "notEqual": ["status", { "const": "closed" }] }, + { "present": "closedAt" } + ] + }, + "perUnitFee": { "greaterThanOrEqual": [{ "divide": ["price", "fee"] }, 1] } + }) + } + + /// An `offer` type declaring `offer_rules()`, beside a `plain` type + /// declaring none, created at `platform_version`. + fn contract(platform_version: &PlatformVersion) -> DataContract { + let rules = Value::from(offer_rules()); + let documents = platform_value!({ + "offer": { + "type": "object", + "properties": { + "price": { "type": "integer", "minimum": 0, "position": 0 }, + "fee": { "type": "integer", "minimum": 0, "position": 1 }, + "discount": { "type": "integer", "minimum": 0, "position": 2 }, + "status": { + "type": "string", + "enum": ["open", "closed"], + "maxLength": 10, + "position": 3 + }, + "closedAt": { "type": "integer", "minimum": 0, "position": 4 }, + "sellerId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 5 + } + }, + "required": ["price", "fee"], + "additionalProperties": false, + "propertyConstraints": rules + }, + "plain": { + "type": "object", + "properties": { + "message": { "type": "string", "maxLength": 64, "position": 0 } + }, + "additionalProperties": false + } + }); + + let factory = DataContractFactory::new(platform_version.protocol_version) + .expect("factory for the protocol version"); + factory + .create_with_value_config(Identifier::new(OWNER), 1, documents, None, None) + .expect("offer contract") + .data_contract() + .clone() + } + + fn serialized_contract(platform_version: &PlatformVersion) -> Vec { + contract(platform_version) + .serialize_to_bytes_with_platform_version(platform_version) + .expect("serialized contract") + } + + /// A mock SDK handle pinned to `platform_version`. + fn sdk_handle(platform_version: &'static PlatformVersion) -> *mut SDKHandle { + let mut wrapper = SDKWrapper::new_mock(); + wrapper.sdk = SdkBuilder::new_mock() + .with_version(platform_version) + .build() + .expect("mock SDK"); + Box::into_raw(Box::new(wrapper)) as *mut SDKHandle + } + + /// The result's string, or its error code and message; frees both. + fn take(result: DashSDKResult) -> Result { + unsafe { + if !result.error.is_null() { + let error = &*result.error; + let outcome = ( + error.code, + CStr::from_ptr(error.message).to_string_lossy().into_owned(), + ); + dash_sdk_error_free(result.error); + return Err(outcome); + } + let text = result.data as *mut c_char; + let value = + serde_json::from_str(&CStr::from_ptr(text).to_string_lossy()).expect("JSON result"); + dash_sdk_string_free(text); + Ok(value) + } + } + + fn rules_of( + sdk: *mut SDKHandle, + contract: &[u8], + document_type: &str, + ) -> Result { + let document_type = CString::new(document_type).expect("no NUL in the type name"); + take(unsafe { + dash_sdk_data_contract_get_property_constraints( + sdk, + contract.as_ptr(), + contract.len(), + document_type.as_ptr(), + ) + }) + } + + fn check( + sdk: *mut SDKHandle, + contract: &[u8], + document_type: &str, + properties: serde_json::Value, + owner: [u8; 32], + ) -> Result { + check_json(sdk, contract, document_type, &properties.to_string(), owner) + } + + fn check_json( + sdk: *mut SDKHandle, + contract: &[u8], + document_type: &str, + properties_json: &str, + owner: [u8; 32], + ) -> Result { + let document_type = CString::new(document_type).expect("no NUL in the type name"); + let properties_json = CString::new(properties_json).expect("no NUL in the JSON"); + take(unsafe { + dash_sdk_data_contract_check_property_constraints( + sdk, + contract.as_ptr(), + contract.len(), + document_type.as_ptr(), + properties_json.as_ptr(), + owner.as_ptr(), + ) + }) + } + + fn base58(bytes: [u8; 32]) -> String { + Identifier::new(bytes).to_string(Encoding::Base58) + } + + #[test] + fn should_list_every_rule_in_name_order_with_what_it_reads() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + let declared = offer_rules(); + + let rules = rules_of(sdk, &contract, "offer"); + destroy_mock_sdk_handle(sdk); + + assert_eq!( + rules.expect("rules of offer"), + json!([ + { + "name": "closedNeedsClosedAt", + "rule": declared["closedNeedsClosedAt"], + "reads": [ + { "path": "status", "kind": "text" }, + { "path": "closedAt", "kind": "presence" } + ], + "readsOwner": false + }, + { + "name": "discountBelowPrice", + "rule": declared["discountBelowPrice"], + "reads": [ + { "path": "discount", "kind": "value" }, + { "path": "price", "kind": "value" } + ], + "readsOwner": false + }, + { + "name": "perUnitFee", + "rule": declared["perUnitFee"], + "reads": [ + { "path": "price", "kind": "value" }, + { "path": "fee", "kind": "value" } + ], + "readsOwner": false + }, + { + "name": "sellerIsOwner", + "rule": declared["sellerIsOwner"], + "reads": [ + { "path": "sellerId", "kind": "presence" }, + { "path": "sellerId", "kind": "identifier" } + ], + "readsOwner": true + }, + { + "name": "tieredFee", + "rule": declared["tieredFee"], + "reads": [{ "path": "fee", "kind": "value" }], + "readsOwner": false + } + ]) + ); + } + + #[test] + fn should_list_no_rules_for_a_document_type_declaring_none() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + + let rules = rules_of(sdk, &contract, "plain"); + destroy_mock_sdk_handle(sdk); + + assert_eq!(rules.expect("rules of plain"), json!([])); + } + + #[test] + fn should_refuse_a_document_type_the_contract_does_not_declare() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + + let rules = rules_of(sdk, &contract, "letter"); + let violation = check(sdk, &contract, "letter", json!({}), OWNER); + destroy_mock_sdk_handle(sdk); + + for outcome in [rules, violation] { + let (code, message) = outcome.expect_err("an unknown type is refused"); + assert_eq!(code, DashSDKErrorCode::NotFound); + assert!(message.contains("'letter' not found"), "{message}"); + } + } + + #[test] + fn should_report_nothing_for_a_document_meeting_every_rule() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + + let bare = check( + sdk, + &contract, + "offer", + json!({ "price": 100, "fee": 10 }), + OWNER, + ); + // The seller arrives as base58 text and is typed an identifier, as the + // create path types it, so it equals the owner + let full = check( + sdk, + &contract, + "offer", + json!({ + "price": 100, + "fee": 10, + "discount": 5, + "status": "closed", + "closedAt": 1000, + "sellerId": base58(OWNER) + }), + OWNER, + ); + let plain = check(sdk, &contract, "plain", json!({ "message": "hi" }), OWNER); + destroy_mock_sdk_handle(sdk); + + assert_eq!(bare.expect("checked"), serde_json::Value::Null); + assert_eq!(full.expect("checked"), serde_json::Value::Null); + assert_eq!(plain.expect("checked"), serde_json::Value::Null); + } + + #[test] + fn should_report_the_first_rule_broken_in_name_order() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + let violation_of = |properties: serde_json::Value| { + let mut document = json!({ "price": 100, "fee": 10 }); + for (key, value) in properties.as_object().expect("an object") { + document[key] = value.clone(); + } + check(sdk, &contract, "offer", document, OWNER).expect("checked") + }; + + // A string constant: a closed offer must say when it closed + let closed = violation_of(json!({ "status": "closed" })); + // An integer comparison + let discounted = violation_of(json!({ "discount": 200 })); + // A fee of 0 divides by zero before the `in` rule is reached + let free = violation_of(json!({ "fee": 0 })); + // `in` + let untiered = violation_of(json!({ "fee": 5 })); + // Two broken rules: the first in name order is reported + let both = violation_of(json!({ "discount": 200, "fee": 5 })); + destroy_mock_sdk_handle(sdk); + + assert_eq!( + closed, + json!({ + "rule": "closedNeedsClosedAt", + "violation": "NotMet", + "message": PropertyConstraintViolation::NotMet.to_string() + }) + ); + assert_eq!(discounted["rule"], "discountBelowPrice"); + assert_eq!(discounted["violation"], "NotMet"); + assert_eq!( + free, + json!({ + "rule": "perUnitFee", + "violation": "DivisionByZero", + "message": PropertyConstraintViolation::DivisionByZero.to_string() + }) + ); + assert_eq!(untiered["rule"], "tieredFee"); + assert_eq!(untiered["violation"], "NotMet"); + assert_eq!(both["rule"], "discountBelowPrice"); + } + + #[test] + fn should_read_the_owner_for_owner_id() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + let offer = json!({ "price": 100, "fee": 10, "sellerId": base58(OTHER) }); + + let owned_by_someone_else = check(sdk, &contract, "offer", offer.clone(), OWNER); + let owned_by_the_seller = check(sdk, &contract, "offer", offer, OTHER); + destroy_mock_sdk_handle(sdk); + + let violation = owned_by_someone_else.expect("checked"); + assert_eq!(violation["rule"], "sellerIsOwner"); + assert_eq!(violation["violation"], "NotMet"); + assert_eq!( + owned_by_the_seller.expect("checked"), + serde_json::Value::Null + ); + } + + /// Parsers before protocol version 14 ignore the keyword, so an SDK at such + /// a version reports no rule and no violation: exactly what consensus + /// enforced there. The bytes are those a network at 14 returns; the + /// contract cannot be created at 13, whose meta-schema refuses the keyword + /// when JSON schema validation is compiled in. + #[test] + fn should_report_no_rules_below_protocol_version_14() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(PlatformVersion::latest()); + + let rules = rules_of(sdk, &contract, "offer"); + let violation = check( + sdk, + &contract, + "offer", + json!({ "price": 100, "fee": 0, "discount": 200 }), + OWNER, + ); + destroy_mock_sdk_handle(sdk); + + assert_eq!(rules.expect("rules of offer"), json!([])); + assert_eq!(violation.expect("checked"), serde_json::Value::Null); + } + + #[test] + fn should_refuse_what_is_not_a_contract_or_a_properties_object() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + + let not_a_contract = rules_of(sdk, &[0xff, 0x00, 0x13], "offer"); + let not_json = check_json(sdk, &contract, "offer", "{price:", OWNER); + let not_an_object = check_json(sdk, &contract, "offer", "[1, 2]", OWNER); + destroy_mock_sdk_handle(sdk); + + assert_eq!( + not_a_contract.expect_err("refused").0, + DashSDKErrorCode::SerializationError + ); + let (code, message) = not_json.expect_err("refused"); + assert_eq!(code, DashSDKErrorCode::InvalidParameter); + assert!(message.contains("Invalid properties JSON"), "{message}"); + let (code, message) = not_an_object.expect_err("refused"); + assert_eq!(code, DashSDKErrorCode::InvalidParameter); + assert!( + message.contains("Failed to convert properties"), + "{message}" + ); + } + + #[test] + fn should_refuse_null_or_empty_arguments() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + let offer = CString::new("offer").expect("no NUL"); + let properties = CString::new("{}").expect("no NUL"); + + let outcomes = unsafe { + [ + take(dash_sdk_data_contract_get_property_constraints( + std::ptr::null(), + contract.as_ptr(), + contract.len(), + offer.as_ptr(), + )), + take(dash_sdk_data_contract_get_property_constraints( + sdk, + contract.as_ptr(), + 0, + offer.as_ptr(), + )), + take(dash_sdk_data_contract_check_property_constraints( + sdk, + contract.as_ptr(), + contract.len(), + offer.as_ptr(), + properties.as_ptr(), + std::ptr::null(), + )), + take(dash_sdk_data_contract_check_property_constraints( + sdk, + std::ptr::null(), + contract.len(), + offer.as_ptr(), + properties.as_ptr(), + OWNER.as_ptr(), + )), + ] + }; + destroy_mock_sdk_handle(sdk); + + for outcome in outcomes { + assert_eq!( + outcome.expect_err("refused").0, + DashSDKErrorCode::InvalidParameter + ); + } + } +} diff --git a/packages/rs-sdk-ffi/src/document/create.rs b/packages/rs-sdk-ffi/src/document/create.rs index 6aa744398d6..5d23d28815c 100644 --- a/packages/rs-sdk-ffi/src/document/create.rs +++ b/packages/rs-sdk-ffi/src/document/create.rs @@ -1,5 +1,6 @@ //! Document creation operations +use crate::document::helpers::{build_document_from_properties, parse_document_properties_json}; use crate::sdk::SDKWrapper; use crate::types::{DashSDKResultDataType, DocumentHandle, SDKHandle}; use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; @@ -110,25 +111,9 @@ pub unsafe extern "C" fn dash_sdk_document_create( }; // Parse properties JSON - let properties_value: serde_json::Value = match serde_json::from_str(properties_str) { - Ok(v) => v, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid properties JSON: {}", e), - )) - } - }; - - // Convert JSON to platform Value - handle hex strings for byte arrays - let mut properties = match serde_json::from_value::>(properties_value) { - Ok(map) => map, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Failed to convert properties: {}", e), - )) - } + let properties = match parse_document_properties_json(properties_str) { + Ok(properties) => properties, + Err(error) => return DashSDKResult::error(error), }; let result: Result<(Document, [u8; 32]), FFIError> = wrapper.runtime.block_on(async { @@ -173,21 +158,15 @@ pub unsafe extern "C" fn dash_sdk_document_create( .map_err(|e| FFIError::InternalError(format!("Failed to get document type: {}", e)))?; // Sanitize document properties (convert hex/base64 to bytes, base58 to identifiers, etc.) - use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; - document_type_ref.sanitize_document_properties(&mut properties); - eprintln!("📝 [DOCUMENT CREATE] Sanitized document properties"); - - // Create document with entropy - this will generate the document ID internally - let document = document_type_ref - .create_document_from_data( - properties.into(), - owner_id, - 0, // block_height - will be set by platform - 0, // core_block_height - will be set by platform - entropy, - platform_version, - ) - .map_err(|e| FFIError::InternalError(format!("Failed to create document: {}", e)))?; + // and create the document with entropy - this will generate the document ID internally + let document = build_document_from_properties( + document_type_ref.as_ref(), + properties, + owner_id, + entropy, + platform_version, + ) + .map_err(|e| FFIError::InternalError(format!("Failed to create document: {}", e)))?; Ok((document, entropy)) }); diff --git a/packages/rs-sdk-ffi/src/document/helpers.rs b/packages/rs-sdk-ffi/src/document/helpers.rs index afdc0239f1b..491da439c49 100644 --- a/packages/rs-sdk-ffi/src/document/helpers.rs +++ b/packages/rs-sdk-ffi/src/document/helpers.rs @@ -1,16 +1,76 @@ //! Helper functions for document operations +use std::collections::BTreeMap; + +use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; +use dash_sdk::dpp::document::Document; +use dash_sdk::dpp::platform_value::Value; use dash_sdk::dpp::prelude::Identifier; use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use dash_sdk::dpp::state_transition::StateTransitionSigningOptions; use dash_sdk::dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; use dash_sdk::dpp::tokens::token_payment_info::v0::TokenPaymentInfoV0; use dash_sdk::dpp::tokens::token_payment_info::TokenPaymentInfo; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::dpp::ProtocolError; use crate::types::{ DashSDKGasFeesPaidBy, DashSDKStateTransitionCreationOptions, DashSDKTokenPaymentInfo, }; -use crate::FFIError; +use crate::{DashSDKError, DashSDKErrorCode, FFIError}; + +/// Parse the properties JSON of a document to create, as `dash_sdk_document_create` +/// takes it: a JSON object keyed by property name, each value read into a platform +/// `Value` as JSON writes it (integers, booleans, strings, arrays and objects). Byte +/// arrays and identifiers are still the strings the caller wrote here; +/// [`build_document_from_properties`] decodes them against the document type. +/// +/// Errors carry `InvalidParameter`: the text is not JSON, or not an object. +pub(crate) fn parse_document_properties_json( + properties_json: &str, +) -> Result, DashSDKError> { + let properties_value: serde_json::Value = + serde_json::from_str(properties_json).map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + format!("Invalid properties JSON: {}", e), + ) + })?; + + // Convert JSON to platform Value - handle hex strings for byte arrays + serde_json::from_value::>(properties_value).map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + format!("Failed to convert properties: {}", e), + ) + }) +} + +/// Build the document `dash_sdk_document_create` creates from `properties`: they are +/// sanitized against `document_type` (hex or base64 strings become byte arrays, base58 +/// or hex strings identifiers, integers narrow to their declared width, typed array +/// elements included), then `DocumentType::create_document_from_data` builds the +/// revision-1 document owned by `owner_id`, its id derived from `entropy`, typing the +/// value at each of the document type's identifier paths as an identifier. Block +/// heights are left at 0 for Platform to set. +pub(crate) fn build_document_from_properties( + document_type: DocumentTypeRef<'_>, + mut properties: BTreeMap, + owner_id: Identifier, + entropy: [u8; 32], + platform_version: &PlatformVersion, +) -> Result { + document_type.sanitize_document_properties(&mut properties); + document_type.create_document_from_data( + properties.into(), + owner_id, + 0, // block_height - will be set by platform + 0, // core_block_height - will be set by platform + entropy, + platform_version, + ) +} /// Convert FFI GasFeesPaidBy to Rust enum /// diff --git a/packages/rs-sdk-ffi/src/document/mod.rs b/packages/rs-sdk-ffi/src/document/mod.rs index 1847dbfd98c..95c168486e1 100644 --- a/packages/rs-sdk-ffi/src/document/mod.rs +++ b/packages/rs-sdk-ffi/src/document/mod.rs @@ -35,6 +35,7 @@ pub use transfer::{ pub use util::{dash_sdk_document_destroy, dash_sdk_document_handle_destroy}; // Re-export helper functions for use by submodules +pub(crate) use helpers::{build_document_from_properties, parse_document_properties_json}; pub use helpers::{ convert_gas_fees_paid_by, convert_state_transition_creation_options, convert_token_payment_info, }; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift new file mode 100644 index 00000000000..72ccab1a759 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift @@ -0,0 +1,345 @@ +import DashSDKFFI +import Foundation + +/// A rule of a document type's `propertyConstraints` (meta-schema v3, protocol +/// version 14): a named condition every created or replaced document's +/// properties must meet. Consensus checks every rule, in name order, and +/// refuses a document breaking one with `DocumentPropertyConstraintViolatedError` +/// (code 10422); a refused state transition is still paid for. +/// +/// Rust parses the rules and reports them +/// (`dash_sdk_data_contract_get_property_constraints`); this type only carries +/// what it reports. The fields mirror wasm-dpp2's `DocumentPropertyConstraint` +/// key for key. +public struct DocumentPropertyConstraint: Equatable, Sendable { + /// The rule's name, its key in `propertyConstraints`. + public let name: String + + /// The rule exactly as the document type's schema declares it, as compact + /// JSON text with sorted keys (every operator object has a single key, so + /// sorting changes nothing a reader would notice). + public let ruleJSON: String + + /// Every property the rule reads, in declared order, a property read twice + /// listed twice. `$ownerId` is no property and is not listed: see + /// `readsOwner`. + public let reads: [PropertyConstraintRead] + + /// Whether the rule compares the document's owner, `$ownerId`: then a + /// transfer or a purchase, which changes the owner, is judged against it + /// too. + public let readsOwner: Bool + + public init(name: String, ruleJSON: String, reads: [PropertyConstraintRead], readsOwner: Bool) { + self.name = name + self.ruleJSON = ruleJSON + self.reads = reads + self.readsOwner = readsOwner + } + + /// `ruleJSON` indented for display, or `ruleJSON` itself should it not + /// parse back. + public var prettyRuleJSON: String { + guard let data = ruleJSON.data(using: .utf8), + let rule = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]), + let pretty = try? JSONSerialization.data( + withJSONObject: rule, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes, .fragmentsAllowed]), + let text = String(data: pretty, encoding: .utf8) + else { + return ruleJSON + } + return text + } + + /// Decode the JSON array `dash_sdk_data_contract_get_property_constraints` + /// returns, keeping its order (name order). + public static func list(fromJSON json: String) throws -> [DocumentPropertyConstraint] { + guard let entries = try PropertyConstraintJSON.object(from: json) as? [Any] else { + throw SDKError.serializationError("propertyConstraints rules are not a JSON array") + } + return try entries.map { entry in + guard let rule = entry as? [String: Any], + let name = rule["name"] as? String, + let declaration = rule["rule"], + let reads = rule["reads"] as? [Any], + let readsOwner = DocumentTypedArray.jsonBool(rule["readsOwner"]) + else { + throw SDKError.serializationError("Malformed propertyConstraints rule: \(entry)") + } + return DocumentPropertyConstraint( + name: name, + ruleJSON: try PropertyConstraintJSON.compactText(declaration), + reads: try reads.map(PropertyConstraintRead.init(jsonEntry:)), + readsOwner: readsOwner + ) + } + } +} + +/// A property a `propertyConstraints` rule reads, and how it reads it. +public struct PropertyConstraintRead: Hashable, Sendable { + /// How a rule reads a property; the names are wasm-dpp2's + /// `PropertyConstraintReadKind`. + public enum Kind: Hashable, Sendable { + /// By its value, as an integer operand: an integer or boolean property. + case value + /// Only whether the document holds it, in `present` or `absent`. + case presence + /// By its value, compared with strings: a string property. + case text + /// By its value, compared with identifiers: an identifier property. + case identifier + /// A kind this build does not know, by its name. + case other(String) + + public init(name: String) { + switch name { + case "value": self = .value + case "presence": self = .presence + case "text": self = .text + case "identifier": self = .identifier + default: self = .other(name) + } + } + + /// The kind's name, as Rust reports it. + public var name: String { + switch self { + case .value: return "value" + case .presence: return "presence" + case .text: return "text" + case .identifier: return "identifier" + case let .other(name): return name + } + } + } + + /// The property's dotted path. + public let path: String + public let kind: Kind + + public init(path: String, kind: Kind) { + self.path = path + self.kind = kind + } + + init(jsonEntry entry: Any) throws { + guard let read = entry as? [String: Any], + let path = read["path"] as? String, + let kind = read["kind"] as? String + else { + throw SDKError.serializationError("Malformed propertyConstraints read: \(entry)") + } + self.init(path: path, kind: Kind(name: kind)) + } +} + +/// The first `propertyConstraints` rule a document breaks, as consensus would +/// report it in `DocumentPropertyConstraintViolatedError` (code 10422). +/// +/// Rust judges the document (`dash_sdk_data_contract_check_property_constraints`) +/// with the check consensus runs; this type only carries the verdict. The +/// fields mirror wasm-dpp2's `DocumentPropertyConstraintViolation`. +public struct PropertyConstraintViolation: Error, Equatable, Sendable, LocalizedError { + /// Why the rule is broken; the names are wasm-dpp2's + /// `PropertyConstraintViolationKind`. + public enum Kind: Hashable, Sendable { + /// The rule evaluates without a fault but does not hold. + case notMet + /// A value the rule reads or computes does not fit a 128-bit signed + /// integer. + case overflow + /// A `divide` or `modulo` by zero. + case divisionByZero + /// A `power` with a negative exponent. + case negativeExponent + /// A value the rule reads is not an integer. + case notAnInteger + /// A reason this build does not know, by its name. + case other(String) + + public init(name: String) { + switch name { + case "NotMet": self = .notMet + case "Overflow": self = .overflow + case "DivisionByZero": self = .divisionByZero + case "NegativeExponent": self = .negativeExponent + case "NotAnInteger": self = .notAnInteger + default: self = .other(name) + } + } + + /// The reason's name, as Rust reports it. + public var name: String { + switch self { + case .notMet: return "NotMet" + case .overflow: return "Overflow" + case .divisionByZero: return "DivisionByZero" + case .negativeExponent: return "NegativeExponent" + case .notAnInteger: return "NotAnInteger" + case let .other(name): return name + } + } + } + + /// The broken rule's name. + public let rule: String + public let violation: Kind + /// A readable reason, as in the consensus error's message. + public let message: String + + public init(rule: String, violation: Kind, message: String) { + self.rule = rule + self.violation = violation + self.message = message + } + + public var errorDescription: String? { + "The document breaks the propertyConstraints rule \"\(rule)\" (\(violation.name)): \(message)." + } + + /// Decode the JSON `dash_sdk_data_contract_check_property_constraints` + /// returns: `nil` for JSON `null`, when the document meets every rule. + public static func decode(fromJSON json: String) throws -> PropertyConstraintViolation? { + let object = try PropertyConstraintJSON.object(from: json) + if object is NSNull { + return nil + } + guard let violation = object as? [String: Any], + let rule = violation["rule"] as? String, + let kind = violation["violation"] as? String, + let message = violation["message"] as? String + else { + throw SDKError.serializationError("Malformed propertyConstraints violation: \(json)") + } + return PropertyConstraintViolation(rule: rule, violation: Kind(name: kind), message: message) + } +} + +// MARK: - FFI + +extension SDK { + /// The `propertyConstraints` rules of `documentType`, in name order (the + /// order consensus checks them in). Empty for a type declaring none, and for + /// every type while this SDK's protocol version is below 14. + /// + /// `serializedContract` is the contract's platform serialization, the bytes + /// kept beside a fetched contract (`PersistentDataContract.binarySerialization`). + /// Rust reads it at this SDK's protocol version. Bridges + /// `dash_sdk_data_contract_get_property_constraints`. + /// + /// - Throws: `SDKError.notFound` for a document type the contract does not + /// declare, `SDKError.serializationError` for bytes that are not a + /// contract. + public func documentPropertyConstraints( + serializedContract: Data, + documentType: String + ) throws -> [DocumentPropertyConstraint] { + guard let handle else { + throw SDKError.invalidState("SDK not initialized") + } + let result = serializedContract.withUnsafeBytes { contract in + documentType.withCString { documentType in + dash_sdk_data_contract_get_property_constraints( + handle, + contract.bindMemory(to: UInt8.self).baseAddress, + UInt(contract.count), + documentType + ) + } + } + return try DocumentPropertyConstraint.list(fromJSON: PropertyConstraintJSON.text(of: result)) + } + + /// The first `propertyConstraints` rule a document to create would break, + /// or `nil` when it meets them all (always so while this SDK's protocol + /// version is below 14). + /// + /// `propertiesJSON` is the properties JSON the document would be created + /// with (the string handed to `ManagedPlatformWallet.createDocument`) and + /// `ownerId` the 32-byte identity that would own it, which `$ownerId` + /// reads. Rust builds the document the create path builds and judges it + /// with the check consensus runs; nothing but the rules is checked. + /// `serializedContract` is as for `documentPropertyConstraints`. Bridges + /// `dash_sdk_data_contract_check_property_constraints`. + /// + /// - Throws: `SDKError.invalidParameter` for an owner id that is not 32 + /// bytes or properties that are not a JSON object, `SDKError.notFound` for + /// a document type the contract does not declare, + /// `SDKError.serializationError` for bytes that are not a contract. + public func checkDocumentPropertyConstraints( + serializedContract: Data, + documentType: String, + propertiesJSON: String, + ownerId: Identifier + ) throws -> PropertyConstraintViolation? { + guard let handle else { + throw SDKError.invalidState("SDK not initialized") + } + // The FFI reads exactly 32 bytes behind the pointer + guard ownerId.count == 32 else { + throw SDKError.invalidParameter("Owner ID must be 32 bytes, got \(ownerId.count)") + } + let result = serializedContract.withUnsafeBytes { contract in + ownerId.withUnsafeBytes { owner in + documentType.withCString { documentType in + propertiesJSON.withCString { propertiesJSON in + dash_sdk_data_contract_check_property_constraints( + handle, + contract.bindMemory(to: UInt8.self).baseAddress, + UInt(contract.count), + documentType, + propertiesJSON, + owner.bindMemory(to: UInt8.self).baseAddress + ) + } + } + } + } + return try PropertyConstraintViolation.decode(fromJSON: PropertyConstraintJSON.text(of: result)) + } +} + +// MARK: - JSON readers + +enum PropertyConstraintJSON { + /// The C string a `DashSDKResult` carries, freeing it, or the error it + /// carries as an `SDKError` (keeping its code), freeing that. + static func text(of result: DashSDKResult) throws -> String { + if let error = result.error { + let sdkError = SDKError.fromDashSDKError(error.pointee) + dash_sdk_error_free(error) + throw sdkError + } + guard let data = result.data else { + throw SDKError.internalError("No data returned") + } + let text = String(cString: data.assumingMemoryBound(to: CChar.self)) + dash_sdk_string_free(data.assumingMemoryBound(to: CChar.self)) + return text + } + + /// The JSON value `text` holds, `NSNull` for `null`. + static func object(from text: String) throws -> Any { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + else { + throw SDKError.serializationError("Not JSON: \(text)") + } + return object + } + + /// `value` as compact JSON text with sorted keys. + static func compactText(_ value: Any) throws -> String { + guard JSONSerialization.isValidJSONObject([value]), + let data = try? JSONSerialization.data( + withJSONObject: value, + options: [.sortedKeys, .withoutEscapingSlashes, .fragmentsAllowed]), + let text = String(data: data, encoding: .utf8) + else { + throw SDKError.serializationError("Not a JSON value: \(value)") + } + return text + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift index 2761654c802..3b79011b461 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift @@ -152,6 +152,60 @@ extension PersistentDocumentType { DocumentTypedArray.named(name, inDocumentTypeSchema: schema) } + /// Whether the persisted schema carries the `propertyConstraints` keyword + /// (protocol version 14). Says nothing about the rules themselves: those + /// are read by `propertyConstraints(using:)`, which parses them in Rust. + public var declaresPropertyConstraints: Bool { + schema?["propertyConstraints"] != nil + } + + /// The type's `propertyConstraints` rules, in name order: what + /// `SDK.documentPropertyConstraints(serializedContract:documentType:)` + /// reads from the parent contract's stored platform serialization + /// (`PersistentDataContract.binarySerialization`) at `sdk`'s protocol + /// version. Nothing is stored for them: like `immutability`, they are + /// derived on demand, so the model's entity hash does not move. + /// + /// - Throws: `SDKError.invalidState` when the parent contract has no + /// stored serialization, or what the SDK call throws. + public func propertyConstraints(using sdk: SDK) throws -> [DocumentPropertyConstraint] { + try sdk.documentPropertyConstraints( + serializedContract: storedContractSerialization(), + documentType: name + ) + } + + /// The first `propertyConstraints` rule a document of this type, created + /// with `propertiesJSON` and owned by `ownerId`, would break, or `nil` + /// when it meets them all: what + /// `SDK.checkDocumentPropertyConstraints(serializedContract:documentType:propertiesJSON:ownerId:)` + /// reports for the parent contract's stored platform serialization. + /// + /// - Throws: `SDKError.invalidState` when the parent contract has no + /// stored serialization, or what the SDK call throws. + public func propertyConstraintViolation( + propertiesJSON: String, + ownerId: Identifier, + using sdk: SDK + ) throws -> PropertyConstraintViolation? { + try sdk.checkDocumentPropertyConstraints( + serializedContract: storedContractSerialization(), + documentType: name, + propertiesJSON: propertiesJSON, + ownerId: ownerId + ) + } + + /// The parent contract's stored platform serialization. + private func storedContractSerialization() throws -> Data { + guard let serialization = dataContract?.binarySerialization, !serialization.isEmpty else { + throw SDKError.invalidState( + "The data contract \(contractIdBase58) has no stored serialization; download it again" + ) + } + return serialization + } + public var documentCount: Int { documents?.count ?? 0 } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift index 731d741e047..7c6f83000e6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift @@ -9,17 +9,27 @@ struct DocumentTypeDetailsView: View { @Environment(\.dismiss) var dismiss @State private var expandedIndices: Set = [] @State private var showingCreateDocument = false + /// The type's `propertyConstraints` rules as Rust reads them; `nil` until + /// loaded. + @State private var propertyConstraints: [DocumentPropertyConstraint]? + @State private var propertyConstraintsError: String? var body: some View { List { newDocumentSection documentInfoSection documentSettingsSection + propertyConstraintsSection documentIndexesSection documentPropertiesSection } .navigationTitle(documentType.name) .navigationBarTitleDisplayMode(.inline) + // Re-read when the SDK learns the network's protocol version: the + // rules are parsed at that version, and none exist below 14. + .task(id: appState.platformProtocolVersion) { + loadPropertyConstraints() + } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { @@ -181,6 +191,57 @@ struct DocumentTypeDetailsView: View { } } + /// Protocol version 14: named rules every created or replaced document + /// must meet, checked in name order. Rust parses them from the stored + /// contract; this section only shows what it reports. + @ViewBuilder + private var propertyConstraintsSection: some View { + if let error = propertyConstraintsError { + Section("Property Constraints") { + Label(error, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundColor(.orange) + } + } else if let rules = propertyConstraints, !rules.isEmpty { + Section { + ForEach(rules, id: \.name) { rule in + PropertyConstraintRowView(rule: rule) + } + } header: { + Text("Property Constraints (\(rules.count))") + } footer: { + Text("Every created or replaced document must meet each rule, checked in name order. A document breaking one is refused (error 10422) and the fee is still charged.") + } + } else if propertyConstraints != nil, documentType.declaresPropertyConstraints { + Section("Property Constraints") { + Text("The schema declares propertyConstraints, but the network's protocol version does not enforce them (they take effect at protocol version 14).") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + private func loadPropertyConstraints() { + // A schema without the keyword has no rules to read + guard documentType.declaresPropertyConstraints else { + propertyConstraints = [] + propertyConstraintsError = nil + return + } + guard let sdk = appState.sdk else { + propertyConstraints = nil + propertyConstraintsError = "Connect to a network to read the property constraints." + return + } + do { + propertyConstraints = try documentType.propertyConstraints(using: sdk) + propertyConstraintsError = nil + } catch { + propertyConstraints = nil + propertyConstraintsError = "Could not read the property constraints: \(error.localizedDescription)" + } + } + @ViewBuilder private var documentIndexesSection: some View { if let indices = documentType.indices, !indices.isEmpty { @@ -389,6 +450,64 @@ struct ExpandableIndexRowView: View { } } +/// One `propertyConstraints` rule: its name, the rule as declared, what it +/// reads, and whether an owner change is judged against it too. +struct PropertyConstraintRowView: View { + let rule: DocumentPropertyConstraint + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(rule.name) + .font(.headline) + Spacer() + if rule.readsOwner { + Text("$ownerId") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.purple.opacity(0.2)) + .foregroundColor(.purple) + .cornerRadius(4) + } + } + + ScrollView(.horizontal, showsIndicators: false) { + Text(rule.prettyRuleJSON) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .fixedSize(horizontal: true, vertical: true) + } + + if !readsText.isEmpty { + Text("Reads: \(readsText)") + .font(.caption) + .foregroundColor(.secondary) + } + + if rule.readsOwner { + Label( + "Reads $ownerId, the document's owner: transfers and purchases are judged against this rule too.", + systemImage: "person.crop.circle.badge.checkmark" + ) + .font(.caption2) + .foregroundColor(.purple) + } + } + .padding(.vertical, 4) + .accessibilityIdentifier("documentType.propertyConstraint.\(rule.name)") + } + + /// Each property the rule reads with how it reads it, repeats dropped. + private var readsText: String { + var seen = Set() + return rule.reads + .filter { seen.insert($0).inserted } + .map { "\($0.path) (\($0.kind.name))" } + .joined(separator: ", ") + } +} + struct PropertyRowView: View { let propertyName: String let propertyData: Any diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift index c157d226459..478cc8b8209 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift @@ -1498,6 +1498,7 @@ struct CreateDocumentView: View { private struct SubmitError: Identifiable { let id = UUID() + var title = "Create failed" let message: String } @@ -1532,7 +1533,7 @@ struct CreateDocumentView: View { .interactiveDismissDisabled(isSubmitting) .alert(item: $submitError) { err in Alert( - title: Text("Create failed"), + title: Text(err.title), message: Text(err.message), dismissButton: .default(Text("OK")) ) @@ -1764,6 +1765,21 @@ struct CreateDocumentView: View { return } + // Protocol version 14: consensus refuses a document breaking one of + // its type's propertyConstraints rules (error 10422) and still charges + // for the transition, so judge it first with the same Rust check. + if let violation = propertyConstraintViolation( + of: docType, + propertiesJSON: propertiesJSON, + ownerId: ownerIdentity.identityId + ) { + submitError = .init( + title: "Not sent: a property constraint is broken", + message: "Rule: \(violation.rule)\nViolation: \(violation.violation.name)\nReason: \(violation.message)" + ) + return + } + isSubmitting = true // Fresh `KeychainSigner` per submit pass, same as // `TransferCreditsView` / `RegisterNameView`: the trampoline @@ -1817,6 +1833,28 @@ struct CreateDocumentView: View { } } + /// The first propertyConstraints rule the document would break, or `nil` + /// when it meets them all or has none. A check that cannot run (no SDK, + /// no stored contract serialization) blocks nothing: consensus judges the + /// document either way. + private func propertyConstraintViolation( + of docType: PersistentDocumentType, + propertiesJSON: String, + ownerId: Identifier + ) -> PropertyConstraintViolation? { + guard docType.declaresPropertyConstraints, let sdk = appState.sdk else { return nil } + do { + return try docType.propertyConstraintViolation( + propertiesJSON: propertiesJSON, + ownerId: ownerId, + using: sdk + ) + } catch { + print("⚠️ propertyConstraints pre-check could not run: \(error.localizedDescription)") + return nil + } + } + /// Persist the confirmed document so it shows up in the Documents /// list (DOC-01). Persistence stays in Swift per /// `swift-sdk/CLAUDE.md`; the broadcast itself happened in Rust. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift new file mode 100644 index 00000000000..ee29cda741c --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift @@ -0,0 +1,381 @@ +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +/// Coverage for the protocol-version-14 `propertyConstraints` bridge: the +/// decoding of what `dash_sdk_data_contract_get_property_constraints` and +/// `dash_sdk_data_contract_check_property_constraints` return, and the +/// wrappers' round trip through the FFI. +/// +/// Rust parses and evaluates the rules (rs-sdk-ffi's own tests cover every +/// rule family and violation); the Swift side only marshals, so these tests pin +/// the JSON shapes, which match wasm-dpp2's key for key, and the marshalling. +/// The round trips run on the FFI mock SDK, which sits at a protocol version +/// below 14, where no document type carries a rule. +@MainActor +final class DocumentPropertyConstraintsTests: XCTestCase { + + /// The rules of an `offer` type as the FFI reports them (rs-sdk-ffi's + /// `should_list_every_rule_in_name_order_with_what_it_reads`), abridged to + /// three rules. + private let rulesJSON = """ + [ + { + "name": "closedNeedsClosedAt", + "readsOwner": false, + "reads": [ + { "kind": "text", "path": "status" }, + { "kind": "presence", "path": "closedAt" } + ], + "rule": { + "anyOf": [ + { "notEqual": ["status", { "const": "closed" }] }, + { "present": "closedAt" } + ] + } + }, + { + "name": "perUnitFee", + "readsOwner": false, + "reads": [ + { "kind": "value", "path": "price" }, + { "kind": "value", "path": "fee" } + ], + "rule": { "greaterThanOrEqual": [{ "divide": ["price", "fee"] }, 1] } + }, + { + "name": "sellerIsOwner", + "readsOwner": true, + "reads": [ + { "kind": "presence", "path": "sellerId" }, + { "kind": "identifier", "path": "sellerId" } + ], + "rule": { + "anyOf": [{ "absent": "sellerId" }, { "equal": ["sellerId", "$ownerId"] }] + } + } + ] + """ + + /// The platform serialization of a contract created at protocol version + /// 13, owned by `[7; 32]`, declaring one `note` type with a string + /// `message` and no rules (generated by rs-sdk-ffi's + /// `serialize_to_bytes_with_platform_version`). It reads at every later + /// protocol version too. + private let noteContractHex = + "013ac40651a4bcb91c3f5c1e458a1c95e48dc2c8259003dd580cab11a5f02ed4850100000000010100000101070707070707" + + "07070707070707070707070707070707070707070707070707070001046e6f7465160312047479706512066f626a65637412" + + "0a70726f70657274696573160112076d65737361676516031204747970651206737472696e6712096d61784c656e67746805" + + "801208706f736974696f6e050012146164646974696f6e616c50726f70657274696573130000000000000000000000" + + private let ownerId = Data(repeating: 7, count: 32) + + // MARK: - Rules + + func testRulesDecodeInOrderWithWhatTheyRead() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: rulesJSON) + + XCTAssertEqual(rules.map(\.name), ["closedNeedsClosedAt", "perUnitFee", "sellerIsOwner"]) + XCTAssertEqual( + rules[0].reads, + [ + PropertyConstraintRead(path: "status", kind: .text), + PropertyConstraintRead(path: "closedAt", kind: .presence) + ] + ) + XCTAssertEqual(rules[1].reads.map(\.kind), [.value, .value]) + XCTAssertEqual(rules[2].reads.map(\.kind), [.presence, .identifier]) + XCTAssertEqual(rules.map(\.readsOwner), [false, false, true]) + } + + /// The rule is kept as the JSON the schema declares, compact with sorted + /// keys: array order, and so operand order, is untouched. + func testRuleIsKeptAsDeclaredJSON() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: rulesJSON) + + XCTAssertEqual( + rules[0].ruleJSON, + #"{"anyOf":[{"notEqual":["status",{"const":"closed"}]},{"present":"closedAt"}]}"# + ) + XCTAssertEqual(rules[1].ruleJSON, #"{"greaterThanOrEqual":[{"divide":["price","fee"]},1]}"#) + XCTAssertEqual( + rules[2].ruleJSON, + #"{"anyOf":[{"absent":"sellerId"},{"equal":["sellerId","$ownerId"]}]}"# + ) + } + + func testPrettyRuleJSONIndentsTheSameRule() throws { + let rule = try XCTUnwrap(DocumentPropertyConstraint.list(fromJSON: rulesJSON).first) + + let pretty = rule.prettyRuleJSON + XCTAssertTrue(pretty.contains("\n"), pretty) + let reparsed = try JSONSerialization.jsonObject(with: Data(pretty.utf8)) as? NSDictionary + let original = try JSONSerialization.jsonObject(with: Data(rule.ruleJSON.utf8)) as? NSDictionary + XCTAssertEqual(reparsed, original) + } + + func testNoRulesDecodeToAnEmptyList() throws { + XCTAssertEqual(try DocumentPropertyConstraint.list(fromJSON: "[]"), []) + } + + /// A kind added by a later protocol version is kept by name rather than + /// failing the whole list. + func testUnknownReadKindIsKeptByName() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: """ + [{ "name": "r", "rule": { "present": "a" }, "readsOwner": false, + "reads": [{ "path": "a", "kind": "somethingNew" }] }] + """) + + XCTAssertEqual(rules.first?.reads.first?.kind, .other("somethingNew")) + XCTAssertEqual(rules.first?.reads.first?.kind.name, "somethingNew") + } + + func testMalformedRulesAreRefused() { + let malformed = [ + "not json", + #"{"name": "r"}"#, + #"[{"name": "r", "rule": {"present": "a"}, "reads": []}]"#, + // A number is not a boolean, although NSNumber would cast it + #"[{"name": "r", "rule": {"present": "a"}, "reads": [], "readsOwner": 1}]"#, + #"[{"name": "r", "rule": {"present": "a"}, "reads": [{"path": "a"}], "readsOwner": false}]"# + ] + for json in malformed { + XCTAssertThrowsError(try DocumentPropertyConstraint.list(fromJSON: json), json) { error in + guard case SDKError.serializationError = error else { + return XCTFail("\(json): expected a serialization error, got \(error)") + } + } + } + } + + // MARK: - Violations + + func testViolationDecodes() throws { + let violation = try XCTUnwrap(PropertyConstraintViolation.decode(fromJSON: """ + { "rule": "perUnitFee", "violation": "DivisionByZero", "message": "it divides by zero" } + """)) + + XCTAssertEqual( + violation, + PropertyConstraintViolation( + rule: "perUnitFee", + violation: .divisionByZero, + message: "it divides by zero" + ) + ) + XCTAssertEqual( + violation.localizedDescription, + "The document breaks the propertyConstraints rule \"perUnitFee\" (DivisionByZero): it divides by zero." + ) + } + + func testEveryViolationNameRoundTrips() { + let names = ["NotMet", "Overflow", "DivisionByZero", "NegativeExponent", "NotAnInteger"] + let kinds: [PropertyConstraintViolation.Kind] = [ + .notMet, .overflow, .divisionByZero, .negativeExponent, .notAnInteger + ] + XCTAssertEqual(names.map(PropertyConstraintViolation.Kind.init(name:)), kinds) + XCTAssertEqual(kinds.map(\.name), names) + XCTAssertEqual(PropertyConstraintViolation.Kind(name: "Later"), .other("Later")) + } + + func testNullMeansEveryRuleHolds() throws { + XCTAssertNil(try PropertyConstraintViolation.decode(fromJSON: "null")) + } + + func testMalformedViolationsAreRefused() { + for json in ["", "[]", #"{"rule": "r", "violation": "NotMet"}"#] { + XCTAssertThrowsError(try PropertyConstraintViolation.decode(fromJSON: json), json) + } + } + + // MARK: - FFI round trips + + func testWrappersRoundTripThroughTheFFI() throws { + let sdk = try mockSDK() + let contract = try noteContract() + + XCTAssertEqual( + try sdk.documentPropertyConstraints(serializedContract: contract, documentType: "note"), + [] + ) + XCTAssertNil( + try sdk.checkDocumentPropertyConstraints( + serializedContract: contract, + documentType: "note", + propertiesJSON: #"{"message":"hi"}"#, + ownerId: ownerId + ) + ) + } + + func testFFIErrorsKeepTheirCodes() throws { + let sdk = try mockSDK() + let contract = try noteContract() + + assertThrows( + try sdk.documentPropertyConstraints(serializedContract: contract, documentType: "letter") + ) { error in + if case SDKError.notFound = error { return true } + return false + } + assertThrows( + try sdk.documentPropertyConstraints(serializedContract: Data([0xFF, 0x00, 0x13]), documentType: "note") + ) { error in + if case SDKError.serializationError = error { return true } + return false + } + assertThrows( + try sdk.documentPropertyConstraints(serializedContract: Data(), documentType: "note") + ) { error in + if case SDKError.invalidParameter = error { return true } + return false + } + assertThrows( + try sdk.checkDocumentPropertyConstraints( + serializedContract: contract, + documentType: "note", + propertiesJSON: "[1]", + ownerId: ownerId + ) + ) { error in + if case SDKError.invalidParameter = error { return true } + return false + } + } + + /// The FFI reads 32 bytes behind the owner pointer, so a shorter id is + /// refused before the call. + func testOwnerIdMustBe32Bytes() throws { + let sdk = try mockSDK() + + assertThrows( + try sdk.checkDocumentPropertyConstraints( + serializedContract: try noteContract(), + documentType: "note", + propertiesJSON: "{}", + ownerId: Data(repeating: 7, count: 20) + ) + ) { error in + if case SDKError.invalidParameter = error { return true } + return false + } + } + + // MARK: - PersistentDocumentType + + func testDocumentTypeReadsThroughItsStoredContract() throws { + let sdk = try mockSDK() + let stored = try persistNoteType(binarySerialization: try noteContract()) + let docType = stored.documentType + + XCTAssertFalse(docType.declaresPropertyConstraints) + XCTAssertEqual(try docType.propertyConstraints(using: sdk), []) + XCTAssertNil( + try docType.propertyConstraintViolation( + propertiesJSON: #"{"message":"hi"}"#, + ownerId: ownerId, + using: sdk + ) + ) + withExtendedLifetime(stored.context) {} + } + + func testDocumentTypeWithoutAStoredContractCannotBeRead() throws { + let sdk = try mockSDK() + let stored = try persistNoteType(binarySerialization: nil) + + assertThrows(try stored.documentType.propertyConstraints(using: sdk)) { error in + if case SDKError.invalidState = error { return true } + return false + } + withExtendedLifetime(stored.context) {} + } + + func testDeclaresPropertyConstraintsReadsTheKeyword() { + let schema: [String: Any] = [ + "type": "object", + "propertyConstraints": ["r": ["present": "a"]] + ] + let docType = PersistentDocumentType( + contractId: Data(repeating: 1, count: 32), + name: "offer", + schemaJSON: (try? JSONSerialization.data(withJSONObject: schema)) ?? Data(), + propertiesJSON: Data("{}".utf8) + ) + + XCTAssertTrue(docType.declaresPropertyConstraints) + } + + // MARK: - Helpers + + private func mockSDK() throws -> SDK { + SDK.initialize() + return try SDK(mockVectorsDirectory: nil) + } + + private func noteContract() throws -> Data { + let contract = try XCTUnwrap(Data(hexString: noteContractHex)) + // `Data(hexString:)` ignores a trailing odd digit: pin the whole fixture + XCTAssertEqual(contract.count * 2, noteContractHex.count) + return contract + } + + /// A persisted document type and the context holding it, which the caller + /// keeps alive while it reads the type's relationships. + private struct StoredDocumentType { + let documentType: PersistentDocumentType + let context: ModelContext + } + + /// Persist the `note` contract and its type the way a download does: the + /// contract row carrying `binarySerialization`, and the parser writing the + /// type row. + private func persistNoteType(binarySerialization: Data?) throws -> StoredDocumentType { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + let contractId = Data(repeating: 0xC3, count: 32) + + let contract = PersistentDataContract( + id: contractId, + name: "Notes", + serializedContract: Data(), + network: .testnet + ) + contract.binarySerialization = binarySerialization + context.insert(contract) + try context.save() + + try DataContractParser.parseDataContract( + contractData: [ + "documents": [ + "note": [ + "type": "object", + "properties": ["message": ["type": "string", "maxLength": 64, "position": 0]], + "additionalProperties": false + ] + ] + ], + contractId: contractId, + modelContext: context + ) + + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.contractId == contractId } + ) + let docType = try XCTUnwrap(try context.fetch(descriptor).first) + return StoredDocumentType(documentType: docType, context: context) + } + + private func assertThrows( + _ expression: @autoclosure () throws -> T, + file: StaticString = #filePath, + line: UInt = #line, + matching matches: (Error) -> Bool + ) { + XCTAssertThrowsError(try expression(), file: file, line: line) { error in + XCTAssertTrue(matches(error), "unexpected error: \(error)", file: file, line: line) + } + } +} From f48797263b759cf56c13bc6e969a66e4cf0009a9 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 23:46:13 +0700 Subject: [PATCH 029/113] docs: add a contract keywords reference page to the book (#5067) Co-authored-by: Claude Opus 5.5 --- book/src/SUMMARY.md | 1 + book/src/contract-keywords.md | 327 ++++++++++++++++++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 book/src/contract-keywords.md diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 5dc2b090f4e..6a428598fcb 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -119,4 +119,5 @@ # Appendix +- [Contract Keywords Reference](contract-keywords.md) - [API Reference](api-reference.md) diff --git a/book/src/contract-keywords.md b/book/src/contract-keywords.md new file mode 100644 index 00000000000..7378e95df3f --- /dev/null +++ b/book/src/contract-keywords.md @@ -0,0 +1,327 @@ +# Contract Keywords Reference + +This page lists every keyword a data contract's document type schema accepts. For each one it gives what the keyword does, where it may go, the protocol version it arrived in, what a contract update may do with it and, where one applies, the error a document that breaks it is refused with. The chapters linked from each entry explain the mechanics. + +The list follows the document meta-schema of protocol version 14, `packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json`. Every document type schema is validated against it when a contract is registered or updated, and the parser (`try_from_schema`) checks the rules a JSON schema cannot express. When this page and the meta-schema disagree, the meta-schema is right and this page is out of date. + +## Where keywords go + +A contract's `documentSchemas` maps each document type name to its schema. Keywords sit at three levels: + +```json +"post": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "indices": [ + { "name": "byOwner", "properties": [{ "$ownerId": "asc" }, { "$createdAt": "asc" }] } + ], + "properties": { + "text": { "type": "string", "maxLength": 280, "maxBytes": 560, "position": 0 }, + "replyTo": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "deletableDocument", "documentType": "post" }, + "position": 1 + } + }, + "required": ["$createdAt", "text"], + "additionalProperties": false +} +``` + +- **Document type keywords** sit at the top of the schema (`documentsMutable`, `canBeDeleted`, `indices`, `required`). They say what may happen to a document of the type and who may do it. +- **Index keywords** sit inside an entry of `indices` (`name`, `properties`). +- **Property keywords** sit inside a property's schema (`type`, `maxLength`, `maxBytes`, `position`, `refersTo`). Most are ordinary JSON Schema; the rest are Platform's own. + +## How to read the tables + +- **From** is the first protocol version at which the keyword can be used. The document meta-schema changed at three versions: v1 at protocol version 12, v2 at 13 and v3 at 14. From 12 the meta-schema refuses a key it does not know; before 12 an unknown document type key was ignored. +- **Update** is what a contract update may do with the keyword on a document type that already exists. *Fixed* means adding, removing and changing it are all refused. A document type the update adds may use any keyword, as a new contract may. +- Codes in parentheses are consensus error codes (see [Error Codes](error-handling/error-codes.md)). A contract update that breaks an update rule is refused with `IncompatibleDocumentTypeSchemaError` (10246) when the schema comparison catches it, or `DocumentTypeUpdateError` (40212) when the document type comparison does. The tables say which. + +## Document type keywords + +### Shape + +| Keyword | Value | What it does | From | Update | +|---|---|---|---|---| +| `type` | `"object"` | Required. A document is always an object. | 1 | Fixed | +| `$schema` | the meta-schema URL | The platform adds it when it reads the contract; a contract need not write it. | 1 | Fixed | +| `properties` | object | Required. The document's properties, 1 to 100 of them, each named with 1 to 64 letters, digits or underscores (from 14; earlier versions also admitted `-`). The system properties below may be listed here too. See [Property keywords](#property-keywords). | 1 | Properties may be added, optional or (with `requiredSince`) required; none may be removed (10246) | +| `additionalProperties` | `false` | Required, and only `false`: a document holds only the properties its type declares. | 1 | Fixed | +| `required` | array of names | The properties every document must hold. Listing a system timestamp or block height (`$createdAt`, `$updatedAtBlockHeight`) makes the platform record it on every document of the type; one that is not listed is never recorded. See [System properties](#system-properties). | 1 | May gain only a property the update adds, annotated with `requiredSince`; loses nothing (`DataContractInvalidRequiredFieldsUpdateError`, 10276) | +| `transient` | array of names | Properties validated on the transition but never stored. From 14 a replace drops them as a create does, each entry must name a top-level property, and no index, lookup key or `encryptedFor` may read one. See [Transient Properties](data-model/documents.md#transient-properties). | 1 | Fixed (10246); order and repeats are no change | +| `$comment`, `description` | string | Notes for readers; consensus ignores them. | 1 | Free | +| `minProperties`, `maxProperties` | integer | JSON Schema bounds on how many properties a document holds. | 12 | Fixed | +| `dependentRequired` | object | JSON Schema: a property that requires others when present. | 12 | May lose entries, not gain them (10246) | +| `$defs` | object | Definitions local to the document type, which `$ref` may point at. Contract-wide definitions live in the contract's `schemaDefs`. | 1 | Definitions may be added, not removed (10246) | + +### What may happen to a document + +| Keyword | Value | What it does | From | Update | +|---|---|---|---|---| +| `documentsMutable` | boolean, default `true` | `false` makes documents unchangeable after creation: a replace is refused (`InvalidDocumentTransitionActionError`, 10404). | 1 | Fixed (40212) | +| `canBeDeleted` | boolean, default `true` | `false` stops a document's owner from deleting it (10404). Says nothing about moderators or a `ttl`. From 14 a type that keeps history may not allow deletion. | 1 | Fixed (40212), except that a type keeping history may turn it off | +| `immutable` | array of names | Top-level properties frozen at creation on an otherwise mutable type: a replace that changes, adds or removes one is refused (`DocumentImmutablePropertyChangedError`, 40128). Only with `documentsMutable: true`; no system, nested or transient names. See [Immutable Properties](data-model/documents.md#immutable-properties-on-mutable-document-types). | 14 | May gain entries, never lose one (40212) | +| `immutableAllowSetting` | array of names | The `immutable` properties a replace may still set once, while the stored document has no value for them. Every entry must also be in `immutable`. | 14 | May lose entries; may gain one only for a property made immutable in the same update (40212) | +| `transferable` | `0` never, `1` always | `1` lets an owner give a document to another identity with a transfer transition. A transfer of a type set to `0` is refused (10404). | 1 | Fixed (40212) | +| `tradeMode` | `0` none, `1` direct purchase | `1` lets an owner set a price and anyone buy the document at that price, with no approval. Refused price updates are 10404; a purchase of a document with no price is `DocumentNotForSaleError` (40108), one at the wrong price `DocumentIncorrectPurchasePriceError` (40109). | 1 | Fixed (40212) | +| `creationRestrictionMode` | `0` anyone, `1` contract owner only, `2` nobody | Who may create documents. `2` is for system contracts whose documents only the platform writes. A refused create is `DocumentCreationNotAllowedError` (10416). | 1 | Fixed (40212) | +| `ttl` | seconds, 3600 to 31536000 | The platform deletes each document this long after its `$createdAt`, whoever owns it. Storage is priced for the time the document lives and refunds nothing. Needs `$createdAt` in `required`; refused with `documentsKeepHistory`, `indexOnly` and a contested index. After expiry a document can only be deleted (`DocumentExpiredError`, 40140). See [Document Time To Live](data-model/document-ttl.md). | 14 | Fixed (40212) | +| `documentsKeepHistory` | boolean, default `false` | Drive keeps every revision of every document, not only the latest. | 1 | Fixed (40212) | +| `keepsTransferHistory` | boolean, default `false` | Records every transfer of a document of the type in the document history system contract. | 13 | Fixed (40212) | +| `keepsPurchaseHistory` | boolean, default `false` | Records every purchase in the document history system contract. | 13 | Fixed (40212) | +| `keepsPricingHistory` | boolean, default `false` | Records every price update in the document history system contract. | 13 | Fixed (40212) | + +### Who may write + +| Keyword | Value | What it does | From | Update | +|---|---|---|---|---| +| `signatureSecurityLevelRequirement` | `1` critical, `2` high, `3` medium | The weakest identity key security level that may sign a transition on documents of the type. Default `2` (high). A key that is too weak is refused (`InvalidSignaturePublicKeySecurityLevelError`, 20004). See [Security Level](sdk/identity-keys.md#security-level). | 1 | Fixed (40212) | +| `requiresIdentityEncryptionBoundedKey` | `0` unique, `1` multiple, `2` multiple with a pointer to the latest | Lets identities add encryption keys bound to this document type, and says how they are kept: one key that cannot be replaced, several, or several with a pointer to the latest. A key may only be bound to a type that declares it. See [Contract Bounds](sdk/identity-keys.md#contract-bounds). | 1 | Fixed (40212) | +| `requiresIdentityDecryptionBoundedKey` | same as above | The same for decryption keys. | 1 | Fixed (40212) | +| `ownerRefersTo` | a `refersTo` declaration | A reference whose value is the writer (`$ownerId`) instead of a property: for example, the writer must own a document a lookup finds, or be an element of a list. Only on types whose documents can be neither transferred nor traded. Checked on create, and on a replace that changes what it reads. See [On the writer or the creator](data-model/documents.md#on-the-writer-or-the-creator-ownerrefersto-creatorrefersto). | 14 | Fixed (10246) | +| `creatorRefersTo` | a `refersTo` declaration | The same for the document's creator (`$creatorId`), for types whose documents can be transferred or traded. A type declares at most one of the two. | 14 | Fixed (10246) | +| `canBeDeletedByModerators` | boolean | Lets the contract's moderators delete documents of the type with a moderation transition, leaving a removal record. Needs `moderation` in the contract config; refused on types that keep history, are `indexOnly` or restrict creation. Makes the type count as deletable for references. See [Deleting Documents](data-model/contract-moderation.md#deleting-documents). | 14 | Fixed (40212) | +| `canBeDeletedByModeratorsFor` | seconds, 1 to 4294967295 | Limits the moderators' deletion to this long after the document's last change (`$updatedAt`). Later deletions are refused (`DocumentModerationWindowElapsedError`, 41116). Needs `canBeDeletedByModerators: true` and `$updatedAt` in `required`. | 14 | Fixed (40212) | + +### Rules over several properties + +| Keyword | Value | What it does | From | Update | +|---|---|---|---|---| +| `propertyConstraints` | object of named rules | Rules every created or replaced document must meet, where JSON Schema bounds one property at a time: comparisons and arithmetic over integer properties, string and identifier comparisons, value sets, presence tests, combined with `anyOf`, `allOf` and `not`. At most 16 rules of at most 32 nodes each. A broken rule refuses the document (`DocumentPropertyConstraintViolatedError`, 10422). See [the operators](#propertyconstraints-operators) and [Property Constraints](data-model/documents.md#property-constraints-propertyconstraints). | 14 | Fixed (10246) | + +### Costs + +| Keyword | Value | What it does | From | Update | +|---|---|---|---|---| +| `tokenCost` | object keyed by action | A token payment for an action on a document: `create`, `replace`, `delete`, `transfer`, `update_price` or `purchase`. Each takes the keys below. A transition that leaves out the payment a required cost asks for is refused (`RequiredTokenPaymentInfoNotSetError`, 40115). See [Fee System Overview](fees/overview.md#gas-paid-by-the-contract-owner). | 9 | Fixed | +| `tokenCost..tokenPosition` | integer | Required. Which token of the contract (`contractId` absent) or of the named contract is charged. | 9 | | +| `tokenCost..amount` | integer, at least 1 | Required. How many tokens the action costs. | 9 | | +| `tokenCost..contractId` | identifier | The contract whose token is charged, when it is not this one. | 9 | | +| `tokenCost..effect` | `0` transfer to the contract owner (default), `1` burn | What happens to the tokens paid. Burning is only allowed on the contract's own token (10261). | 9 | | +| `tokenCost..gasFeesPaidBy` | `0` document owner (default), `1` contract owner, `2` prefer contract owner | Who the contract owner offers to have pay the gas of a token-paid action. A transition that insists on a payer the type does not offer is refused (`GasFeesPaidByNotAllowedError`, 40129). Acted on from 14. | 14 | | +| `tokenCost..optional` | boolean, default `false` | `true` lets a transition skip the token and pay the gas in credits instead. See [Optional token costs](fees/overview.md#optional-token-costs). | 14 | | +| `actionFees` | object keyed by action | A fixed fee in credits, on top of the gas, for `create`, `replace`, `delete`, `transfer`, `update_price` or `purchase`, split between the contract owner's pot and the moderators' pot. The transition must state the fee it agrees to (`DocumentActionFeeAgreementNotSetError` 40132, `DocumentActionFeeAgreementMismatchError` 40133, `DocumentActionFeeMultiplierNotToleratedError` 40134). See [Document action fees](fees/overview.md#document-action-fees). | 14 | Fixed (40212) | +| `actionFees.pricing` | `"feeMultiplier"` (default) or `"fixed"` | Whether the amounts scale with the epoch's fee multiplier or are charged as written. | 14 | | +| `actionFees..owner` | credits | Added to the contract owner's pot, which the owner claims. | 14 | | +| `actionFees..moderators` | credits | Added to the moderators' pot, shared by the moderation team. Needs `moderation` in the contract config (10902). | 14 | | + +### Storage layout and aggregates + +| Keyword | Value | What it does | From | Update | +|---|---|---|---|---| +| `indices` | array of 1 to 10 indexes | The indexes documents are queried by, and the uniqueness rules they enforce. See [Index keywords](#index-keywords). | 1 | Fixed from 14: no index added, removed or changed (10217); reordering is no change | +| `documentsCountable` | boolean | Keeps a count of the type's documents in the primary key tree, so the total is read in one step. See [Document Count Trees](drive/document-count-trees.md#primary-key-tree-flags). | 12 | Fixed (40212) | +| `rangeCountable` | boolean | A provable count tree on the primary key, for counts over id ranges. Implies `documentsCountable`. | 12 | Fixed (40212) | +| `documentsSummable` | property name | Keeps the sum of one required integer property over all the type's documents. See [Document Sum Trees](drive/document-sum-trees.md#primary-key-tree-flags). | 12 | Fixed (40212) | +| `rangeSummable` | boolean | A provable sum tree on the primary key. Needs `documentsSummable` or `documentsAverageable`. Rarely useful; the index flag of the same name is what most contracts want. | 12 | Fixed (40212) | +| `documentsAverageable` | property name | Shorthand for `documentsCountable: true` plus `documentsSummable` on the named property. | 12 | Fixed (40212) | +| `rangeAverageable` | boolean | Shorthand for `rangeCountable` plus `rangeSummable`. Needs `documentsAverageable`. | 12 | Fixed (40212) | +| `indexOnly` | boolean | Documents are never written to primary storage: the index entries are the rows. Needs every property required and indexed, `$ownerId` in an index, `documentsMutable: false`, no transfers, trading, history or transient properties. See [Index-Only Document Types](drive/index-only-document-types.md). | 14 | Fixed (40212) | +| `entryPayload` | array of 1 to 16 names | On an `indexOnly` type, the properties stored in each entry's value instead of in a key. | 14 | Fixed (40212) | + +## Property keywords + +### JSON Schema keywords + +A property's schema is JSON Schema (draft 2020-12), limited to the keywords below. Every document is validated against it on create and replace, and a value it refuses is a `JsonSchemaError`. Unless a row says otherwise, a contract update that breaks a property's update rule is refused with `IncompatibleDocumentTypeSchemaError` (10246). + +| Keyword | Where | What it does | From | Update | +|---|---|---|---|---| +| `type` | every property | `string`, `integer`, `number`, `boolean`, `object` or `array`. An array is a byte array (`byteArray: true`) or, from 14, a typed array (`items`). | 1 | Fixed | +| `minLength`, `maxLength` | strings | Length in characters. A string with `pattern` or `format` must declare `maxLength` of at most 50000. | 1 | May be loosened: `maxLength` raised, `minLength` lowered, either removed | +| `pattern` | strings | A regular expression the value must match, in the syntax of Rust's `regex` crate (`IncompatibleRe2PatternError`, 10202). | 1 | May be removed, not added or changed | +| `format` | strings | A JSON Schema format such as `date-time` or `uri`. | 1 | May be removed, not added or changed | +| `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` | numbers | Numeric bounds. On an integer they also decide how many bytes it is stored in, when the contract sets `sizedIntegerTypes`. | 1 | May be loosened or removed, unless that changes an integer's stored width or sign (40212); `multipleOf` is fixed | +| `enum` | any | The values allowed, at least one, none repeated. | 1 | Values may be added, not removed; the keyword may be removed, not added. A value that widens an integer's stored width is refused (40212) | +| `const` | any | The one value allowed. | 1 | May be removed, not added or changed | +| `minItems`, `maxItems` | arrays | On a byte array, the length in bytes; on a typed array, the number of elements (`maxItems` required there, at most 1024). | 1 | May be loosened; a byte array may not switch between fixed and variable length (40212) | +| `uniqueItems` | arrays | On a typed array, no element may repeat. On a plain byte array, no byte may repeat; refused on an identifier. | 1 | May be removed, not added | +| `contains` | arrays | JSON Schema `contains`. | 1 | Fixed | +| `properties`, `required`, `additionalProperties` | objects | A nested object's own properties (each needs a `position`), which of them are required, and `additionalProperties: false`, which is required. | 1 | Nested properties may be added, not removed; a nested `required` and `additionalProperties` are fixed | +| `minProperties`, `maxProperties`, `dependentRequired` | objects | JSON Schema bounds on a nested object. | 1 | `dependentRequired` may lose entries, not gain them; the others are fixed | +| `$ref` | any | Points at a definition in the contract's `schemaDefs` (`#/$defs/...`). Only local references starting with `#`. | 1 | Fixed | +| `$id`, `$comment`, `description`, `examples` | any | Annotations; consensus ignores them. | 1 | Free, except that `$id` may only be added | + +### Platform keywords + +| Keyword | Where | What it does | From | Update | +|---|---|---|---|---| +| `position` | every property | The property's place in the stored document, which is encoded by position, not by name. Top-level positions, and those inside each object, run 0, 1, 2 with no gap (`MissingPositionsInDocumentTypePropertiesError`, 10411). See [Document Serialization](serialization/document-serialization.md). | 1 | Fixed | +| `byteArray` | arrays | `true` makes the array a string of bytes, stored raw. | 1 | Fixed | +| `contentMediaType` | byte arrays | `"application/x.dash.dpp.identifier"` makes a 32-byte array an identifier: shown in base58, converted from strings, and the only kind of property `refersTo` and `distinctFrom` accept. It must come with `byteArray: true`, `minItems: 32` and `maxItems: 32`. | 1 | Fixed | +| `items` | arrays | Makes the array a typed array: a list whose elements are all one scalar type (integer, number, string, boolean, byte array or identifier), stored inline. Elements take `enum`, bounds, `maxBytes`, `distinctFrom` and `refersTo`, but no `position` or `const`. See [Typed Arrays](data-model/documents.md#typed-arrays). | 14 | `items` may be neither added nor removed; the keywords inside it follow the rules above, and a change to how elements are stored is refused (40212) | +| `requiredSince` | top-level required properties | The contract version from which a property is required. It lets an update add a new required property: documents written under an earlier version may leave it out. On an update it must equal the version the update creates; on a new contract it may only be 1 (`DataContractInvalidRequiredFieldsUpdateError`, 10276). See [Adding Required Fields](data-model/data-contracts.md#evolving-a-contract-adding-required-fields). | 14 | Only on a property the update adds; an existing annotation is fixed | +| `maxBytes` | strings, and string elements | The most bytes the value may take in UTF-8, 1 to 65535, where `maxLength` counts characters of up to four bytes each. A longer value is refused (`DocumentPropertyMaxBytesExceededError`, 10421). See [Byte Caps on Strings](data-model/documents.md#byte-caps-on-strings-maxbytes). | 14 | May be raised or removed; not added or lowered (10246) | +| `distinctFrom` | identifiers, and identifier elements | The value must differ from another identifier property of the document, or from `$ownerId`. An equal pair is refused (`DocumentPropertyNotDistinctError`, 10419), and so is a transfer or purchase to the identity a `$ownerId` rule names. See [Distinct Identifier Properties](data-model/documents.md#distinct-identifier-properties). | 14 | Fixed (10246) | +| `encryptedFor` | byte arrays that are not identifiers | Declares how the property's ciphertext was made: `recipient` (an identifier property or `$ownerId`), `recipientKey` and `senderKey` (key id properties) and `scheme` (`"ecdh-secp256k1-aes256-cbc"`). All four are required. Consensus checks only the length shape (`InvalidEncryptedPropertyShapeError`, 10420). See [Encrypted Properties](data-model/documents.md#encrypted-properties-encryptedfor). | 14 | Fixed (10246) | +| `refersTo` | identifiers, identifier elements, and key id integers | What the value points at, checked when the document is written: the target must exist, and may have to meet further requirements. See [refersTo](#refersto) below. | 14 | Fixed (10246) | + +## `refersTo` + +A reference is checked when a document is created or replaced. Nothing checks it again when the target changes later: a `permanentDocument` target can never be deleted, and a `deletableDocument` reference is checked again on the referring document's next replace. A document carries at most 256 references, counting one per property, one for `ownerRefersTo` or `creatorRefersTo`, and `maxItems` per typed array of references. See [Document References](data-model/documents.md#document-references-refersto). + +### Targets + +| `type` | The value is | Keys it takes | +|---|---|---| +| `identity` | the id of an existing identity | none | +| `contract` | the id of an existing data contract | `contractRequirements` | +| `token` | the id of an existing token | none | +| `permanentDocument` | the id of a document of a type that can never be deleted, or with `lookup` a part of a unique index key that finds one | `documentType`, `contractId`, `propertyAgreement`, `lookup` | +| `deletableDocument` | the same for a type that can be deleted. Re-checked on every replace, so a reference to a deleted document must be repointed or cleared. | `documentType`, `contractId`, `propertyAgreement`, `lookup` | +| `identityPublicKey` | an identity key that exists and is not disabled. On an identifier, the identity, with `keyIdProperty` naming the key id property; on an integer from 0 to 4294967295, the key id, with `identityProperty` naming whose key it is. | `keyIdProperty` or `identityProperty`, `keyRequirements` | +| `listElement` | one of the identifiers a list of another document holds | `documentType`, `contractId`, `propertyAgreement` (with one `$id` pair), `inList` | + +Instead of a `type`, a declaration may hold only `anyOf` (at least one operand holds) or only `allOf` (every operand holds). An operand is an `identity`, a `permanentDocument`, a `listElement`, a `deletableDocument` with a `lookup`, or an expression of the other combinator. A list holds 2 to 4 operands and expressions nest at most 4 deep. See [Reference expressions](data-model/documents.md#reference-expressions-anyof-allof). + +### Keys + +| Key | For | What it does | +|---|---|---| +| `documentType` | document and list references | The referenced document type. For `permanentDocument` and `listElement` it must forbid deletion; for `deletableDocument` it must allow it. | +| `contractId` | document and list references | The contract holding `documentType`, as base58 or 32 bytes. Absent means this contract. | +| `propertyAgreement` | document and list references | Up to 10 pairs `{ "referring property": "referenced property" }` that must be equal when the document is written. The referring side may be `$ownerId`, which makes the pair a write gate; the referenced side may be `$ownerId`, `$creatorId` or `$id`. | +| `lookup` | `permanentDocument`, `deletableDocument` | `{ "index": ..., "keys": {...} }`: the value is part of a key, and the referenced document is the one a unique index of `documentType` finds. Each key maps an index property to a referring property path, `"$ownerId"` or `"."` (the value itself, exactly once). See [Resolved through a unique index](data-model/documents.md#resolved-through-a-unique-index-lookup). | +| `inList` | `listElement` | The typed array of identifiers on the referenced document the value must be in. The list must never change once written. See [An element of a list](data-model/documents.md#an-element-of-a-list-listelement). | +| `keyIdProperty` | `identityPublicKey` on an identifier | The sibling integer property holding the key id. | +| `identityProperty` | `identityPublicKey` on a key id | Whose key it is: `"$ownerId"`, `"$creatorId"` or an identifier property path. | +| `keyRequirements` | `identityPublicKey` | What the key must be: `purpose` (`authentication`, `encryption`, `decryption`, `transfer`, `voting` or `owner`) and `boundTo` (a document type of this contract the key must be bound to). | +| `contractRequirements` | `contract` | What the contract must be: `moderation` (`"elected"`, or `"electionOpen"` once its election delay has passed), `minimumAgeSeconds`, `minimumSecondsSinceUpdate`, `owner` (`"self"`, the writer, or `"other"`), `readonly: true`, `keepsHistory: true`, `ownerProtected` (boolean). See [Elected Moderation](data-model/contract-moderation.md#elected-moderation). | + +### Errors + +| Error | Code | When | +|---|---|---| +| `ReferencedEntityNotFoundError` | 40120 | The target does not exist, a lookup finds nothing, or a value is not in the list. | +| `ReferencedDocumentTypeNotFoundError` | 40121 | At registration: `documentType` does not exist. | +| `ReferencedDocumentTypeDeletableError` | 40122 | At registration: a `permanentDocument` or `listElement` reference names a type that can be deleted. | +| `ReferencedIdentityKeyNotFoundError` | 40123 | The key does not exist. | +| `ReferencedIdentityKeyDisabledError` | 40124 | The key is disabled. | +| `ReferencedKeyIdPropertyInvalidError` | 40125 | A key id is set without the identity it belongs to, or a key reference is declared twice. | +| `ReferencedDocumentPropertyAgreementInvalidError` | 40126 | At registration: a `propertyAgreement` pair names a missing property or mismatched kinds. | +| `ReferencedDocumentPropertyMismatchError` | 40127 | A `propertyAgreement` pair does not hold. | +| `ReferencedDocumentTypeNotDeletableError` | 40131 | At registration: a `deletableDocument` reference names a type that forbids deletion. | +| `ReferencedContractRequirementNotMetError` | 40135 | A `contractRequirements` entry is not met. | +| `ReferencedIdentityKeyRequirementNotMetError` | 40136 | A `keyRequirements` entry is not met. | +| `ReferencedDocumentLookupInvalidError` | 40137 | At registration: a lookup into another contract cannot resolve. | +| `ReferencedDocumentListInvalidError` | 40138 | At registration: an `inList` list of another contract does not qualify. | + +## Index keywords + +An index entry sits in `indices`. A document type has at most 10 indexes of at most 10 properties each. See [Indexes](drive/indexes.md). + +| Keyword | Value | What it does | From | +|---|---|---|---| +| `name` | string, 1 to 32 characters | Required. The index's name, unique within the type. Queries and errors name it. | 1 | +| `properties` | array of `{ "": "asc" }` | The indexed properties, in order; a query uses the index through a prefix of them. A nested property is named by its dotted path (`records.identity`), and system properties such as `$ownerId` and `$createdAt` may be indexed. An indexed string needs `maxLength` of at most 63 and a byte array `maxItems` of at most 255 (`InvalidIndexedPropertyConstraintError`, 10205); a typed array cannot be indexed (`InvalidIndexPropertyTypeError`, 10206). | 1 | +| `unique` | boolean | No two documents may hold the same values for all the properties (`DuplicateUniqueIndexError`, 40105). A document with a null among them is not held to it. | 1 | +| `nullSearchable` | boolean, default `true` | `false` leaves out of the index a document whose indexed properties are all null. | 1 | +| `contested` | object | Makes a unique index a contested resource: a document whose values match opens or joins a contest that masternodes vote on, instead of being refused as a duplicate. Takes `resolution` (required: `0` masternode vote, `1` masternode vote without a lock choice, from 14), `fieldMatches` (a list of `{ "field", "regexPattern" }`, the values that are contested) and `description`. Needs `unique: true` on a type whose documents cannot be replaced (`ContestedUniqueIndexOnMutableDocumentTypeError`, 10248). See [Contested Documents](data-model/contested-documents.md). | 1 | +| `countable` | `"notCountable"`, `"countable"`, `"countableAllowingOffset"`, or a boolean | Keeps a document count per indexed value, so counts are read without walking the documents. See [Document Count Trees](drive/document-count-trees.md#per-index-countable-flag). | 12 | +| `rangeCountable` | boolean | Counts over ranges of the indexed value in logarithmic time, with proofs. Implies `countable`. | 12 | +| `summable` | property name | Keeps the sum of a required integer property per indexed value. See [Document Sum Trees](drive/document-sum-trees.md#per-index-summable-flag). | 12 | +| `rangeSummable` | boolean | Sums over ranges of the indexed value. Needs `summable` or `averageable`. | 12 | +| `averageable` | property name | Shorthand for `countable: "countable"` plus `summable` on the named property. | 12 | +| `rangeAverageable` | boolean | Shorthand for `rangeCountable` plus `rangeSummable`. Needs `averageable`. | 12 | +| `rankedCountable` | boolean, or `{ "at": ... }` | Orders the indexed values by how many documents each has, for "top K" queries with proofs. `at` names the index level, or levels, that carry the ranking. Needs `rangeCountable`. See [Document Ranked Trees](drive/document-ranked-trees.md#contract-grammar). | 14 | +| `rankedSummable` | boolean | Orders the indexed values by the sum of the `summable` property. Needs `rangeSummable`. | 14 | +| `rankedAverageable` | boolean | Orders the indexed values by the average of the `averageable` property. Needs `rangeAverageable`, or `rangeCountable` and `rangeSummable`. | 14 | +| `timeRange` | `{ "on", "range", "step", "phase", "ttl" }` | Buckets the index's first property, a system timestamp, into time windows of `range` seconds starting every `step` seconds, offset by `phase`, for trending queries. `ttl` (at most one week) expires entries past their window. See [Time-Range Index TTL](drive/time-range-ttl.md#grammar-and-validation). | 14 | +| `terminal` | property name or list of names | On an `indexOnly` type, the property or properties whose values key each entry, in place of the document id. Default `$ownerId`. | 14 | +| `preallocated` | boolean | On an `indexOnly` type bound to a same-contract `permanentDocument` reference, creates the index's trees when the referenced document is created, so every entry costs the same. See [Preallocated index paths](drive/index-only-document-types.md#preallocated-index-paths). | 14 | +| `skipIfAbsent` | boolean | On an `indexOnly` type, a document without the index's first property writes no entry. See [Conditional participation](drive/index-only-document-types.md#conditional-participation-skipifabsent). | 14 | + +From protocol version 14 a contract update may not add, remove or change an index of an existing document type (`DataContractInvalidIndexDefinitionUpdateError`, 10217). Indexes are compared by name, so reordering `indices` is no change and renaming one is a removal plus an addition. A document type the update adds may declare any index. + +## `propertyConstraints` operators + +A rule is an object with one key. Paths are dotted property paths (`"meta.total"`); a bare string is always a path and a bare number always a value. + +| Operator | Form | Holds when | +|---|---|---| +| `equal`, `notEqual` | `[a, b]` | The two integer expressions are equal (or not). Also compares a string property with `{ "const": "..." }` or another string property, and an identifier property with a base58 `const`, another identifier property or `$ownerId`. | +| `lessThan`, `lessThanOrEqual`, `greaterThan`, `greaterThanOrEqual` | `[a, b]` | The comparison of the two integer expressions holds. | +| `in` | `[a, [v1, v2, ...]]` | `a` takes one of two or more distinct values: integers, or strings for a string or identifier property. | +| `present` | `"path"` | The document holds the property, with a value other than null. | +| `absent` | `"path"` | The document leaves the property out or sets it to null. | +| `anyOf` | `[c1, c2, ...]` | At least one condition holds, checked in order. | +| `allOf` | `[c1, c2, ...]` | Every condition holds, checked in order. | +| `not` | `c` | The condition does not hold. | + +An integer expression is a number, the path of an integer or boolean property (a property left out reads as 0, a boolean as 1 or 0), or an object with one key: + +| Operator | Form | Value | +|---|---|---| +| `add`, `multiply` | `[a, b, ...]` | The sum or product of two or more operands. | +| `subtract`, `divide`, `modulo`, `power` | `[a, b]` | The result of the operation. `divide` and `modulo` are Euclidean. | +| `ifAbsent` | `["path", default]` | The property's value, or `default` when the document leaves it out. | +| `const` | `"string"` | A string or identifier constant, only as a side of `equal` or `notEqual`. | + +Arithmetic is exact over 128-bit integers: an overflow, a division by zero or a negative exponent breaks the rule. + +## System properties + +The platform manages these. A document type lists them to use them: in `required` to have the platform record them, in `indices` to query by them, and in `properties` where it wants to say more about them. + +| Property | What it holds | Recorded | +|---|---|---| +| `$id` | The document's id. | Always | +| `$ownerId` | The identity that owns the document: its creator, then whoever it was transferred or sold to. | Always | +| `$revision` | The document's revision: 1 at creation, raised by every replace, transfer, price update and purchase. | On types whose documents can be replaced, transferred or traded | +| `$createdAt`, `$updatedAt`, `$transferredAt` | Block times, in milliseconds, of the creation, the last replace or price update, and the last transfer. | When listed in `required` | +| `$createdAtBlockHeight`, `$updatedAtBlockHeight`, `$transferredAtBlockHeight` | Platform block heights of the same events. | When listed in `required` | +| `$createdAtCoreBlockHeight`, `$updatedAtCoreBlockHeight`, `$transferredAtCoreBlockHeight` | Core chain block heights of the same events. | When listed in `required` | +| `$creatorId` | The identity that created the document, which a transfer or purchase never changes. Not declared in `properties`: references, `creatorRefersTo` and indexes read it. | On transferable or tradeable types of format-1 contracts, from protocol version 10 | + +## Contract-level keys + +The document types sit in a contract, which has keys of its own. See [Data Contracts](data-model/data-contracts.md#what-v1-added). + +| Key | What it is | From | +|---|---|---| +| `$formatVersion` | The contract's serialization format, `"0"` or `"1"`. Format 1 is the default from protocol version 9 and carries every key below. | 1 | +| `id`, `ownerId`, `version` | The contract's id, the identity that owns it, and its version, which each update raises by one. | 1 | +| `config` | Contract-wide settings, below. | 1 | +| `documentSchemas` | The document types, by name. | 1 | +| `schemaDefs` | Definitions every document type may `$ref`. An update may add definitions, not remove them (`IncompatibleDataContractSchemaError`, 10213). | 1 | +| `groups` | Groups of identities that act together, each member with a voting power, where a token action needs a group's approval. | 9 | +| `tokens` | The contract's tokens, by position. | 9 | +| `keywords` | Up to 50 search keywords, 3 to 50 characters each, no repeats, for the keyword search contract. | 9 | +| `description` | 3 to 100 characters, for the keyword search contract. | 9 | +| `createdAt`, `updatedAt`, and their block heights and epochs | Set by the platform. | 9 | + +The `config` keys: + +| Key | Default | What it does | From | Update | +|---|---|---|---|---| +| `canBeDeleted` | `false` | Whether the contract itself may ever be deleted. No transition deletes a contract today. | 1 | Fixed (40002) | +| `readonly` | `false` | `true` means the contract can never be updated (`DataContractIsReadonlyError`, 40001). | 1 | Cannot be set by an update (40002) | +| `keepsHistory` | `false` | Drive keeps every version of the contract. | 1 | Fixed (40002) | +| `documentsKeepHistoryContractDefault` | `false` | The `documentsKeepHistory` of a document type that does not say. | 1 | Fixed (40002) | +| `documentsMutableContractDefault` | `true` | The `documentsMutable` of a document type that does not say. | 1 | Fixed (40002) | +| `documentsCanBeDeletedContractDefault` | `true` | The `canBeDeleted` of a document type that does not say. | 1 | Fixed (40002) | +| `requiresIdentityEncryptionBoundedKey`, `requiresIdentityDecryptionBoundedKey` | absent | The document type keywords of the same names, for keys bound to the whole contract. | 1 | Fixed (40002) | +| `sizedIntegerTypes` | `true` | Stores each integer in the smallest width its `minimum` and `maximum` allow, instead of 8 bytes. | 9 | May be turned on, not off (40002) | +| `moderation` | absent | Declares which of a banlist, a suspension list and a warning list the contract keeps, and who moderates: the owner, appointed identities or an elected team. Needed by `canBeDeletedByModerators` and the moderators' share of `actionFees`. See [Contract Moderation](data-model/contract-moderation.md). | 14 | The lists kept and an elected team are fixed; appointed moderators may change (40002) | + +## Limits + +The first three come from the meta-schema, the rest from protocol version 14's `SystemLimits`. A contract over a limit is refused at registration. + +| Limit | Value | Applies to | +|---|---|---| +| Properties per object | 100 | `properties`, at the top and in each nested object | +| Indexes per document type | 10 | `indices` | +| Properties per index | 10 | an index's `properties` | +| `max_field_value_size` | 5120 bytes | any one value a document stores (`DocumentFieldMaxSizeExceededError`, 10417) | +| `max_typed_array_items` | 1024 | a typed array's `maxItems` | +| `max_references_per_document` | 256 | references one document carries | +| `max_reference_operands` | 4 | operands in one `anyOf` or `allOf` of a reference | +| `max_reference_expression_depth` | 4 | nesting of reference expressions | +| `max_property_constraints` | 16 | rules in one `propertyConstraints` | +| `max_property_constraint_nodes` | 32 | nodes in one rule | +| `min_document_ttl_seconds`, `max_document_ttl_seconds` | 3600, 31536000 | `ttl` | +| `max_time_range_ttl_seconds` | 604800 | a `timeRange` index's `ttl` | From 8783489cb976779b3e5e2df42f9b8d009086cfa3 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 23:47:12 +0700 Subject: [PATCH 030/113] feat(sdk): propertyConstraints rules and pre-check in the Kotlin SDK and Android example app (#5066) Co-authored-by: Claude Opus 5.5 --- .../ui/contracts/CreateDocumentScreen.kt | 55 ++++ .../ui/contracts/DocumentTypeDetailsScreen.kt | 136 +++++++++ .../ui/contracts/PropertyConstraints.kt | 139 +++++++++ .../ui/contracts/PropertyConstraintsTest.kt | 226 ++++++++++++++ .../dashsdk/PropertyConstraintsFfiTest.kt | 116 ++++++++ .../dashsdk/ffi/QueriesNative.kt | 32 ++ .../queries/DocumentPropertyConstraints.kt | 277 ++++++++++++++++++ .../dashsdk/queries/PlatformQueries.kt | 66 +++++ .../DocumentPropertyConstraintsTest.kt | 249 ++++++++++++++++ packages/rs-unified-sdk-jni/src/queries.rs | 146 ++++++++- 10 files changed, 1436 insertions(+), 6 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt create mode 100644 packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/PropertyConstraintsFfiTest.kt create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/CreateDocumentScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/CreateDocumentScreen.kt index 137a699e8c7..4f8d025f79c 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/CreateDocumentScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/CreateDocumentScreen.kt @@ -1,5 +1,6 @@ package org.dashfoundation.example.ui.contracts +import android.util.Log import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -48,6 +49,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity +import org.dashfoundation.dashsdk.queries.PropertyConstraintViolation import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.di.LocalAppState import org.dashfoundation.example.ui.components.AccessiblePicker @@ -88,6 +90,13 @@ import org.dashfoundation.example.util.truncateMiddle * decodes them to native bytes. The confirmed canonical JSON the FFI * returns is shown on success — its `$id` is the on-chain document id a * DOC-01 browse (chain query) then surfaces. + * + * Before broadcasting, a document whose type declares `propertyConstraints` + * (protocol version 14) is judged by Rust against those rules + * (`sdk.contracts.checkPropertyConstraints`, the check consensus runs); a + * broken rule is shown by name, violation and reason and nothing is sent. A + * check that cannot run (no SDK, no stored contract serialization) is logged + * and the create goes ahead, as on iOS. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -99,6 +108,7 @@ fun CreateDocumentScreen( val container = LocalAppContainer.current val appState = LocalAppState.current val scope = rememberCoroutineScope() + val sdk by appState.sdk.collectAsStateWithLifecycle() val network by appState.currentNetwork.collectAsStateWithLifecycle() val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() @@ -129,6 +139,8 @@ fun CreateDocumentScreen( var isSubmitting by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } + // The broken propertyConstraints rule that stopped the last submit. + var constraintError by remember { mutableStateOf(null) } var createdDocId by remember { mutableStateOf(null) } var createdJson by remember { mutableStateOf(null) } @@ -283,6 +295,42 @@ fun CreateDocumentScreen( isSubmitting = true scope.launch { try { + // Protocol version 14: consensus refuses a document + // breaking one of its type's propertyConstraints rules + // (error 10422) and still charges for the transition, + // so judge it first with the same Rust check. + val activeSdk = sdk + val check: (suspend (ByteArray) -> PropertyConstraintViolation?)? = + if (activeSdk == null) { + null + } else { + { bytes -> + activeSdk.contracts.checkPropertyConstraints( + serializedContract = bytes, + documentType = typeName, + propertiesJson = propertiesJson, + ownerId = ownerId.identityId, + ) + } + } + when ( + val preCheck = propertyConstraintPreCheck( + schema, + contract?.binarySerialization, + check, + ) + ) { + is PropertyConstraintPreCheck.Broken -> { + constraintError = propertyConstraintViolationAlert(preCheck.violation) + return@launch + } + // Consensus judges the document either way. + is PropertyConstraintPreCheck.Skipped -> Log.w( + TAG, + "propertyConstraints pre-check could not run: ${preCheck.reason}", + ) + PropertyConstraintPreCheck.Passed -> Unit + } val json = mgr.documentTransactions.create( walletHandle = wallet.handle, ownerId = ownerId.identityId, @@ -304,8 +352,15 @@ fun CreateDocumentScreen( } ErrorAlertDialog(message = error, onDismiss = { error = null }) + ErrorAlertDialog( + message = constraintError, + title = PROPERTY_CONSTRAINT_BROKEN_TITLE, + onDismiss = { constraintError = null }, + ) } +private const val TAG = "CreateDocumentScreen" + /** * One schema-property editor, dispatched on the JSON-schema `type`. Shared * by the create form and the DOC-03 replace form ([DocumentActionsScreen]), diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt index 77dde84388f..6038ecc9636 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt @@ -1,6 +1,7 @@ package org.dashfoundation.example.ui.contracts import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -22,6 +23,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -29,12 +31,15 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import org.dashfoundation.dashsdk.queries.DocumentPropertyConstraint import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState import org.dashfoundation.example.navigation.CountDocuments import org.dashfoundation.example.navigation.Documents import org.dashfoundation.example.navigation.NewDocument @@ -52,6 +57,12 @@ import org.dashfoundation.example.util.hexToBytes * The "New Document" affordance broadcasts a real create state transition * via `CreateDocumentScreen` (port of iOS `CreateDocumentView`); the query * actions (browse / count / sum-average) live alongside it. + * + * The "Property Constraints" section (protocol version 14) lists the rules + * Rust reads from the contract's stored platform serialization + * (`sdk.contracts.propertyConstraints`), re-read when the SDK learns the + * network's protocol version: the rules are read at that version, and none + * exist below 14. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -61,6 +72,9 @@ fun DocumentTypeDetailsScreen( navController: NavHostController, ) { val container = LocalAppContainer.current + val appState = LocalAppState.current + val sdk by appState.sdk.collectAsStateWithLifecycle() + val protocolVersion by appState.platformProtocolVersion.collectAsStateWithLifecycle() val contractId = remember(contractIdHex) { contractIdHex.hexToBytes() } val contractFlow = remember(contractIdHex) { @@ -69,6 +83,9 @@ fun DocumentTypeDetailsScreen( val contract by contractFlow.collectAsStateWithLifecycle(initialValue = null) var expandedIndices by rememberSaveable { mutableStateOf(setOf()) } + var constraintsSection by remember(contractIdHex, typeName) { + mutableStateOf(PropertyConstraintsSection.Hidden) + } Scaffold( topBar = { @@ -92,6 +109,18 @@ fun DocumentTypeDetailsScreen( } val capabilities = documentTypeCapabilities(schema, contractConfig) + LaunchedEffect(sdk, protocolVersion, current.lastUpdated, typeName) { + val activeSdk = sdk + val read: (suspend (ByteArray) -> List)? = + if (activeSdk == null) { + null + } else { + { bytes -> activeSdk.contracts.propertyConstraints(bytes, typeName) } + } + constraintsSection = + loadPropertyConstraintsSection(schema, current.binarySerialization, read) + } + val properties = schema.objectField("properties") ?: JsonObject(emptyMap()) val indices = schema.arrayField("indices")?.mapNotNull { it as? JsonObject }.orEmpty() val required = schema.arrayField("required") @@ -195,6 +224,8 @@ fun DocumentTypeDetailsScreen( } } + PropertyConstraintsFormSection(constraintsSection) + if (indices.isNotEmpty()) { FormSection(title = "Indices (${indices.size})") { indices.sortedBy { it.stringField("name").orEmpty() }.forEach { index -> @@ -321,6 +352,111 @@ fun DocumentTypeDetailsScreen( } } +/** + * The protocol-version-14 `propertyConstraints` rules: named conditions every + * created or replaced document must meet, checked in name order. Rust reads + * them from the stored contract; this section only shows what it reports + * (← `propertyConstraintsSection` in DocumentTypeDetailsView.swift). + */ +@Composable +private fun PropertyConstraintsFormSection(section: PropertyConstraintsSection) { + when (section) { + PropertyConstraintsSection.Hidden -> Unit + + is PropertyConstraintsSection.Rules -> FormSection( + title = "Property Constraints (${section.rules.size})", + modifier = Modifier.testTag("documentType.propertyConstraints"), + ) { + section.rules.forEach { rule -> PropertyConstraintRow(rule) } + Text( + "Every created or replaced document must meet each rule, checked in name " + + "order. A document breaking one is refused (error 10422) and the fee " + + "is still charged.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + PropertyConstraintsSection.NotEnforced -> FormSection( + title = "Property Constraints", + modifier = Modifier.testTag("documentType.propertyConstraints"), + ) { + Text( + "The schema declares propertyConstraints, but the network's protocol " + + "version does not enforce them (they take effect at protocol version 14).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + is PropertyConstraintsSection.Unavailable -> FormSection( + title = "Property Constraints", + modifier = Modifier.testTag("documentType.propertyConstraints"), + ) { + Text( + section.reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +/** + * One `propertyConstraints` rule: its name, the rule as declared, what it + * reads, and whether an owner change is judged against it too + * (← `PropertyConstraintRowView` in DocumentTypeDetailsView.swift). + */ +@Composable +private fun PropertyConstraintRow(rule: DocumentPropertyConstraint) { + val prettyRule = remember(rule) { rule.prettyRuleJson } + val reads = remember(rule) { propertyConstraintReadsText(rule) } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .testTag("documentType.propertyConstraint.${rule.name}"), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(rule.name, style = MaterialTheme.typography.titleSmall) + if (rule.readsOwner) { + Text( + "\$ownerId", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + Text( + prettyRule, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + softWrap = false, + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = 4.dp), + ) + if (reads.isNotEmpty()) { + Text( + "Reads: $reads", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (rule.readsOwner) { + Text( + "Reads \$ownerId, the document's owner: transfers and purchases are judged " + + "against this rule too.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } +} + /** One schema property row (← `PropertyRowView` in DocumentTypeDetailsView.swift). */ @Composable private fun PropertyRow(name: String, property: JsonObject, isRequired: Boolean) { diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt new file mode 100644 index 00000000000..24b89091beb --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt @@ -0,0 +1,139 @@ +package org.dashfoundation.example.ui.contracts + +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.JsonObject +import org.dashfoundation.dashsdk.queries.DocumentPropertyConstraint +import org.dashfoundation.dashsdk.queries.PropertyConstraintViolation + +// Screen-side glue for a document type's `propertyConstraints` rules +// (protocol version 14): named conditions every created or replaced document +// must meet, refused by consensus with error 10422 while the fee is still +// charged. Rust parses and judges the rules (`sdk.contracts.propertyConstraints` +// and `sdk.contracts.checkPropertyConstraints`); nothing here evaluates one. +// Port of the SwiftExampleApp's `DocumentTypeDetailsView` section and +// `CreateDocumentView` pre-check. + +/** + * Whether [schema] carries the `propertyConstraints` keyword. Says nothing + * about the rules themselves: those are read by Rust. + */ +internal fun documentTypeDeclaresPropertyConstraints(schema: JsonObject?): Boolean = + schema?.get("propertyConstraints") != null + +/** What the document type screen shows for its rules. */ +internal sealed interface PropertyConstraintsSection { + /** Nothing: the schema declares no rules. */ + data object Hidden : PropertyConstraintsSection + + /** The rules Rust read, in name order (the order consensus checks them in). */ + data class Rules(val rules: List) : PropertyConstraintsSection + + /** + * The schema declares rules, but the SDK's protocol version enforces none + * (they take effect at protocol version 14). + */ + data object NotEnforced : PropertyConstraintsSection + + /** The rules could not be read, with why. */ + data class Unavailable(val reason: String) : PropertyConstraintsSection +} + +/** + * Read the rules of a document type with [schema] from its contract's stored + * platform serialization, [serializedContract], through [read] (null when no + * SDK is connected). + */ +internal suspend fun loadPropertyConstraintsSection( + schema: JsonObject?, + serializedContract: ByteArray?, + read: (suspend (serializedContract: ByteArray) -> List)?, +): PropertyConstraintsSection { + if (!documentTypeDeclaresPropertyConstraints(schema)) return PropertyConstraintsSection.Hidden + if (read == null) { + return PropertyConstraintsSection.Unavailable( + "Connect to a network to read the property constraints.", + ) + } + if (serializedContract == null || serializedContract.isEmpty()) { + return PropertyConstraintsSection.Unavailable( + "The data contract has no stored serialization; download it again to read " + + "the property constraints.", + ) + } + return try { + val rules = read(serializedContract) + if (rules.isEmpty()) { + PropertyConstraintsSection.NotEnforced + } else { + PropertyConstraintsSection.Rules(rules) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + PropertyConstraintsSection.Unavailable("Could not read the property constraints: ${e.message}") + } catch (_: UnsatisfiedLinkError) { + PropertyConstraintsSection.Unavailable(NATIVE_LIBRARY_PREDATES_RULES) + } +} + +/** The outcome of checking a document's rules before it is broadcast. */ +internal sealed interface PropertyConstraintPreCheck { + /** The document meets every rule, or its type declares none: send it. */ + data object Passed : PropertyConstraintPreCheck + + /** The document breaks [violation]'s rule: do not send it. */ + data class Broken(val violation: PropertyConstraintViolation) : PropertyConstraintPreCheck + + /** + * The check could not run, for [reason]. The create goes ahead: consensus + * judges the document either way. + */ + data class Skipped(val reason: String) : PropertyConstraintPreCheck +} + +/** + * Judge a document to create, of a type with [schema], against its rules + * through [check] (null when no SDK is connected), reading the contract's + * stored platform serialization [serializedContract]. + */ +internal suspend fun propertyConstraintPreCheck( + schema: JsonObject?, + serializedContract: ByteArray?, + check: (suspend (serializedContract: ByteArray) -> PropertyConstraintViolation?)?, +): PropertyConstraintPreCheck { + if (!documentTypeDeclaresPropertyConstraints(schema)) return PropertyConstraintPreCheck.Passed + if (check == null) return PropertyConstraintPreCheck.Skipped("no SDK is connected") + if (serializedContract == null || serializedContract.isEmpty()) { + return PropertyConstraintPreCheck.Skipped("the data contract has no stored serialization") + } + return try { + check(serializedContract) + ?.let { PropertyConstraintPreCheck.Broken(it) } + ?: PropertyConstraintPreCheck.Passed + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + PropertyConstraintPreCheck.Skipped(e.message ?: e.toString()) + } catch (_: UnsatisfiedLinkError) { + PropertyConstraintPreCheck.Skipped(NATIVE_LIBRARY_PREDATES_RULES) + } +} + +/** + * Why nothing could be read or checked when the bundled native library was + * built before the two `propertyConstraints` exports: a stale `.so` must not + * crash the screen or block a create. + */ +internal const val NATIVE_LIBRARY_PREDATES_RULES = + "The native library predates property constraints; rebuild it to read them." + +/** Each property [rule] reads with how it reads it, repeats dropped: `price (value), fee (value)`. */ +internal fun propertyConstraintReadsText(rule: DocumentPropertyConstraint): String = + rule.reads.distinct().joinToString(", ") { "${it.path} (${it.kind.name})" } + +/** Title of the alert a broken rule raises instead of a broadcast. */ +internal const val PROPERTY_CONSTRAINT_BROKEN_TITLE = "Not sent: a property constraint is broken" + +/** Body of the alert a broken rule raises: the rule, the violation and the reason. */ +internal fun propertyConstraintViolationAlert(violation: PropertyConstraintViolation): String = + "Rule: ${violation.rule}\nViolation: ${violation.violation.name}\nReason: ${violation.message}" diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt new file mode 100644 index 00000000000..d801d71618f --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt @@ -0,0 +1,226 @@ +package org.dashfoundation.example.ui.contracts + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.queries.DocumentPropertyConstraint +import org.dashfoundation.dashsdk.queries.PropertyConstraintRead +import org.dashfoundation.dashsdk.queries.PropertyConstraintViolation +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** + * Pins the screen-side `propertyConstraints` glue (protocol version 14) behind + * the DocumentTypeDetailsScreen section and the CreateDocumentScreen + * pre-check. Rust reads and judges the rules; these tests stand in for it + * with plain lambdas, so they pin only when the app asks, what it does with + * the answer, and that a check which cannot run never blocks a create + * (consensus judges the document either way). Pure JVM: no native library. + */ +class PropertyConstraintsTest { + + private val schema = buildJsonObject { + put("type", "object") + putJsonObject("propertyConstraints") { + putJsonObject("perUnitFee") { put("present", "fee") } + } + } + + private val plainSchema = buildJsonObject { put("type", "object") } + + private val stored = byteArrayOf(1, 2, 3) + + private val rule = DocumentPropertyConstraint( + name = "sellerIsOwner", + ruleJson = """{"anyOf":[{"absent":"sellerId"},{"equal":["sellerId","${'$'}ownerId"]}]}""", + reads = listOf( + PropertyConstraintRead("sellerId", PropertyConstraintRead.Kind.Presence), + PropertyConstraintRead("sellerId", PropertyConstraintRead.Kind.Identifier), + PropertyConstraintRead("sellerId", PropertyConstraintRead.Kind.Presence), + ), + readsOwner = true, + ) + + private val violation = PropertyConstraintViolation( + rule = "perUnitFee", + violation = PropertyConstraintViolation.Kind.DivisionByZero, + message = "it divides by zero", + ) + + @Test + fun `should see the keyword only where the schema declares it`() { + assertTrue(documentTypeDeclaresPropertyConstraints(schema)) + assertFalse(documentTypeDeclaresPropertyConstraints(plainSchema)) + assertFalse(documentTypeDeclaresPropertyConstraints(null)) + } + + // Document type screen + + @Test + fun `should hide the section without asking Rust when the schema declares no rules`() = runTest { + val section = loadPropertyConstraintsSection(plainSchema, stored) { + fail("a schema without the keyword must not be read") + emptyList() + } + + assertEquals(PropertyConstraintsSection.Hidden, section) + } + + @Test + fun `should list the rules Rust reads from the stored contract`() = runTest { + var readBytes: ByteArray? = null + val section = loadPropertyConstraintsSection(schema, stored) { bytes -> + readBytes = bytes + listOf(rule) + } + + assertEquals(PropertyConstraintsSection.Rules(listOf(rule)), section) + assertArrayEquals(stored, readBytes) + } + + /** Below protocol version 14 Rust reports no rules for a type declaring some. */ + @Test + fun `should say the rules are not enforced when Rust reads none`() = runTest { + val section = loadPropertyConstraintsSection(schema, stored) { emptyList() } + + assertEquals(PropertyConstraintsSection.NotEnforced, section) + } + + @Test + fun `should say why the rules cannot be read`() = runTest { + val noSdk = loadPropertyConstraintsSection(schema, stored, read = null) + assertEquals( + PropertyConstraintsSection.Unavailable("Connect to a network to read the property constraints."), + noSdk, + ) + + for (missing in listOf(null, ByteArray(0))) { + val noBytes = loadPropertyConstraintsSection(schema, missing) { + fail("nothing can be read without the stored serialization") + emptyList() + } + assertTrue( + "$noBytes", + (noBytes as PropertyConstraintsSection.Unavailable).reason.contains("download it again"), + ) + } + + val failed = loadPropertyConstraintsSection(schema, stored) { + throw DashSdkError.SerializationError("Failed to deserialize contract") + } + assertEquals( + PropertyConstraintsSection.Unavailable( + "Could not read the property constraints: Failed to deserialize contract", + ), + failed, + ) + } + + // Create pre-check + + @Test + fun `should pass a document whose type declares no rules without asking Rust`() = runTest { + val preCheck = propertyConstraintPreCheck(plainSchema, stored) { + fail("a schema without the keyword must not be checked") + null + } + + assertEquals(PropertyConstraintPreCheck.Passed, preCheck) + } + + @Test + fun `should stop a document that breaks a rule`() = runTest { + var checkedBytes: ByteArray? = null + val preCheck = propertyConstraintPreCheck(schema, stored) { bytes -> + checkedBytes = bytes + violation + } + + assertEquals(PropertyConstraintPreCheck.Broken(violation), preCheck) + assertArrayEquals(stored, checkedBytes) + } + + @Test + fun `should pass a document that meets every rule`() = runTest { + assertEquals( + PropertyConstraintPreCheck.Passed, + propertyConstraintPreCheck(schema, stored) { null }, + ) + } + + @Test + fun `should let the create proceed when the check cannot run`() = runTest { + assertEquals( + PropertyConstraintPreCheck.Skipped("no SDK is connected"), + propertyConstraintPreCheck(schema, stored, check = null), + ) + for (missing in listOf(null, ByteArray(0))) { + assertEquals( + PropertyConstraintPreCheck.Skipped("the data contract has no stored serialization"), + propertyConstraintPreCheck(schema, missing) { + fail("nothing can be checked without the stored serialization") + null + }, + ) + } + assertEquals( + PropertyConstraintPreCheck.Skipped("Document type 'offer' not found in the data contract"), + propertyConstraintPreCheck(schema, stored) { + throw DashSdkError.NotFound("Document type 'offer' not found in the data contract") + }, + ) + } + + /** An app bundling a native library built before the two exports must neither crash nor block. */ + @Test + fun `should neither crash nor block on a native library without the exports`() = runTest { + val missing = UnsatisfiedLinkError("dataContractCheckPropertyConstraints") + + assertEquals( + PropertyConstraintPreCheck.Skipped(NATIVE_LIBRARY_PREDATES_RULES), + propertyConstraintPreCheck(schema, stored) { throw missing }, + ) + assertEquals( + PropertyConstraintsSection.Unavailable(NATIVE_LIBRARY_PREDATES_RULES), + loadPropertyConstraintsSection(schema, stored) { throw missing }, + ) + } + + @Test + fun `should not swallow a cancellation`() = runTest { + try { + propertyConstraintPreCheck(schema, stored) { throw CancellationException("left the screen") } + fail("the cancellation must propagate") + } catch (e: CancellationException) { + assertEquals("left the screen", e.message) + } + } + + // Display + + @Test + fun `should list each read once with its kind`() { + assertEquals("sellerId (presence), sellerId (identifier)", propertyConstraintReadsText(rule)) + } + + @Test + fun `should name the rule the violation and the reason in the alert`() { + assertEquals( + "Rule: perUnitFee\nViolation: DivisionByZero\nReason: it divides by zero", + propertyConstraintViolationAlert(violation), + ) + assertEquals( + "Rule: r\nViolation: Later\nReason: m", + propertyConstraintViolationAlert( + PropertyConstraintViolation("r", PropertyConstraintViolation.Kind.Other("Later"), "m"), + ), + ) + } +} diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/PropertyConstraintsFfiTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/PropertyConstraintsFfiTest.kt new file mode 100644 index 00000000000..01b60d620f2 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/PropertyConstraintsFfiTest.kt @@ -0,0 +1,116 @@ +package org.dashfoundation.dashsdk + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.dashfoundation.dashsdk.ffi.DashSDKException +import org.dashfoundation.dashsdk.ffi.NativeLoader +import org.dashfoundation.dashsdk.ffi.QueriesNative +import org.dashfoundation.dashsdk.ffi.SdkNative +import org.dashfoundation.dashsdk.queries.DocumentPropertyConstraint +import org.dashfoundation.dashsdk.queries.PropertyConstraintViolation +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Round trips of the two `propertyConstraints` JNI exports through + * `rs-sdk-ffi` on a device: the marshalling of the contract bytes, the + * strings and the owner id, and the error codes the Kotlin side maps + * (`DashSdkError.fromNative`). Rust's own tests cover the rules; the fixture + * declares none, so the answers are `[]` and `null` at any protocol version. + * No network access: `createTrusted` only builds the client. Kotlin + * counterpart of the FFI round trips in the Swift SDK's + * `DocumentPropertyConstraintsTests`. + */ +@RunWith(AndroidJUnit4::class) +class PropertyConstraintsFfiTest { + + /** + * The platform serialization of a contract created at protocol version + * 13, owned by `[7; 32]`, declaring one `note` type with a string + * `message` and no rules (the Swift test's fixture, generated by + * rs-sdk-ffi's `serialize_to_bytes_with_platform_version`). + */ + private val noteContract = ( + "013ac40651a4bcb91c3f5c1e458a1c95e48dc2c8259003dd580cab11a5f02ed4850100000000010100000101070707070707" + + "07070707070707070707070707070707070707070707070707070001046e6f7465160312047479706512066f626a65637412" + + "0a70726f70657274696573160112076d65737361676516031204747970651206737472696e6712096d61784c656e67746805" + + "801208706f736974696f6e050012146164646974696f6e616c50726f70657274696573130000000000000000000000" + ).chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private val ownerId = ByteArray(32) { 7 } + + private var handle = 0L + + @Before + fun createSdk() { + NativeLoader.ensureLoaded() + handle = SdkNative.createTrusted( + network = 1, + dapiAddresses = null, + quorumUrl = null, + skipAssetLockProofVerification = false, + requestRetryCount = 3, + requestTimeoutMs = 30_000, + platformVersion = 0, + ) + assertNotEquals("createTrusted must return a live handle", 0L, handle) + } + + @After + fun destroySdk() { + SdkNative.destroy(handle) + } + + @Test + fun shouldReadNoRulesAndNoViolationForATypeDeclaringNone() { + val rules = QueriesNative.dataContractGetPropertyConstraints(handle, noteContract, "note") + assertEquals(emptyList(), DocumentPropertyConstraint.listFromJson(rules!!)) + + val verdict = QueriesNative.dataContractCheckPropertyConstraints( + handle, + noteContract, + "note", + """{"message":"hi"}""", + ownerId, + ) + assertEquals("null", verdict) + assertNull(PropertyConstraintViolation.fromJson(verdict!!)) + } + + @Test + fun shouldKeepTheNativeErrorCodes() { + // DashSDKErrorCode: 1 InvalidParameter, 4 SerializationError, 7 NotFound + assertCode(7) { QueriesNative.dataContractGetPropertyConstraints(handle, noteContract, "letter") } + assertCode(4) { + QueriesNative.dataContractGetPropertyConstraints(handle, byteArrayOf(-1, 0, 19), "note") + } + assertCode(1) { QueriesNative.dataContractGetPropertyConstraints(handle, ByteArray(0), "note") } + assertCode(1) { + QueriesNative.dataContractCheckPropertyConstraints(handle, noteContract, "note", "[1]", ownerId) + } + } + + /** The C function reads 32 bytes behind the owner pointer, so the JNI refuses a shorter id. */ + @Test + fun shouldRefuseAnOwnerIdThatIsNot32Bytes() { + assertCode(1) { + QueriesNative.dataContractCheckPropertyConstraints( + handle, + noteContract, + "note", + "{}", + ByteArray(20) { 7 }, + ) + } + } + + private fun assertCode(code: Int, call: () -> Unit) { + val error = assertThrows(DashSDKException::class.java) { call() } + assertEquals(error.message, code, error.code) + } +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt index 55676165e54..7c1513a4ed6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt @@ -72,6 +72,38 @@ internal object QueriesNative { serializedContracts: Array, ) + /** + * The `propertyConstraints` rules (protocol version 14) of [documentType] + * as a JSON array in name order, each + * `{"name", "rule", "reads": [{"path", "kind"}], "readsOwner"}`. + * [serializedContract] is the contract's platform serialization (what + * [dataContractFetchWithSerialization] returns), read by Rust at the SDK's + * protocol version; no network call. Throws on error (unknown document + * type, bytes that are not a contract, empty input). + */ + external fun dataContractGetPropertyConstraints( + sdk: Long, + serializedContract: ByteArray, + documentType: String, + ): String? + + /** + * The first `propertyConstraints` rule a document to create would break, + * as `{"rule", "violation", "message"}`, or the JSON text `null` when it + * meets every rule. [propertiesJson] is what the create would send and + * [ownerId] the 32-byte owner `$ownerId` reads; [serializedContract] as + * for [dataContractGetPropertyConstraints]. No network call. Throws on + * error (as above, plus an owner id that is not 32 bytes or properties + * that are not a JSON object). + */ + external fun dataContractCheckPropertyConstraints( + sdk: Long, + serializedContract: ByteArray, + documentType: String, + propertiesJson: String, + ownerId: ByteArray, + ): String? + /** JSON array of documents. whereJson/orderByJson may be null. */ external fun documentSearch( sdk: Long, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt new file mode 100644 index 00000000000..d8e87f6dc81 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt @@ -0,0 +1,277 @@ +package org.dashfoundation.dashsdk.queries + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import org.dashfoundation.dashsdk.errors.DashSdkError + +/** + * A rule of a document type's `propertyConstraints` (protocol version 14): a + * named condition every created or replaced document's properties must meet. + * Consensus checks every rule, in name order, and refuses a document breaking + * one with `DocumentPropertyConstraintViolatedError` (code 10422); a refused + * state transition is still paid for. + * + * Rust parses the rules and reports them + * (`dash_sdk_data_contract_get_property_constraints`, through + * [Contracts.propertyConstraints]); this type only carries what it reports. + * The fields mirror wasm-dpp2's `DocumentPropertyConstraint` key for key, and + * the Swift SDK's `DocumentPropertyConstraint` + * (`SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift`). + */ +data class DocumentPropertyConstraint( + /** The rule's name, its key in `propertyConstraints`. */ + val name: String, + /** + * The rule exactly as the document type's schema declares it, as compact + * JSON text with sorted keys (every operator object has a single key, so + * sorting changes nothing a reader would notice). + */ + val ruleJson: String, + /** + * Every property the rule reads, in declared order, a property read twice + * listed twice. `$ownerId` is no property and is not listed: see + * [readsOwner]. + */ + val reads: List, + /** + * Whether the rule compares the document's owner, `$ownerId`: then a + * transfer or a purchase, which changes the owner, is judged against it + * too. + */ + val readsOwner: Boolean, +) { + /** [ruleJson] indented for display, or [ruleJson] itself should it not parse back. */ + val prettyRuleJson: String + get() = try { + PropertyConstraintJson.pretty(Json.parseToJsonElement(ruleJson)) + } catch (_: SerializationException) { + ruleJson + } + + companion object { + /** + * Decode the JSON array `dash_sdk_data_contract_get_property_constraints` + * returns, keeping its order (name order). + * + * @throws DashSdkError.SerializationError for text that is not such an array. + */ + fun listFromJson(json: String): List { + val entries = PropertyConstraintJson.parse(json) as? JsonArray + ?: throw DashSdkError.SerializationError( + "propertyConstraints rules are not a JSON array", + ) + return entries.map { entry -> + val rule = entry as? JsonObject + val name = rule?.get("name")?.jsonStringOrNull() + val declaration = rule?.get("rule") + val reads = rule?.get("reads") as? JsonArray + val readsOwner = rule?.get("readsOwner")?.jsonBooleanOrNull() + if (name == null || declaration == null || reads == null || readsOwner == null) { + throw DashSdkError.SerializationError("Malformed propertyConstraints rule: $entry") + } + DocumentPropertyConstraint( + name = name, + ruleJson = PropertyConstraintJson.compact(declaration), + reads = reads.map(PropertyConstraintRead::fromJson), + readsOwner = readsOwner, + ) + } + } + } +} + +/** A property a `propertyConstraints` rule reads, and how it reads it. */ +data class PropertyConstraintRead( + /** The property's dotted path. */ + val path: String, + val kind: Kind, +) { + /** How a rule reads a property; the names are wasm-dpp2's `PropertyConstraintReadKind`. */ + sealed interface Kind { + /** The kind's name, as Rust reports it. */ + val name: String + + /** By its value, as an integer operand: an integer or boolean property. */ + data object Value : Kind { + override val name: String get() = "value" + } + + /** Only whether the document holds it, in `present` or `absent`. */ + data object Presence : Kind { + override val name: String get() = "presence" + } + + /** By its value, compared with strings: a string property. */ + data object Text : Kind { + override val name: String get() = "text" + } + + /** By its value, compared with identifiers: an identifier property. */ + data object Identifier : Kind { + override val name: String get() = "identifier" + } + + /** A kind this build does not know, by its name. */ + data class Other(override val name: String) : Kind + + companion object { + /** The kind named [name], or [Other] for a name this build does not know. */ + fun fromName(name: String): Kind = when (name) { + Value.name -> Value + Presence.name -> Presence + Text.name -> Text + Identifier.name -> Identifier + else -> Other(name) + } + } + } + + internal companion object { + fun fromJson(entry: JsonElement): PropertyConstraintRead { + val read = entry as? JsonObject + val path = read?.get("path")?.jsonStringOrNull() + val kind = read?.get("kind")?.jsonStringOrNull() + if (path == null || kind == null) { + throw DashSdkError.SerializationError("Malformed propertyConstraints read: $entry") + } + return PropertyConstraintRead(path, Kind.fromName(kind)) + } + } +} + +/** + * The first `propertyConstraints` rule a document breaks, as consensus would + * report it in `DocumentPropertyConstraintViolatedError` (code 10422). + * + * Rust judges the document (`dash_sdk_data_contract_check_property_constraints`, + * through [Contracts.checkPropertyConstraints]) with the check consensus runs; + * this type only carries the verdict. The fields mirror wasm-dpp2's + * `DocumentPropertyConstraintViolation` and the Swift SDK's + * `PropertyConstraintViolation`. + */ +data class PropertyConstraintViolation( + /** The broken rule's name. */ + val rule: String, + val violation: Kind, + /** A readable reason, as in the consensus error's message. */ + val message: String, +) { + /** Why the rule is broken; the names are wasm-dpp2's `PropertyConstraintViolationKind`. */ + sealed interface Kind { + /** The reason's name, as Rust reports it. */ + val name: String + + /** The rule evaluates without a fault but does not hold. */ + data object NotMet : Kind { + override val name: String get() = "NotMet" + } + + /** A value the rule reads or computes does not fit a 128-bit signed integer. */ + data object Overflow : Kind { + override val name: String get() = "Overflow" + } + + /** A `divide` or `modulo` by zero. */ + data object DivisionByZero : Kind { + override val name: String get() = "DivisionByZero" + } + + /** A `power` with a negative exponent. */ + data object NegativeExponent : Kind { + override val name: String get() = "NegativeExponent" + } + + /** A value the rule reads is not an integer. */ + data object NotAnInteger : Kind { + override val name: String get() = "NotAnInteger" + } + + /** A reason this build does not know, by its name. */ + data class Other(override val name: String) : Kind + + companion object { + /** The reason named [name], or [Other] for a name this build does not know. */ + fun fromName(name: String): Kind = when (name) { + NotMet.name -> NotMet + Overflow.name -> Overflow + DivisionByZero.name -> DivisionByZero + NegativeExponent.name -> NegativeExponent + NotAnInteger.name -> NotAnInteger + else -> Other(name) + } + } + } + + /** One sentence naming the rule, the reason and the message (Swift's `errorDescription`). */ + val description: String + get() = "The document breaks the propertyConstraints rule \"$rule\" " + + "(${violation.name}): $message." + + companion object { + /** + * Decode the JSON `dash_sdk_data_contract_check_property_constraints` + * returns: `null` for JSON `null`, when the document meets every rule. + * + * @throws DashSdkError.SerializationError for text that is neither + * `null` nor a violation object. + */ + fun fromJson(json: String): PropertyConstraintViolation? { + val value = PropertyConstraintJson.parse(json) + if (value is JsonNull) return null + val violation = value as? JsonObject + val rule = violation?.get("rule")?.jsonStringOrNull() + val kind = violation?.get("violation")?.jsonStringOrNull() + val message = violation?.get("message")?.jsonStringOrNull() + if (rule == null || kind == null || message == null) { + throw DashSdkError.SerializationError("Malformed propertyConstraints violation: $json") + } + return PropertyConstraintViolation(rule, Kind.fromName(kind), message) + } + } +} + +/** JSON readers for the two `propertyConstraints` payloads. */ +internal object PropertyConstraintJson { + private val prettyPrinter = Json { prettyPrint = true } + + /** The JSON value [text] holds, [JsonNull] for `null`. */ + fun parse(text: String): JsonElement = try { + Json.parseToJsonElement(text) + } catch (e: SerializationException) { + throw DashSdkError.SerializationError("Not JSON: $text", e) + } + + /** [value] as compact JSON text with sorted keys. */ + fun compact(value: JsonElement): String = sortedKeys(value).toString() + + /** [value] as indented JSON text with sorted keys. */ + fun pretty(value: JsonElement): String = + prettyPrinter.encodeToString(JsonElement.serializer(), sortedKeys(value)) + + private fun sortedKeys(value: JsonElement): JsonElement = when (value) { + is JsonObject -> JsonObject( + value.entries + .sortedBy { it.key } + .associateTo(LinkedHashMap()) { (key, element) -> key to sortedKeys(element) }, + ) + is JsonArray -> JsonArray(value.map(::sortedKeys)) + is JsonPrimitive -> value + } +} + +/** The string a JSON string holds; `null` for any other JSON value. */ +private fun JsonElement.jsonStringOrNull(): String? = + (this as? JsonPrimitive)?.takeIf { it.isString }?.content + +/** + * The boolean a JSON `true` or `false` holds; `null` for any other JSON value, + * the string `"true"` included. + */ +private fun JsonElement.jsonBooleanOrNull(): Boolean? = + (this as? JsonPrimitive)?.takeIf { !it.isString }?.booleanOrNull diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt index e019bd7323c..621364b45ea 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import org.dashfoundation.dashsdk.Sdk +import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.NativeCleaner import org.dashfoundation.dashsdk.ffi.QueriesNative @@ -658,6 +659,71 @@ class Contracts internal constructor(private val sdk: Sdk) { ) } } + + /** + * The `propertyConstraints` rules (protocol version 14) of [documentType], + * in name order (the order consensus checks them in). Empty for a type + * declaring none, and for every type while this SDK's protocol version is + * below 14. Port of Swift's `SDK.documentPropertyConstraints`. + * + * [serializedContract] is the contract's platform serialization, the bytes + * kept beside a fetched contract ([ContractWithSerialization.binarySerialization], + * `DataContractEntity.binarySerialization`). Rust reads it at this SDK's + * protocol version; no network call. + * + * @throws DashSdkError.NotFound for a document type the contract does not declare. + * @throws DashSdkError.SerializationError for bytes that are not a contract. + * @throws DashSdkError.InvalidParameter for empty contract bytes. + */ + suspend fun propertyConstraints( + serializedContract: ByteArray, + documentType: String, + ): List = sdk.queryGate.op { + val json = mapNativeErrors { + QueriesNative.dataContractGetPropertyConstraints( + sdk.handle, + serializedContract, + documentType, + ) + } ?: throw DashSdkError.InternalError("No propertyConstraints rules returned") + DocumentPropertyConstraint.listFromJson(json) + } + + /** + * The first `propertyConstraints` rule a document to create would break, + * or `null` when it meets them all (always so while this SDK's protocol + * version is below 14). Port of Swift's + * `SDK.checkDocumentPropertyConstraints`. + * + * [propertiesJson] is the properties JSON the document would be created + * with (what `DocumentTransactions.create` takes) and [ownerId] the 32-byte + * identity that would own it, which `$ownerId` reads. Rust builds the + * document the create path builds and judges it with the check consensus + * runs; nothing but the rules is checked. [serializedContract] is as for + * [propertyConstraints]; no network call. + * + * @throws DashSdkError.InvalidParameter for empty contract bytes, an owner + * id that is not 32 bytes, or properties that are not a JSON object. + * @throws DashSdkError.NotFound for a document type the contract does not declare. + * @throws DashSdkError.SerializationError for bytes that are not a contract. + */ + suspend fun checkPropertyConstraints( + serializedContract: ByteArray, + documentType: String, + propertiesJson: String, + ownerId: ByteArray, + ): PropertyConstraintViolation? = sdk.queryGate.op { + val json = mapNativeErrors { + QueriesNative.dataContractCheckPropertyConstraints( + sdk.handle, + serializedContract, + documentType, + propertiesJson, + ownerId, + ) + } ?: throw DashSdkError.InternalError("No propertyConstraints verdict returned") + PropertyConstraintViolation.fromJson(json) + } } /** diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt new file mode 100644 index 00000000000..d053d150ab5 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt @@ -0,0 +1,249 @@ +package org.dashfoundation.dashsdk.queries + +import kotlinx.serialization.json.Json +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Coverage for the decoding of what the two protocol-version-14 + * `propertyConstraints` natives return + * (`dash_sdk_data_contract_get_property_constraints` and + * `dash_sdk_data_contract_check_property_constraints`). + * + * Rust parses and evaluates the rules (rs-sdk-ffi's own tests cover every + * rule family and violation); Kotlin only decodes, so these tests pin the + * JSON shapes, which match wasm-dpp2's and the Swift SDK's key for key + * (`DocumentPropertyConstraintsTests.swift` holds the same cases). Pure JVM: + * no native library is loaded. + */ +class DocumentPropertyConstraintsTest { + + /** + * The rules of an `offer` type as the FFI reports them (rs-sdk-ffi's + * `should_list_every_rule_in_name_order_with_what_it_reads`), abridged to + * three rules. + */ + private val rulesJson = """ + [ + { + "name": "closedNeedsClosedAt", + "readsOwner": false, + "reads": [ + { "kind": "text", "path": "status" }, + { "kind": "presence", "path": "closedAt" } + ], + "rule": { + "anyOf": [ + { "notEqual": ["status", { "const": "closed" }] }, + { "present": "closedAt" } + ] + } + }, + { + "name": "perUnitFee", + "readsOwner": false, + "reads": [ + { "kind": "value", "path": "price" }, + { "kind": "value", "path": "fee" } + ], + "rule": { "greaterThanOrEqual": [{ "divide": ["price", "fee"] }, 1] } + }, + { + "name": "sellerIsOwner", + "readsOwner": true, + "reads": [ + { "kind": "presence", "path": "sellerId" }, + { "kind": "identifier", "path": "sellerId" } + ], + "rule": { + "anyOf": [{ "absent": "sellerId" }, { "equal": ["sellerId", "${'$'}ownerId"] }] + } + } + ] + """.trimIndent() + + // Rules + + @Test + fun `should decode the rules in order with what they read`() { + val rules = DocumentPropertyConstraint.listFromJson(rulesJson) + + assertEquals(listOf("closedNeedsClosedAt", "perUnitFee", "sellerIsOwner"), rules.map { it.name }) + assertEquals( + listOf( + PropertyConstraintRead("status", PropertyConstraintRead.Kind.Text), + PropertyConstraintRead("closedAt", PropertyConstraintRead.Kind.Presence), + ), + rules[0].reads, + ) + assertEquals( + listOf(PropertyConstraintRead.Kind.Value, PropertyConstraintRead.Kind.Value), + rules[1].reads.map { it.kind }, + ) + assertEquals( + listOf(PropertyConstraintRead.Kind.Presence, PropertyConstraintRead.Kind.Identifier), + rules[2].reads.map { it.kind }, + ) + assertEquals(listOf(false, false, true), rules.map { it.readsOwner }) + } + + /** + * The rule is kept as the JSON the schema declares, compact with sorted + * keys: array order, and so operand order, is untouched. + */ + @Test + fun `should keep each rule as its declared JSON`() { + val rules = DocumentPropertyConstraint.listFromJson(rulesJson) + + assertEquals( + """{"anyOf":[{"notEqual":["status",{"const":"closed"}]},{"present":"closedAt"}]}""", + rules[0].ruleJson, + ) + assertEquals("""{"greaterThanOrEqual":[{"divide":["price","fee"]},1]}""", rules[1].ruleJson) + assertEquals( + """{"anyOf":[{"absent":"sellerId"},{"equal":["sellerId","${'$'}ownerId"]}]}""", + rules[2].ruleJson, + ) + } + + @Test + fun `should sort the keys of a rule declaring several`() { + val rules = DocumentPropertyConstraint.listFromJson( + """[{"name":"r","readsOwner":false,"reads":[],"rule":{"b":[2],"a":{"d":1,"c":0}}}]""", + ) + + assertEquals("""{"a":{"c":0,"d":1},"b":[2]}""", rules.single().ruleJson) + } + + @Test + fun `should indent the same rule for display`() { + val rule = DocumentPropertyConstraint.listFromJson(rulesJson).first() + + val pretty = rule.prettyRuleJson + assertTrue(pretty, pretty.contains("\n")) + assertEquals(Json.parseToJsonElement(rule.ruleJson), Json.parseToJsonElement(pretty)) + } + + @Test + fun `should show rule text that does not parse as it is`() { + val rule = DocumentPropertyConstraint("r", "not json", emptyList(), readsOwner = false) + + assertEquals("not json", rule.prettyRuleJson) + } + + @Test + fun `should decode no rules to an empty list`() { + assertEquals(emptyList(), DocumentPropertyConstraint.listFromJson("[]")) + } + + /** A kind added by a later protocol version is kept by name rather than failing the whole list. */ + @Test + fun `should keep an unknown read kind by its name`() { + val rules = DocumentPropertyConstraint.listFromJson( + """ + [{ "name": "r", "rule": { "present": "a" }, "readsOwner": false, + "reads": [{ "path": "a", "kind": "somethingNew" }] }] + """.trimIndent(), + ) + + val kind = rules.single().reads.single().kind + assertEquals(PropertyConstraintRead.Kind.Other("somethingNew"), kind) + assertEquals("somethingNew", kind.name) + } + + @Test + fun `should round trip every read kind name`() { + val names = listOf("value", "presence", "text", "identifier") + val kinds = listOf( + PropertyConstraintRead.Kind.Value, + PropertyConstraintRead.Kind.Presence, + PropertyConstraintRead.Kind.Text, + PropertyConstraintRead.Kind.Identifier, + ) + + assertEquals(kinds, names.map(PropertyConstraintRead.Kind::fromName)) + assertEquals(names, kinds.map { it.name }) + } + + @Test + fun `should refuse malformed rules`() { + val malformed = listOf( + "not json", + """{"name": "r"}""", + """[{"name": "r", "rule": {"present": "a"}, "reads": []}]""", + // A number is not a boolean + """[{"name": "r", "rule": {"present": "a"}, "reads": [], "readsOwner": 1}]""", + // Nor is a string + """[{"name": "r", "rule": {"present": "a"}, "reads": [], "readsOwner": "true"}]""", + // A name must be a string + """[{"name": 7, "rule": {"present": "a"}, "reads": [], "readsOwner": false}]""", + """[{"name": "r", "rule": {"present": "a"}, "reads": [{"path": "a"}], "readsOwner": false}]""", + """[7]""", + ) + for (json in malformed) { + assertThrows(json, DashSdkError.SerializationError::class.java) { + DocumentPropertyConstraint.listFromJson(json) + } + } + } + + // Violations + + @Test + fun `should decode a violation`() { + val violation = PropertyConstraintViolation.fromJson( + """{ "rule": "perUnitFee", "violation": "DivisionByZero", "message": "it divides by zero" }""", + ) + + assertEquals( + PropertyConstraintViolation( + rule = "perUnitFee", + violation = PropertyConstraintViolation.Kind.DivisionByZero, + message = "it divides by zero", + ), + violation, + ) + assertEquals( + "The document breaks the propertyConstraints rule \"perUnitFee\" (DivisionByZero): " + + "it divides by zero.", + violation?.description, + ) + } + + @Test + fun `should round trip every violation name`() { + val names = listOf("NotMet", "Overflow", "DivisionByZero", "NegativeExponent", "NotAnInteger") + val kinds = listOf( + PropertyConstraintViolation.Kind.NotMet, + PropertyConstraintViolation.Kind.Overflow, + PropertyConstraintViolation.Kind.DivisionByZero, + PropertyConstraintViolation.Kind.NegativeExponent, + PropertyConstraintViolation.Kind.NotAnInteger, + ) + + assertEquals(kinds, names.map(PropertyConstraintViolation.Kind::fromName)) + assertEquals(names, kinds.map { it.name }) + assertEquals( + PropertyConstraintViolation.Kind.Other("Later"), + PropertyConstraintViolation.Kind.fromName("Later"), + ) + } + + @Test + fun `should read null as every rule holding`() { + assertNull(PropertyConstraintViolation.fromJson("null")) + } + + @Test + fun `should refuse malformed violations`() { + for (json in listOf("", "[]", "\"NotMet\"", """{"rule": "r", "violation": "NotMet"}""")) { + assertThrows(json, DashSdkError.SerializationError::class.java) { + PropertyConstraintViolation.fromJson(json) + } + } + } +} diff --git a/packages/rs-unified-sdk-jni/src/queries.rs b/packages/rs-unified-sdk-jni/src/queries.rs index bf2e0bf9d7e..e56c8b934a3 100644 --- a/packages/rs-unified-sdk-jni/src/queries.rs +++ b/packages/rs-unified-sdk-jni/src/queries.rs @@ -1,6 +1,8 @@ //! JNI exports for read-only Platform queries: identities, DPNS names, //! data contracts, documents. All payloads are JSON strings produced by -//! `rs-sdk-ffi`; parsing happens on the Kotlin side. +//! `rs-sdk-ffi`; parsing happens on the Kotlin side. The two +//! `propertyConstraints` exports read a contract the caller already holds +//! and make no network call. //! //! Kotlin counterpart: `org.dashfoundation.dashsdk.ffi.QueriesNative`. @@ -16,13 +18,14 @@ use rs_sdk_ffi::{ dash_sdk_add_known_contracts, dash_sdk_address_fetch_info, dash_sdk_addresses_fetch_infos, dash_sdk_calculate_token_id, dash_sdk_contested_resource_get_identity_votes, dash_sdk_contested_resource_get_resources, dash_sdk_contested_resource_get_vote_state, - dash_sdk_contested_resource_get_voters_for_identity, dash_sdk_data_contract_destroy, + dash_sdk_contested_resource_get_voters_for_identity, + dash_sdk_data_contract_check_property_constraints, dash_sdk_data_contract_destroy, dash_sdk_data_contract_fetch, dash_sdk_data_contract_fetch_json, dash_sdk_data_contract_fetch_result_free, dash_sdk_data_contract_fetch_with_serialization, - dash_sdk_data_contracts_fetch_by_range, dash_sdk_document_average, dash_sdk_document_count, - dash_sdk_document_search, dash_sdk_document_sum, dash_sdk_dpns_check_availability, - dash_sdk_dpns_get_usernames, dash_sdk_dpns_resolve, dash_sdk_dpns_search, - dash_sdk_evonode_get_proposed_epoch_blocks_by_ids, + dash_sdk_data_contract_get_property_constraints, dash_sdk_data_contracts_fetch_by_range, + dash_sdk_document_average, dash_sdk_document_count, dash_sdk_document_search, + dash_sdk_document_sum, dash_sdk_dpns_check_availability, dash_sdk_dpns_get_usernames, + dash_sdk_dpns_resolve, dash_sdk_dpns_search, dash_sdk_evonode_get_proposed_epoch_blocks_by_ids, dash_sdk_evonode_get_proposed_epoch_blocks_by_range, dash_sdk_group_get_action_signers, dash_sdk_group_get_actions, dash_sdk_group_get_info, dash_sdk_identities_fetch_balances, dash_sdk_identities_fetch_contract_keys, dash_sdk_identity_fetch, @@ -59,6 +62,40 @@ fn c_ptr(opt: &Option) -> *const c_char { opt.as_ref().map_or(ptr::null(), |s| s.as_ptr()) } +/// Copy a required Java `byte[]` into an owned buffer that outlives the FFI +/// call; throws + returns None for a null or unreadable array. +fn read_bytes(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Option> { + if arr.is_null() { + throw_sdk_exception(env, 1, &format!("{field} was null")); + return None; + } + match env.convert_byte_array(arr) { + Ok(bytes) => Some(bytes), + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, &format!("{field} byte[] was invalid")); + None + } + } +} + +/// Read a required 32-byte id from a Java `byte[]`; throws + returns None +/// on the wrong length or a JNI error. +fn read_id32(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Option<[u8; 32]> { + let bytes = read_bytes(env, arr, field)?; + match <[u8; 32]>::try_from(bytes.as_slice()) { + Ok(id) => Some(id), + Err(_) => { + throw_sdk_exception( + env, + 1, + &format!("{field} must be 32 bytes, got {}", bytes.len()), + ); + None + } + } +} + /// Shorthand for the guard + required-string preamble every query shares. macro_rules! require_cstr { ($env:expr, $val:expr) => { @@ -1652,3 +1689,100 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_QueriesNative_addKnow unsafe { crate::results::take_error(env, &result) }; }) } + +/// The `propertyConstraints` rules (protocol version 14) of `documentType`, +/// as the JSON array `dash_sdk_data_contract_get_property_constraints` +/// returns: every rule in name order (the order consensus checks them in), +/// each as `{"name", "rule", "reads": [{"path", "kind"}], "readsOwner"}`. +/// `[]` for a type declaring none, and for every type while the SDK's +/// protocol version is below 14. +/// +/// * `serialized_contract` is the contract's platform serialization, the +/// bytes `dataContractFetchWithSerialization` returns and +/// `DataContractEntity.binarySerialization` keeps; Rust reads it at the +/// SDK's protocol version. No network call. +/// +/// Returns the JSON, or null after throwing: `NotFound` for a document type +/// the contract does not declare, `SerializationError` for bytes that are not +/// a contract, `InvalidParameter` for a null or empty argument. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_QueriesNative_dataContractGetPropertyConstraints( + mut env: JNIEnv, + _class: JClass, + sdk: jlong, + serialized_contract: JByteArray, + document_type: JString, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(contract) = read_bytes(env, &serialized_contract, "serializedContract") else { + return ptr::null_mut(); + }; + let document_type = require_cstr!(env, document_type); + // `contract` and `document_type` outlive the synchronous call. + let result = unsafe { + dash_sdk_data_contract_get_property_constraints( + sdk as *const SDKHandle, + contract.as_ptr(), + contract.len(), + document_type.as_ptr(), + ) + }; + unsafe { unwrap_string(env, result) } + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// The first `propertyConstraints` rule a document to create would break, as +/// the JSON `dash_sdk_data_contract_check_property_constraints` returns: +/// `{"rule", "violation", "message"}`, or the JSON text `null` when it meets +/// every rule (always so while the SDK's protocol version is below 14). Rust +/// builds the document the create path builds and judges it with the check +/// consensus runs; nothing but the rules is checked. +/// +/// * `serialized_contract` is as for `dataContractGetPropertyConstraints`. +/// * `properties_json` is the properties JSON the document would be created +/// with (what `documentTransactions.create` takes). +/// * `owner_id` is the 32-byte identity that would own it, which `$ownerId` +/// reads. +/// +/// Returns the JSON, or null after throwing: `NotFound` for a document type +/// the contract does not declare, `SerializationError` for bytes that are not +/// a contract, `InvalidParameter` for a null or empty argument, an owner id +/// that is not 32 bytes, or properties that are not a JSON object. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_QueriesNative_dataContractCheckPropertyConstraints( + mut env: JNIEnv, + _class: JClass, + sdk: jlong, + serialized_contract: JByteArray, + document_type: JString, + properties_json: JString, + owner_id: JByteArray, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(contract) = read_bytes(env, &serialized_contract, "serializedContract") else { + return ptr::null_mut(); + }; + let document_type = require_cstr!(env, document_type); + let properties = require_cstr!(env, properties_json); + // The FFI reads exactly 32 bytes behind the owner pointer. + let Some(owner) = read_id32(env, &owner_id, "ownerId") else { + return ptr::null_mut(); + }; + // Every buffer outlives the synchronous call. + let result = unsafe { + dash_sdk_data_contract_check_property_constraints( + sdk as *const SDKHandle, + contract.as_ptr(), + contract.len(), + document_type.as_ptr(), + properties.as_ptr(), + owner.as_ptr(), + ) + }; + unsafe { unwrap_string(env, result) } + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} From 3f15eead88c04e4979c20e48929c5ac56995a24e Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 23:48:38 +0700 Subject: [PATCH 031/113] docs(platform): document the genesis protocol version exception and Swift/Kotlin indentation (#5061) Co-authored-by: Claude Opus 5.5 --- .editorconfig | 4 ++++ AGENTS.md | 3 ++- book/src/contributing/coding-conventions.md | 10 +++++++++- book/src/versioning/versioned-dispatch.md | 21 +++++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.editorconfig b/.editorconfig index 2e9dc07ba04..35057547424 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,6 +11,10 @@ end_of_line = lf [*.rs] indent_size = 4 +# Swift and Kotlin follow their languages' 4-space convention. +[*.{swift,kt,kts}] +indent_size = 4 + # Preserve the existing indentation of the Swift SDK Python scripts. [packages/swift-sdk/scripts/*.py] indent_size = 4 diff --git a/AGENTS.md b/AGENTS.md index 316d53322ad..26ede667802 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,8 @@ Platform uses data contracts to define application data schemas: - Run linters: `yarn lint` ## Coding Style & Naming Conventions -- Follow `.editorconfig`: 2-space indent by default; 4 spaces for `*.rs` and +- Follow `.editorconfig`: 2-space indent by default; 4 spaces for `*.rs`, + Swift and Kotlin (`*.swift`, `*.kt`, `*.kts`), and `packages/swift-sdk/scripts/*.py`, preserving the existing Python script style. Use LF, UTF‑8, and a final newline. - JS/TS: ESLint (Airbnb/TypeScript rules via package configs). Use camelCase for variables/functions, PascalCase for classes; prefer kebab-case filenames within JS packages. diff --git a/book/src/contributing/coding-conventions.md b/book/src/contributing/coding-conventions.md index 47bbb0f4f9f..ee0556e0325 100644 --- a/book/src/contributing/coding-conventions.md +++ b/book/src/contributing/coding-conventions.md @@ -84,7 +84,15 @@ dpp method whose own gate is `None` there), or the edit is a pure refactor with identical output. "Probably inert" is not enough. If the argument takes more than a sentence, or rests on a runtime check inside the shipped module, add a generation instead. Inside a new generation the capability is a constant fact -(`Index::try_from_value_map(map, true)`), not a runtime check. +(`Index::try_from_value_map(map, true)`), not a runtime check. For new +changes, the patterns that compare `protocol_version` on purpose are the +protocol upgrade ladder, the `is_allowed` gate for new transition kinds, and +chain-creation content (`create_genesis_state` v1 and the Drive helpers that +build the initial state structure); the [Versioned +Dispatch](../versioning/versioned-dispatch.md) chapter describes them. Older +comparisons elsewhere (for example the pre-version-9 arithmetic in +`DocumentPropertyType`) stay because history replays through them; they are not +a pattern to copy. Why: replay safety is structural when a shipped file stays byte-identical, and becomes a proof the reviewer has to check the moment it does not. An in-place diff --git a/book/src/versioning/versioned-dispatch.md b/book/src/versioning/versioned-dispatch.md index ba303a9a835..7a59a994be1 100644 --- a/book/src/versioning/versioned-dispatch.md +++ b/book/src/versioning/versioned-dispatch.md @@ -687,6 +687,27 @@ stage of the validation pipeline reading the constants in its initial version is rejected with a `StateTransitionNotActiveError` rather than an unknown-version dispatch error. +Genesis content is the other place a `protocol_version` comparison is the +intended shape. `create_genesis_state` runs once per chain, under the protocol +version the chain is born at. Mainnet and testnet were born at protocol +version 1 and replay generation 0, which stays frozen. Generation 1 is selected +only for chains born at protocol version 9 or later, and every such chain is a +devnet, a local network or a test suite that is created again for each +release, so no live node reproduces its genesis. When a protocol version adds +a system contract or other genesis content, it goes into +`create_genesis_state_v1` behind `if platform_version.protocol_version >= N` +(document history at 13, app connect and moderation charters at 14), and a +chain that already exists gets the same content from its +`transition_to_version_N` rung. A new genesis generation would add code for no +replay benefit. Never edit generation 0. + +The Drive helpers that build the initial state structure follow the same rule +for the same reason: they run once, at chain creation, under the chain's +initial protocol version, and a chain that already exists gets the same trees +from its upgrade rung. `add_initial_withdrawal_state_structure_operations` +adds the withdrawal sum trees behind `>= 4` and the credit history trees behind +`>= 14`; replaying mainnet's genesis at protocol version 1 takes neither branch. + ## Rules **Do:** From 86c948de89f17c20db74df4e899b43945f769fbb Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 23:49:35 +0700 Subject: [PATCH 032/113] test(drive): pin that a cached contract read after an in-block update bills like a cold read (#5052) Co-authored-by: Claude Opus 5.5 --- .../get_contract_with_fetch_info/mod.rs | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/packages/rs-drive/src/drive/contract/get_fetch/get_contract_with_fetch_info/mod.rs b/packages/rs-drive/src/drive/contract/get_fetch/get_contract_with_fetch_info/mod.rs index 8e7056105aa..3af9abd89da 100644 --- a/packages/rs-drive/src/drive/contract/get_fetch/get_contract_with_fetch_info/mod.rs +++ b/packages/rs-drive/src/drive/contract/get_fetch/get_contract_with_fetch_info/mod.rs @@ -195,6 +195,9 @@ mod tests { use dpp::tests::json_document::json_document_to_contract; use crate::util::batch::{DataContractOperationType, DriveOperation}; + use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; + use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; + use dpp::data_contract::document_type::random_document::CreateRandomDocument; use dpp::version::PlatformVersion; use std::borrow::Cow; use std::sync::Arc; @@ -719,4 +722,152 @@ mod tests { 1 ); } + + /// A contract updated in the block is afterwards served from the block cache, seeded by + /// the update's refresh. Billing that read from the cost recorded at the refresh must + /// charge what a cold read of the contract through the same transaction charges (which is + /// what a node that evicted the contract on update used to bill), even when documents were + /// written under the contract and other contracts rebalanced the contracts tree between + /// the refresh and the read. Run at the shipped protocol version 13 and at the latest. + #[test] + fn should_bill_a_block_cache_hit_after_an_update_like_a_cold_read() { + for platform_version in [ + PlatformVersion::get(13).expect("expected protocol version 13"), + PlatformVersion::latest(), + ] { + let drive = setup_drive_with_initial_state_structure(None); + let contract = json_document_to_contract( + "tests/supporting_files/contract/references/references.json", + false, + platform_version, + ) + .expect("expected to get a contract"); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply contract successfully"); + let contract_id = contract.id().to_buffer(); + let epoch = Epoch::new(0).expect("epoch 0"); + + let transaction = drive.grove.start_transaction(); + + let mut updated = contract.clone(); + updated.increment_version(); + drive + .apply_drive_operations( + vec![DriveOperation::DataContractOperation( + DataContractOperationType::ApplyContract { + contract: Cow::Borrowed(&updated), + storage_flags: None, + }, + )], + true, + &BlockInfo::default(), + Some(&transaction), + platform_version, + None, + ) + .expect("expected to apply the update"); + assert!(drive.cache.data_contracts.is_modified_in_block(contract_id)); + // The entry the refresh seeded, with the cost recorded before the writes below. + let seeded = drive + .cache + .data_contracts + .get(contract_id, true) + .expect("the refresh must seed the block cache"); + + // Documents written under the contract after the refresh. + let note_type = updated + .document_type_for_name("note") + .expect("expected the note type"); + for seed in 0..10u64 { + let note = note_type + .random_document(Some(seed), platform_version) + .expect("expected a random note"); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((¬e, None)), + owner_id: None, + }, + contract: &updated, + document_type: note_type, + }, + false, + BlockInfo::default(), + true, + Some(&transaction), + platform_version, + None, + ) + .expect("expected to insert a note"); + } + + // Other contracts that rebalance the contracts tree after the refresh. + let mut other = contract.clone(); + for i in 100..140u8 { + other.set_id(Identifier::from([i; 32])); + drive + .apply_contract( + &other, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + Some(&transaction), + platform_version, + ) + .expect("expected to apply contract successfully"); + } + + // The billed read of the updated contract, served from the block cache. + let (hit_fee, hit) = drive + .get_contract_with_fetch_info_and_fee( + contract_id, + Some(&epoch), + true, + Some(&transaction), + platform_version, + ) + .expect("expected the read to succeed"); + let hit = hit.expect("expected the contract"); + assert_eq!(hit.contract.version(), 2); + // The read must be served by the entry the refresh seeded, not by a cold fetch that + // would make this test compare two cold reads. + assert!( + Arc::ptr_eq(&seeded, &hit), + "protocol version {}: the billed read must be the refresh-seeded cache hit", + platform_version.protocol_version + ); + + // A cold read of the same contract through the same transaction. + let cold = drive + .fetch_contract_and_add_operations( + contract_id, + Some(&epoch), + Some(&transaction), + &mut vec![], + platform_version, + ) + .expect("expected the read to succeed") + .expect("expected the contract"); + + assert_eq!( + hit.cost, cold.cost, + "protocol version {}: the cached cost must equal a cold read's cost", + platform_version.protocol_version + ); + assert_eq!( + hit_fee, cold.fee, + "protocol version {}: the cached read must bill like a cold read", + platform_version.protocol_version + ); + } + } } From 8340446f87a00c187a62c2545bc8ca23ffa348de Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Sun, 27 Sep 2026 23:50:48 +0700 Subject: [PATCH 033/113] fix(dpp): restore the shipped order of basic consensus errors (#5053) Co-authored-by: Claude Opus 5.5 --- .../src/errors/consensus/basic/basic_error.rs | 67 ++++++++++++++----- 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index 97a5601d4b1..211839e6dbf 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -628,10 +628,6 @@ pub enum BasicError { InvalidTokenDistributionTimeIntervalNotMinuteAlignedError, ), - #[error(transparent)] - InvalidTokenDistributionEpochIntervalTooShortError( - InvalidTokenDistributionEpochIntervalTooShortError, - ), #[error(transparent)] RedundantDocumentPaidForByTokenWithContractId(RedundantDocumentPaidForByTokenWithContractId), @@ -831,6 +827,14 @@ pub enum BasicError { // A document breaking a rule of its type's `propertyConstraints` (protocol version 14). #[error(transparent)] DocumentPropertyConstraintViolatedError(DocumentPropertyConstraintViolatedError), + + // A perpetual distribution with a zero epoch interval (protocol version 14). Appended here: + // it was first inserted mid-enum, which shifted the discriminant of every variant shipped + // after it in 4.1. + #[error(transparent)] + InvalidTokenDistributionEpochIntervalTooShortError( + InvalidTokenDistributionEpochIntervalTooShortError, + ), } impl From for ConsensusError { @@ -865,7 +869,7 @@ mod tests { discriminant_of(BasicError::IdentityKeyLimitsUpdateEmptyError( IdentityKeyLimitsUpdateEmptyError::new(1) )), - 186 + 185 ); // Once-per-identity token distribution (protocol version 14). assert_eq!( @@ -874,47 +878,47 @@ mod tests { InvalidTokenOncePerIdentityDistributionAmountError::new(0, 1) ) ), - 187 + 186 ); // Pre-programmed distribution amounts (protocol version 14). assert_eq!( discriminant_of(BasicError::PreProgrammedDistributionAmountOverLimitError( PreProgrammedDistributionAmountOverLimitError::new(0, 100) )), - 188 + 187 ); // Contract moderation (protocol version 14). assert_eq!( discriminant_of(BasicError::InvalidContractModerationConfigError( InvalidContractModerationConfigError::new("reason".to_string()) )), - 189 + 188 ); assert_eq!( discriminant_of(BasicError::ContractModerationSelfTargetError( ContractModerationSelfTargetError::new(Identifier::from([1; 32])) )), - 190 + 189 ); assert_eq!( discriminant_of(BasicError::ContractModerationReasonTooLongError( ContractModerationReasonTooLongError::new(1025, 1024) )), - 191 + 190 ); // Document action fees (protocol version 14). assert_eq!( discriminant_of(BasicError::DocumentActionFeesWithoutModerationError( DocumentActionFeesWithoutModerationError::new("post".to_string()) )), - 192 + 191 ); // Documents cited by a contract moderation reason (protocol version 14). assert_eq!( discriminant_of(BasicError::InvalidContractModerationReasonDocumentsError( InvalidContractModerationReasonDocumentsError::new("x".to_string()) )), - 193 + 192 ); // A `distinctFrom` identifier property equal to what it must differ from (protocol // version 14). @@ -926,7 +930,7 @@ mod tests { "$ownerId".to_string(), ) )), - 194 + 193 ); // The shape of an `encryptedFor` property's ciphertext (protocol version 14). assert_eq!( @@ -939,7 +943,7 @@ mod tests { 16 ) )), - 195 + 194 ); // Moderation charters (protocol version 14). assert_eq!( @@ -949,20 +953,20 @@ mod tests { "reason".to_string() ) )), - 196 + 195 ); assert_eq!( discriminant_of(BasicError::ModerationCharterRewardSplitNotOneHundredError( ModerationCharterRewardSplitNotOneHundredError::new(10, 40, 40) )), - 197 + 196 ); // A string over its property's `maxBytes` (protocol version 14). assert_eq!( discriminant_of(BasicError::DocumentPropertyMaxBytesExceededError( DocumentPropertyMaxBytesExceededError::new("description".to_string(), 4097, 4096) )), - 198 + 197 ); // A document breaking a rule of its type's `propertyConstraints` (protocol version 14). assert_eq!( @@ -973,7 +977,36 @@ mod tests { PropertyConstraintViolation::NotMet, ) )), + 198 + ); + // A perpetual distribution with a zero epoch interval (protocol version 14). + assert_eq!( + discriminant_of( + BasicError::InvalidTokenDistributionEpochIntervalTooShortError( + InvalidTokenDistributionEpochIntervalTooShortError::new(0) + ) + ), 199 ); } + + /// The variants that shipped in 4.1 keep the discriminants they were released with, so an + /// SDK built against 4.1 decodes the errors of a newer node as the same variants. A variant + /// inserted anywhere before the tail moves these and fails this test. + #[test] + fn should_keep_the_discriminants_shipped_in_4_1() { + assert_eq!( + discriminant_of(BasicError::RedundantDocumentPaidForByTokenWithContractId( + RedundantDocumentPaidForByTokenWithContractId::new(Identifier::from([1; 32])) + )), + 140 + ); + // The last variant released in 4.1. + assert_eq!( + discriminant_of(BasicError::TokenPricingScheduleEmptyError( + TokenPricingScheduleEmptyError::new(Identifier::from([1; 32])) + )), + 172 + ); + } } From a9e3a2f5fc5780fb4813ceefaf01b992e1b6c014 Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:57:57 +0000 Subject: [PATCH 034/113] ci: bootstrap PR-first runner images on v4.2-dev Replay only the trusted caller from 3fac2dd2045bf8df35508af3c14be1ea559a93f9. Exclude inherited v4.3 policy and wallet changes. --- .github/workflows/runner-image-candidate.yml | 33 ++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/runner-image-candidate.yml diff --git a/.github/workflows/runner-image-candidate.yml b/.github/workflows/runner-image-candidate.yml new file mode 100644 index 00000000000..76354aaad5d --- /dev/null +++ b/.github/workflows/runner-image-candidate.yml @@ -0,0 +1,33 @@ +name: Runner image candidate + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, closed] + branches: [master, 'v*-dev', 'ci/*'] + paths: ['.github/runner-requirements.json'] + +# This is trusted base-branch orchestration. Never check out PR code or select +# the control revision from PR data. Build/publish execute on separate hosted VMs. +permissions: + contents: read + pull-requests: read + actions: read + statuses: write + +concurrency: + group: ${{ github.event.action == 'closed' && format('runner-image-promote-{0}', github.event.pull_request.base.ref) || format('runner-image-pr-{0}', github.event.pull_request.number) }} + cancel-in-progress: ${{ github.event.action != 'closed' }} + +jobs: + image: + if: >- + (github.event.action != 'closed' && !github.event.pull_request.draft) + || (github.event.action == 'closed' && github.event.pull_request.merged) + uses: dashpay/dash-selfhosted-image/.github/workflows/platform-candidate.yml@baf8849b900555d66714e0e1fcffdff669b9e404 + with: + pull_request: ${{ github.event.pull_request.number }} + control_revision: baf8849b900555d66714e0e1fcffdff669b9e404 + mode: ${{ github.event.action == 'closed' && 'promote' || 'candidate' }} + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} From db75bab3105b39e504b93f57168ef0c5b50c0d02 Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:57:57 +0000 Subject: [PATCH 035/113] ci: reconcile rootless runner workflows with v4.2-dev Replay only the runner-image CI changes from PR #4702. Preserve v4.2 Kotlin release hardening and coverage-only llvm-cov checks; omit unrelated v4.3 history. --- .github/SELF_HOSTED_RUNNER.md | 134 ++++++++++++ .github/actions/rust/action.yaml | 88 ++++++-- .github/runner-requirements.json | 206 ++++++++++++++++++ .github/scripts/kotlin-instrumented-tests.sh | 34 +++ .github/scripts/runner-image.py | 152 ++++++++++++++ .github/scripts/tests/test_runner_image.py | 97 +++++++++ .github/workflows/kotlin-sdk-build.yml | 210 ++++++++----------- .github/workflows/kotlin-sdk-nightly.yml | 4 +- .github/workflows/tests-build-js.yml | 3 +- .github/workflows/tests-rs-wallet.yml | 2 +- .github/workflows/tests-rs-workspace.yml | 104 ++++----- .github/workflows/tests.yml | 10 +- 12 files changed, 848 insertions(+), 196 deletions(-) create mode 100644 .github/SELF_HOSTED_RUNNER.md create mode 100644 .github/runner-requirements.json create mode 100644 .github/scripts/kotlin-instrumented-tests.sh create mode 100644 .github/scripts/runner-image.py create mode 100644 .github/scripts/tests/test_runner_image.py diff --git a/.github/SELF_HOSTED_RUNNER.md b/.github/SELF_HOSTED_RUNNER.md new file mode 100644 index 00000000000..cb46745c9b0 --- /dev/null +++ b/.github/SELF_HOSTED_RUNNER.md @@ -0,0 +1,134 @@ +# Self-hosted runner contract + +## Reproducible Linux image + +Source, locked dependencies, deployment examples and publishing workflow: +**[dashpay/dash-selfhosted-image](https://github.com/dashpay/dash-selfhosted-image)**. +Use its `linux/amd64` contract-1 image for persistent Linux `kotlin-ci` / `rust-ci` +runners. The shared Rust action requires `/opt/ci/contract-version` to be `1` on +self-hosted Linux and fails early if an old/native runner picks up the job. + +Platform's desired versions and checksums now live in +[.github/runner-requirements.json](runner-requirements.json). This includes an immutable image-recipe +commit. Persistent Linux jobs verify **both** the installed lock and recipe +revision; a contract-1 marker by itself is not sufficient. The earlier published +bootstrap image must be replaced by a matching candidate before these changes +can be merged. + +The image locks Ubuntu 24.04 by digest, apt to a signed archive snapshot, and +downloaded toolchains to exact URLs and SHA-256 hashes. It includes: + +| Toolchain | Contract | +| --- | --- | +| Native build | build-essential, clang/LLVM, Snappy, CMake, GMP, OpenSSL, pkg-config | +| Rust | rustup plus the repository baseline; exact repo-selected toolchains may be installed user-locally | +| Cargo helpers | llvm-cov **0.9.1**, nextest **0.9.144**, machete **0.9.2**, ndk **4.1.2** | +| Protobuf / Java | protoc **32.0**, JDK **17** | +| Android | API **35**, build-tools **35.0.0**, NDK **28.1.13356709**, pinned emulator/system image | +| Other job tools | Git, GitHub CLI, jq, Python 3, gpg, zip/unzip | + +The SDK is image-owned. The persistent Kotlin workflow uses the image's +`ci-android-emulator` wrapper to create user-writable AVDs, boot with KVM, and stop +the emulator after testing. It does **not** run setup-android, sdkmanager, or an +emulator action that upgrades image packages at job runtime. Lockscreen/PIN and +unlocked-device checks remain in `.github/scripts/kotlin-instrumented-tests.sh`. + +## Runtime privileges + +- Non-root uid/gid **1001:1001**, no sudo, no Docker CLI or host Docker socket. +- Drop **all** capabilities; enable **no-new-privileges**. Keep default Docker + seccomp/AppArmor policies, with no privileged mode or host namespaces. +- Dedicated registration/work volumes only. Kotlin adds **`/dev/kvm:rw`** and its + numeric host group; Rust-only runners do not need that device. +- The operator configures `/dev/kvm` as `root:kvm`, mode **0660**. Jobs only check + access; they never modify host udev rules, permissions or system packages. +- Preserve runner-group selected-repository access and the existing fork guards. + Persistent job data is not isolation between mutually untrusted repositories. + +Building/publishing the image uses Docker on an ephemeral GitHub-hosted builder; +that privilege is not passed into the resulting persistent runner. + +## Publish, prove, then roll out + +For Platform requirements changes, use the PR-first lifecycle below. The +standalone image publishing workflow remains useful for recipe development, +but its default lock is not a separate source of Platform requirements. + +1. Use a successful [image publishing run](https://github.com/dashpay/dash-selfhosted-image/actions/workflows/image.yml). + Publication requires non-root compiler/confinement checks, `KVM_CREATE_VM`, and + a real API 35 emulator boot. Retrieve `image-reference.txt` from the run. +2. Set `RUNNER_IMAGE=dashpay/dash-selfhosted-image@sha256:` in + the operator's deployment. Do not use a floating image or a locally inherited + `github-runner-runner:latest` parent. The image repo's Compose files enforce the + runtime boundary above; the KVM overlay is optional. +3. Drain the old runner before migration. Register a new name in the **existing + group**, retaining its selected repositories, with only the required labels. + Use a short-lived registration-token file, not a PAT stored in Compose. +4. Prove a real Rust job and Kotlin job on that exact runner/digest before retiring + the old instance. Keep the previous registration/image for rollback. Rebuilding + or pushing source does not replace any live runner automatically. + +Deploy and prove the contract-1 image **before merging the consuming workflows**. +Record the selected digest and real-job evidence with the deployment; do not infer +runtime health from YAML validation or the image tag alone. Rebuild/repin when +dependencies change, including runner updates required by GitHub's update policy. + +## Requirements changes: candidate before merge, promotion after + +1. Change .github/runner-requirements.json in the Platform PR, including exact download URLs, + checksums and package metadata. A change to this file is the automatic build + flag; no separate label is required. Change the pinned recipe commit only + when recipe/image code changes. Ordinary user-local Rust toolchain updates + still follow rust-toolchain.toml. +2. The trusted base-branch publisher builds and smoke-tests a candidate on a + disposable VM. A separate VM publishes it without executing PR image/code + with Docker Hub credentials. +3. The normal Rust and Kotlin workflows wait for the candidate, then request + temporary runners labelled for **this PR head, exact digest and job kind**. + A host-side controller creates one-job non-root containers, with KVM only + for Kotlin. Real application jobs must pass; skipped fork jobs do not count. +4. After merge, the publisher verifies the merged/current requirements and both + real candidate jobs, then promotes **the same tested digest**, without a + rebuild. Each base branch gets a platform- channel; main advances only + for Platform's actual GitHub default branch. +5. Promotion does not restart production runners. The operator drains and + switches ordinary runner capacity to the reviewed digest using the rollout + procedure above. Requirements checks fail explicitly until capacity matches. + +The trusted caller must land separately before a PR can use this flow. +See the image repository's +[bootstrap, GitHub App and candidate-controller setup](https://github.com/dashpay/dash-selfhosted-image/blob/feat/platform-pr-images/docs/platform-pr-images.md). +No App key, Docker socket, registry credential or host workspace enters a job. +The existing trusted-fork restrictions are unchanged; the controller's +exact-head approvals do not override workflow-side guards. + +Run the routing checks with: + +~~~sh +python3 -m unittest discover -s .github/scripts/tests -v +~~~ + +## Hosted Linux and native macOS remain distinct + +The shared Rust action branches on `runner.environment`: persistent Linux verifies +the image's native libraries and protoc, while GitHub-hosted consumers retain apt +provisioning and the user-local protoc cache. +Both select clang through `CC`/`CXX`, without mutating system alternatives. + +The Linux image does not provision macOS. Native macOS `rust-ci` runners still +need the existing Homebrew dependencies plus llvm-cov 0.9.1, nextest 0.9.144 and +machete 0.9.2; the wallet fast path needs machete 0.9.2. Provision and verify these +separately before rollout. Do not silently install tools or swallow failures in +persistent jobs. + +Kotlin release builds use persistent `kotlin-ci` capacity and retain their separate +release hardening: forcibly reinstall cargo-ndk 4.1.2 and verify a fresh protoc +download by checksum. A version-only check of a binary left by a previous job is +not a substitute for these release checks. Release attachment and Maven +publication stay on separate GitHub-hosted jobs, keeping publishing credentials +off the persistent build runner. + +Docker publication and Kotlin nightly jobs remain on ephemeral GitHub-hosted +runners; the nightly job explicitly installs cargo-ndk 4.1.2. Any future self-hosted +job that genuinely needs Docker must use separately isolated capacity; the +persistent Rust/Kotlin runner must not regain the host Docker socket. diff --git a/.github/actions/rust/action.yaml b/.github/actions/rust/action.yaml index 808f86cbe32..b5b0f696360 100644 --- a/.github/actions/rust/action.yaml +++ b/.github/actions/rust/action.yaml @@ -21,6 +21,21 @@ inputs: runs: using: composite steps: + - name: Read shared runner tool requirements + shell: bash + run: python3 .github/scripts/runner-image.py env + + - name: Verify persistent Linux runner image contract + if: runner.os == 'Linux' && runner.environment == 'self-hosted' + shell: bash + run: | + if [ "$(cat /opt/ci/contract-version 2>/dev/null)" != 1 ]; then + echo '::error::This runner needs the versioned dashpay/dash-selfhosted-image (contract 1); see .github/SELF_HOSTED_RUNNER.md.' + exit 1 + fi + python3 .github/scripts/runner-image.py verify + command -v rustup + - name: Resolve HOME path for caching id: resolved_home shell: bash @@ -47,7 +62,7 @@ runs: components: ${{ inputs.components }} - name: Get protoc arch - if: runner.os == 'Linux' + if: runner.os == 'Linux' && runner.environment == 'github-hosted' shell: bash id: protoc_arch run: | @@ -66,40 +81,48 @@ runs: ;; esac - - name: Restore cached protoc (v32.0) - if: runner.os == 'Linux' + - name: Restore cached repository-pinned protoc + if: runner.os == 'Linux' && runner.environment == 'github-hosted' id: cache-protoc uses: actions/cache@v5 with: path: | - ${{ steps.resolved_home.outputs.home }}/.local/protoc-32.0/bin - ${{ steps.resolved_home.outputs.home }}/.local/protoc-32.0/include - key: protoc/32.0/${{ runner.os }}/${{ steps.protoc_arch.outputs.arch }} + ${{ steps.resolved_home.outputs.home }}/.local/protoc-${{ env.CI_PROTOC_VERSION }}/bin + ${{ steps.resolved_home.outputs.home }}/.local/protoc-${{ env.CI_PROTOC_VERSION }}/include + key: protoc/${{ env.CI_PROTOC_VERSION }}/${{ runner.os }}/${{ steps.protoc_arch.outputs.arch }} - - name: Install protoc (cached v32.0) - if: runner.os == 'Linux' + - name: Install repository-pinned protoc + if: runner.os == 'Linux' && runner.environment == 'github-hosted' id: deps-protoc shell: bash run: | set -euxo pipefail - PROTOC_DIR="${HOME}/.local/protoc-32.0" + PROTOC_DIR="${HOME}/.local/protoc-${{ env.CI_PROTOC_VERSION }}" if [ ! -x "${PROTOC_DIR}/bin/protoc" ]; then mkdir -p "${PROTOC_DIR}" curl -fsSL -o /tmp/protoc.zip \ - "https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-${{ steps.protoc_arch.outputs.arch }}.zip" + "https://github.com/protocolbuffers/protobuf/releases/download/v${CI_PROTOC_VERSION}/protoc-${{ env.CI_PROTOC_VERSION }}-linux-${{ steps.protoc_arch.outputs.arch }}.zip" unzip -o /tmp/protoc.zip -d "${PROTOC_DIR}" fi echo "${PROTOC_DIR}/bin" >> "$GITHUB_PATH" echo "PROTOC=${PROTOC_DIR}/bin/protoc" >> "$GITHUB_ENV" - - name: Save cached protoc (v32.0) - if: runner.os == 'Linux' && steps.cache-protoc.outputs.cache-hit != 'true' + - name: Save cached repository-pinned protoc + if: runner.os == 'Linux' && runner.environment == 'github-hosted' && steps.cache-protoc.outputs.cache-hit != 'true' uses: actions/cache/save@v5 with: path: | - ${{ steps.resolved_home.outputs.home }}/.local/protoc-32.0/bin - ${{ steps.resolved_home.outputs.home }}/.local/protoc-32.0/include - key: protoc/32.0/${{ runner.os }}/${{ steps.protoc_arch.outputs.arch }} + ${{ steps.resolved_home.outputs.home }}/.local/protoc-${{ env.CI_PROTOC_VERSION }}/bin + ${{ steps.resolved_home.outputs.home }}/.local/protoc-${{ env.CI_PROTOC_VERSION }}/include + key: protoc/${{ env.CI_PROTOC_VERSION }}/${{ runner.os }}/${{ steps.protoc_arch.outputs.arch }} + + - name: Verify prebaked repository-pinned protoc + if: runner.os == 'Linux' && runner.environment == 'self-hosted' + shell: bash + run: | + set -euo pipefail + protoc --version | grep -Fx "libprotoc $CI_PROTOC_VERSION" + echo "PROTOC=$(command -v protoc)" >> "$GITHUB_ENV" - name: Set HOME variable to github context shell: bash @@ -118,12 +141,37 @@ runs: ${{ runner.os }}/cargo/registry/${{ hashFiles('**/Cargo.lock') }} ${{ runner.os }}/cargo/registry/ - - name: Install clang + # This composite is also used by hosted release, nightly and book jobs. + # Keep their bootstrap path; only persistent runners require a prebaked image. + - name: Install native dependencies on ephemeral GitHub-hosted Linux + if: runner.os == 'Linux' && runner.environment == 'github-hosted' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang llvm libsnappy-dev + + # Linux self-hosted runners provide the compiler and native libraries in + # the pinned image. Do not install packages or mutate system alternatives + # from a job: the runner is intentionally non-root. + - name: Verify clang and native dependencies id: deps-clang shell: bash if: runner.os == 'Linux' run: | - sudo apt update - # snappy is required by rust rocksdb - sudo apt install -qq --yes clang llvm libsnappy-dev - sudo update-alternatives --set cc /usr/bin/clang + set -euo pipefail + missing=() + for tool in clang llvm-config; do + command -v "$tool" >/dev/null 2>&1 || missing+=("$tool") + done + for pkg in clang llvm libsnappy-dev; do + dpkg -s "$pkg" >/dev/null 2>&1 || missing+=("$pkg") + done + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Linux runner is missing: ${missing[*]}" + echo "::error::Persistent runners must provision these dependencies in the pinned image; hosted runners use the install step above." + exit 1 + fi + # Use clang for native C/C++ build scripts without changing the + # system-wide cc alternative. + echo "CC=/usr/bin/clang" >> "$GITHUB_ENV" + echo "CXX=/usr/bin/clang++" >> "$GITHUB_ENV" diff --git a/.github/runner-requirements.json b/.github/runner-requirements.json new file mode 100644 index 00000000000..352fb808d33 --- /dev/null +++ b/.github/runner-requirements.json @@ -0,0 +1,206 @@ +{ + "schema": 1, + "recipe_revision": "baf8849b900555d66714e0e1fcffdff669b9e404", + "requirements": { + "schema": 2, + "contract_version": "1", + "platform": "linux/amd64", + "ubuntu_image": "ubuntu:24.04@sha256:496754492fb28b4d3049432f2ca787449331e23fb14f0dd3fffea86bf5a93eb4", + "apt_snapshot": "20260920T000000Z", + "rust_version": "1.98.1", + "rust_manifest_sha256": "a7c8774a5fd8441c997d94c029776cbc5eb111e9d72ab5d256fa69866644347e", + "artifacts": [ + { + "name": "runner", + "url": "https://github.com/actions/runner/releases/download/v2.337.0/actions-runner-linux-x64-2.337.0.tar.gz", + "sha256": "70920811a4f8ad4328818682bca5c6469c1c942fab52448868071d0063816613", + "format": "tar", + "destination": "/opt/actions-runner" + }, + { + "name": "cargo-llvm-cov", + "url": "https://github.com/taiki-e/cargo-llvm-cov/releases/download/v0.9.1/cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz", + "sha256": "b3f68e625481fed9b16444174f3fa5ebcdbde4a1878803a35eabe2dcefcdc41a", + "format": "tar", + "destination": "/opt/ci/bin/cargo-llvm-cov", + "binary": "cargo-llvm-cov" + }, + { + "name": "cargo-nextest", + "url": "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.144/cargo-nextest-0.9.144-x86_64-unknown-linux-gnu.tar.gz", + "sha256": "8a4f726272b0a1c499bd87ca3978bfbb1a8c20bb08ccf075b9996e2081bd1e1e", + "format": "tar", + "destination": "/opt/ci/bin/cargo-nextest", + "binary": "cargo-nextest" + }, + { + "name": "cargo-machete", + "url": "https://github.com/bnjbvr/cargo-machete/releases/download/v0.9.2/cargo-machete-v0.9.2-x86_64-unknown-linux-musl.tar.gz", + "sha256": "48200087f54c55aabcd4db4af1e25742b49846c02a1b1bfa134711945b35b2e9", + "format": "tar", + "destination": "/opt/ci/bin/cargo-machete", + "binary": "cargo-machete" + }, + { + "name": "cargo-ndk", + "url": "https://github.com/bbqsrc/cargo-ndk/releases/download/v4.1.2/cargo-ndk-x86_64-unknown-linux-gnu-v4.1.2.tgz", + "sha256": "9451622c4567e8abb2c8005001855e32901c0b716ccdd3a173a4375fd03426e1", + "format": "tar", + "destination": "/opt/ci/bin/cargo-ndk", + "binary": "cargo-ndk" + }, + { + "name": "protoc", + "url": "https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-x86_64.zip", + "sha256": "7ca037bfe5e5cabd4255ccd21dd265f79eb82d3c010117994f5dc81d2140ee88", + "format": "zip", + "destination": "/opt/protoc" + }, + { + "name": "rustup-init", + "url": "https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-linux-gnu/rustup-init", + "sha256": "20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c", + "format": "file", + "destination": "/opt/ci/rustup-init", + "build_only": true + }, + { + "name": "platforms;android-35", + "url": "https://dl.google.com/android/repository/platform-35_r02.zip", + "upstream_sha1": "0bb560a90a7a2cbd0dd8348224d518b638fe7949", + "format": "zip", + "destination": "/opt/android-sdk/platforms/android-35", + "archive_root": "android-35", + "package_xml": "\n \n 35\n 13\n true\n \n \n \n 2\n \n Android SDK Platform 35\n \n ", + "sha256": "0988cacad01b38a18a47bac14a0695f246bc76c1b06c0eeb8eb0dc825ab0c8e0" + }, + { + "name": "ndk;28.1.13356709", + "url": "https://dl.google.com/android/repository/android-ndk-r28b-linux.zip", + "upstream_sha1": "f574d3165405bd59ffc5edaadac02689075a729f", + "format": "zip", + "destination": "/opt/android-sdk/ndk/28.1.13356709", + "archive_root": "android-ndk-r28b", + "package_xml": "\n \n \n 28\n 1\n 13356709\n \n NDK (Side by side) 28.1.13356709\n \n ", + "sha256": "e9f2759862cecfd48c20bbb7d8cfedbb020f4d91b5f78d9a2fc106f7db3c27ed" + }, + { + "name": "build-tools;35.0.0", + "url": "https://dl.google.com/android/repository/build-tools_r35_linux.zip", + "upstream_sha1": "2cfaa0bbb2336e9ec18ed3ecea84fa2e2af607bc", + "format": "zip", + "destination": "/opt/android-sdk/build-tools/35.0.0", + "archive_root": "android-15", + "package_xml": "\n \n \n 35\n 0\n 0\n \n Android SDK Build-Tools 35\n \n ", + "sha256": "bd3a4966912eb8b30ed0d00b0cda6b6543b949d5ffe00bea54c04c81e1561d88" + }, + { + "name": "cmdline-tools;19.0", + "url": "https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip", + "upstream_sha1": "5fdcc763663eefb86a5b8879697aa6088b041e70", + "format": "zip", + "destination": "/opt/android-sdk/cmdline-tools/19.0", + "archive_root": "cmdline-tools", + "package_xml": "\n \n \n 19\n 0\n \n Android SDK Command-line Tools\n \n ", + "sha256": "7ec965280a073311c339e571cd5de778b9975026cfcbe79f2b1cdcb1e15317ee" + }, + { + "name": "platform-tools", + "url": "https://dl.google.com/android/repository/platform-tools_r37.0.1-linux.zip", + "upstream_sha1": "477254aa5f903c15cf51001717bdf347fb6b53e0", + "format": "zip", + "destination": "/opt/android-sdk/platform-tools", + "archive_root": "platform-tools", + "package_xml": "\n \n \n 37\n 0\n 1\n \n Android SDK Platform-Tools\n \n ", + "sha256": "d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1" + }, + { + "name": "emulator", + "url": "https://dl.google.com/android/repository/emulator-linux_x64-15917651.zip", + "upstream_sha1": "1b1f78891abf8ec268264356e1365c25519e8379", + "format": "zip", + "destination": "/opt/android-sdk/emulator", + "archive_root": "emulator", + "package_xml": "\n \n \n 37\n 1\n 11\n \n Android Emulator\n \n ", + "sha256": "95771e0ae431897b2a4bd2d97fa095f29a8b0624a7b216baf529f9306161c266" + }, + { + "name": "system-images;android-35;default;x86_64", + "url": "https://dl.google.com/android/repository/sys-img/android/x86_64-35_r02.zip", + "upstream_sha1": "2d857d170c0d1b827149565da34b3383e5306f7f", + "format": "zip", + "destination": "/opt/android-sdk/system-images/android-35/default/x86_64", + "archive_root": "x86_64", + "package_xml": "\n \n 35\n 13\n true\n \n default\n Default Android System Image\n \n x86_64\n \n \n 2\n \n Intel x86_64 Atom System Image\n \n \n \n 29\n 1\n 11\n \n \n \n \n ", + "sha256": "6dd7de33e63ef105cf2fabea6badda1dbe7665c96d8908e5f6e1407e63ff4556" + } + ], + "bootstrap_ca": { + "url": "https://snapshot.ubuntu.com/ubuntu/20260920T000000Z/pool/main/c/ca-certificates/ca-certificates_20240203_all.deb", + "sha256": "641de77d8f142cfd62a1a6f964ba67b20754d3337c480efb529d086075a06c9a", + "version": "20240203" + }, + "apt_packages": [ + "ca-certificates", + "curl", + "git", + "gh", + "jq", + "python3", + "unzip", + "zip", + "xz-utils", + "bzip2", + "gnupg", + "build-essential", + "clang", + "llvm", + "libsnappy-dev", + "cmake", + "libgmp-dev", + "libssl-dev", + "pkg-config", + "openjdk-17-jdk-headless", + "libicu74", + "libkrb5-3", + "zlib1g", + "libgcc-s1", + "libstdc++6", + "libcurl4t64", + "liblttng-ust1t64", + "libunwind8", + "libpulse0", + "libx11-xcb1", + "libnss3", + "libxcomposite1", + "libxcursor1", + "libxi6", + "libxrandr2", + "libxtst6", + "libasound2t64", + "libgl1", + "libegl1", + "libdbus-1-3", + "libxdamage1", + "libxfixes3" + ], + "java_major": 17, + "versions": { + "runner": "2.337.0", + "llvm_cov": "0.9.1", + "nextest": "0.9.144", + "machete": "0.9.2", + "cargo_ndk": "4.1.2", + "protoc": "32.0", + "rustup": "1.28.2" + }, + "android": { + "api": 35, + "build_tools": "35.0.0", + "ndk": "28.1.13356709", + "cmdline_tools": "19.0", + "system_image": "system-images;android-35;default;x86_64", + "abi": "x86_64" + } + } +} diff --git a/.github/scripts/kotlin-instrumented-tests.sh b/.github/scripts/kotlin-instrumented-tests.sh new file mode 100644 index 00000000000..586d43ab8c5 --- /dev/null +++ b/.github/scripts/kotlin-instrumented-tests.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Called from packages/kotlin-sdk by the image's ci-android-emulator wrapper. +set -euo pipefail + +# Keystore's unlocked-device-required keys fail if the screen re-locks mid-test. +adb shell settings put system screen_off_timeout 2147483647 +adb shell svc power stayon true + +# Auth-required identity keys need an enrolled secure lockscreen on the test AVD. +adb shell locksettings set-pin 1234 + +# Cold boot can race credential acceptance and keyguard dismissal. Preserve the +# upstream retry and trust-state gate, now in one shell (not line-by-line sh -c). +unlocked=false +for attempt in 1 2 3; do + adb shell input keyevent KEYCODE_WAKEUP + adb shell wm dismiss-keyguard + adb shell input text 1234 + adb shell input keyevent KEYCODE_ENTER + sleep 2 + adb shell wm dismiss-keyguard + sleep 1 + if adb shell dumpsys trust | grep -q 'deviceLocked=0'; then + unlocked=true + break + fi +done +if [ "$unlocked" != true ]; then + echo '::error::Emulator is still locked; Keystore-backed tests would fail spuriously.' + adb shell dumpsys trust + exit 1 +fi + +./gradlew :sdk:connectedDebugAndroidTest --stacktrace diff --git a/.github/scripts/runner-image.py b/.github/scripts/runner-image.py new file mode 100644 index 00000000000..258cf6c442c --- /dev/null +++ b/.github/scripts/runner-image.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Select an exact PR image and export the shared runner tool requirements.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import time +import urllib.parse +import urllib.request + +MANIFEST = ".github/runner-requirements.json" +REPO = "dashpay/platform" + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def read_manifest(path): + data = Path(path).read_bytes() + require(len(data) <= 256 * 1024, "Requirements manifest is too large") + manifest = json.loads(data) + require(set(manifest) == {"schema", "recipe_revision", "requirements"} and manifest["schema"] == 1, + "Unsupported requirements manifest") + require(re.fullmatch(r"[0-9a-f]{40}", manifest["recipe_revision"]), "Pin the image recipe to a full SHA") + require(manifest["requirements"]["platform"] == "linux/amd64", "Unsupported image platform") + return manifest + + +def fingerprint(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def api(path): + token = os.environ.get("GH_TOKEN", "") + headers = {"Accept": "application/vnd.github+json", "User-Agent": "platform-runner-image"} + if token: + headers["Authorization"] = "Bearer " + token + request = urllib.request.Request("https://api.github.com/repos/" + REPO + "/" + path, headers=headers) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + +def changed_requirements(pr): + require(pr.get("changed_files", 0) <= 3000, "PR exceeds GitHub's file-list limit; requirements need explicit review") + for page in range(1, 31): + files = api(f"pulls/{pr['number']}/files?per_page=100&page={page}") + if any(f["filename"] == MANIFEST or f.get("previous_filename") == MANIFEST for f in files): + return True + if len(files) < 100: + return False + return False + + +def export_environment(manifest, output): + lock = manifest["requirements"] + versions, android = lock["versions"], lock["android"] + values = { + "CI_CARGO_LLVM_COV_VERSION": versions["llvm_cov"], + "CI_CARGO_NEXTEST_VERSION": versions["nextest"], + "CI_CARGO_MACHETE_VERSION": versions["machete"], + "CI_CARGO_NDK_VERSION": versions["cargo_ndk"], + "CI_PROTOC_VERSION": versions["protoc"], "CI_JAVA_MAJOR": str(lock["java_major"]), + "CI_ANDROID_API": str(android["api"]), "CI_ANDROID_NDK": android["ndk"], + "CI_ANDROID_BUILD_TOOLS": android["build_tools"], + } + require(all(isinstance(value, str) and re.fullmatch(r"[0-9]+(?:[.][0-9]+){0,3}(?:[-+][A-Za-z0-9.-]+)?", value) + for value in values.values()), "Versions must be version-pinned, newline-free values") + with open(output, "a") as handle: + for key, value in values.items(): + handle.write(f"{key}={value}\n") + + +def select(manifest, kind, output, wait_seconds): + fallback = ["self-hosted", "rust-ci" if kind == "rust" else "kotlin-ci"] + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) + requested = event.get("pull_request") + labels, changed = fallback, False + if requested: + pr = api(f"pulls/{requested['number']}") + head = requested["head"]["sha"] + require(pr["state"] == "open" and pr["head"]["sha"] == head, "This PR run has been superseded") + changed = changed_requirements(pr) + if changed: + # Use the exact PR requirement, not an accidental merge-tree mix + # after both branches edited this file. Rebase such a PR first. + import base64 + remote = api("contents/" + MANIFEST + "?" + urllib.parse.urlencode({"ref": head})) + expected = json.loads(base64.b64decode(remote["content"])) + require(fingerprint(expected) == fingerprint(manifest), + "Merge-tree requirements differ from PR head; rebase before building a candidate") + deadline = time.monotonic() + wait_seconds + while True: + statuses = api(f"commits/{head}/status")["statuses"] + candidate = next((s for s in statuses if s["context"] == f"Runner image candidate / PR {pr['number']}"), None) + if candidate and candidate["state"] == "success": + require(candidate.get("creator", {}).get("login") == "github-actions[bot]", + "Candidate status must come from the trusted publisher") + require(re.fullmatch(r"sha256:[0-9a-f]{64}", candidate.get("description", "")), + "Publisher did not record an immutable digest") + match = re.fullmatch(r"https://github[.]com/dashpay/platform/actions/runs/([0-9]+)", + candidate.get("target_url", "")) + require(match, "Candidate status is not linked to its publishing workflow") + run = api(f"actions/runs/{match.group(1)}") + require(run["path"] == ".github/workflows/runner-image-candidate.yml" + and run["event"] == "pull_request_target", "Unexpected candidate publisher") + if run["conclusion"] == "success": + labels = ["self-hosted", "Linux", "X64", + f"platform-image-pr-{pr['number']}-{head}-{candidate['description'][7:]}-{kind}"] + break + require(time.monotonic() < deadline, + "Candidate image was not published in time. Check Runner image candidate CI and bootstrap setup.") + current = api(f"pulls/{pr['number']}") + require(current["state"] == "open" and current["head"]["sha"] == head, "PR changed while waiting") + time.sleep(20) + with open(output, "a") as handle: + handle.write("labels=" + json.dumps(labels, separators=(",", ":")) + "\n") + handle.write("image_changed=" + str(changed).lower() + "\n") + print("Candidate runner required" if changed else "Using the ordinary provisioned runner pool") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["env", "verify", "select"]) + parser.add_argument("--manifest", default=MANIFEST) + parser.add_argument("--env", default=os.environ.get("GITHUB_ENV")) + parser.add_argument("--output", default=os.environ.get("GITHUB_OUTPUT")) + parser.add_argument("--kind", choices=["rust", "kotlin"]) + parser.add_argument("--wait-seconds", type=int, default=7200) + args = parser.parse_args() + manifest = read_manifest(args.manifest) + if args.command == "select": + require(args.kind and args.output, "Runner selection needs kind and output") + select(manifest, args.kind, args.output, args.wait_seconds) + return + if args.command == "verify": + subprocess.run(["ci-image-contract", "verify", args.manifest], check=True) + require(args.env, "Environment output file is required") + export_environment(manifest, args.env) + + +if __name__ == "__main__": + try: + main() + except (ValueError, KeyError, TypeError, OSError) as error: + print(f"::error::{error}", file=sys.stderr) + sys.exit(1) diff --git a/.github/scripts/tests/test_runner_image.py b/.github/scripts/tests/test_runner_image.py new file mode 100644 index 00000000000..c670105855c --- /dev/null +++ b/.github/scripts/tests/test_runner_image.py @@ -0,0 +1,97 @@ +"""Exercise runner routing, stale candidates and safe environment exports.""" +import base64 +import copy +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[3] +spec = importlib.util.spec_from_file_location("runner_image", ROOT / ".github/scripts/runner-image.py") +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) +HEAD = "a" * 40 +DIGEST = "sha256:" + "d" * 64 + + +class SelectorTests(unittest.TestCase): + def setUp(self): + self.manifest = runner.read_manifest(ROOT / runner.MANIFEST) + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.event = Path(self.temp.name) / "event.json" + self.output = Path(self.temp.name) / "output" + self.pr = {"number": 4702, "state": "open", "changed_files": 1, + "head": {"sha": HEAD}} + self.responses = { + "pulls/4702": self.pr, + "pulls/4702/files?per_page=100&page=1": [{"filename": runner.MANIFEST}], + f"contents/{runner.MANIFEST}?ref={HEAD}": { + "content": base64.b64encode(json.dumps(self.manifest).encode()).decode()}, + f"commits/{HEAD}/status": {"statuses": [{ + "context": "Runner image candidate / PR 4702", "state": "success", + "creator": {"login": "github-actions[bot]"}, "description": DIGEST, + "target_url": "https://github.com/dashpay/platform/actions/runs/7", + }]}, + "actions/runs/7": {"path": ".github/workflows/runner-image-candidate.yml", + "event": "pull_request_target", "conclusion": "success"}, + } + + def select(self, event=None): + self.event.write_text(json.dumps(event if event is not None else {"pull_request": self.pr})) + with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(self.event)}), \ + patch.object(runner, "api", side_effect=lambda path: self.responses[path]): + runner.select(self.manifest, "rust", self.output, 0) + return dict(line.split("=", 1) for line in self.output.read_text().splitlines()) + + def test_non_pr_and_unchanged_pr_use_existing_pool(self): + self.assertEqual(json.loads(self.select({})["labels"]), ["self-hosted", "rust-ci"]) + self.responses["pulls/4702/files?per_page=100&page=1"] = [{"filename": "Cargo.lock"}] + self.assertEqual(self.select()["image_changed"], "false") + + def test_exact_candidate_includes_head_digest_and_kind(self): + output = self.select() + labels = json.loads(output["labels"]) + self.assertEqual(labels[-1], f"platform-image-pr-4702-{HEAD}-{DIGEST[7:]}-rust") + self.assertEqual(output["image_changed"], "true") + + def test_new_head_or_closed_pr_rejects_stale_run(self): + event = {"pull_request": copy.deepcopy(self.pr)} + self.pr["head"]["sha"] = "e" * 40 + with self.assertRaisesRegex(ValueError, "superseded"): + self.select(event) + self.pr["head"]["sha"] = HEAD + self.pr["state"] = "closed" + with self.assertRaisesRegex(ValueError, "superseded"): + self.select(event) + + def test_merge_tree_cannot_mix_requirements_from_both_branches(self): + self.manifest["recipe_revision"] = "e" * 40 + with self.assertRaisesRegex(ValueError, "rebase"): + self.select() + + def test_missing_or_incomplete_publisher_cannot_select_image(self): + self.responses["actions/runs/7"]["conclusion"] = None + with self.assertRaisesRegex(ValueError, "not published"): + self.select() + self.responses[f"commits/{HEAD}/status"]["statuses"] = [] + with self.assertRaisesRegex(ValueError, "not published"): + self.select() + + def test_other_workflow_cannot_supply_candidate_status(self): + self.responses["actions/runs/7"]["path"] = ".github/workflows/tests.yml" + with self.assertRaisesRegex(ValueError, "Unexpected candidate"): + self.select() + + def test_environment_export_rejects_multiline_values_before_writing(self): + self.manifest["requirements"]["versions"]["protoc"] = "32.0\nINJECTED=yes" + with self.assertRaisesRegex(ValueError, "newline-free"): + runner.export_environment(self.manifest, self.output) + self.assertFalse(self.output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/kotlin-sdk-build.yml b/.github/workflows/kotlin-sdk-build.yml index 8919fd7b7dd..a6f6fe1c6ff 100644 --- a/.github/workflows/kotlin-sdk-build.yml +++ b/.github/workflows/kotlin-sdk-build.yml @@ -23,6 +23,10 @@ on: - 'scripts/check_sdk_parity_manifest.py' - 'scripts/tests/**' - '.github/workflows/kotlin-sdk-build.yml' + - '.github/scripts/kotlin-instrumented-tests.sh' + - '.github/actions/rust/**' + - '.github/runner-requirements.json' + - '.github/scripts/runner-image.py' permissions: contents: read @@ -32,9 +36,36 @@ concurrency: cancel-in-progress: true jobs: + select-runner: + name: Select compatible runner image + if: >- + github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name == github.repository + || github.event.pull_request.head.repo.owner.login == 'thepastaclaw' + runs-on: ubuntu-24.04 + # Allow the separate 90-minute build and publication/queue window to finish. + timeout-minutes: 135 + permissions: + contents: read + pull-requests: read + statuses: read + actions: read + outputs: + labels: ${{ steps.select.outputs.labels }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Select provisioned pool or wait for the exact PR candidate + id: select + env: + GH_TOKEN: ${{ github.token }} + run: python3 .github/scripts/runner-image.py select --kind kotlin + kotlin-sdk-build: name: Kotlin SDK build + tests (x86_64 emulator) - runs-on: [self-hosted, kotlin-ci] + needs: select-runner + runs-on: ${{ fromJSON(needs.select-runner.outputs.labels) }} # Fork PRs must not execute on a persistent runner. Keep this guard in sync # with tests-rs-workspace.yml. if: >- @@ -50,83 +81,90 @@ jobs: # Preserve target/ and other untracked build outputs between runs. clean: false - - name: Ensure runner dependencies + # The persistent runner image is the versioned CI environment. Keep + # dependency installation out of the job: it required sudo and made a + # CI job capable of mutating its container. Rebuild the runner image when + # one of these requirements changes instead. + - name: Verify exact runner requirements and load versions + run: python3 .github/scripts/runner-image.py verify + + - name: Verify runner image dependencies run: | set -euo pipefail - MISSING=() - for pkg in build-essential cmake curl jq libgmp-dev libpulse0 libssl-dev libx11-xcb1 pkg-config python3 unzip zip; do - dpkg -s "$pkg" >/dev/null 2>&1 || MISSING+=("$pkg") + missing=() + for tool in cmake curl jq python3 rustup unzip zip; do + command -v "$tool" >/dev/null 2>&1 || missing+=("$tool") done - if [ ${#MISSING[@]} -gt 0 ]; then - echo "Installing: ${MISSING[*]}" - sudo apt-get update -qq - sudo apt-get install -qq --yes "${MISSING[@]}" - fi - - if [ ! -x "$HOME/.cargo/bin/rustup" ]; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --no-modify-path --default-toolchain none + for pkg in build-essential libgmp-dev libpulse0 libssl-dev libx11-xcb1 pkg-config; do + dpkg -s "$pkg" >/dev/null 2>&1 || missing+=("$pkg") + done + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Self-hosted runner image is missing: ${missing[*]}" + echo "::error::Provision these in the pinned runner image; CI jobs must not install host packages." + exit 1 fi - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - name: Validate executable SDK parity manifest run: | python3 scripts/check_sdk_parity_manifest.py python3 -m unittest discover -s scripts/tests -p 'test_*.py' - - name: Verify JDK 17 + - name: Verify repository-pinned JDK run: | JAVA_HOME_RESOLVED=$(dirname "$(dirname "$(readlink -f "$(command -v java)")")") JAVA_VERSION_OUTPUT=$("$JAVA_HOME_RESOLVED/bin/java" -version 2>&1) printf '%s\n' "$JAVA_VERSION_OUTPUT" - printf '%s\n' "$JAVA_VERSION_OUTPUT" | grep -Eq 'version "17([.]|\")' + printf '%s\n' "$JAVA_VERSION_OUTPUT" | grep -E "version \"${CI_JAVA_MAJOR}([.]|\")" echo "JAVA_HOME=$JAVA_HOME_RESOLVED" >> "$GITHUB_ENV" echo "$JAVA_HOME_RESOLVED/bin" >> "$GITHUB_PATH" - - name: Set up Android SDK - uses: android-actions/setup-android@v3 - with: - packages: >- - platforms;android-35 - build-tools;35.0.0 - ndk;28.1.13356709 - platform-tools - - - name: Set up Rust toolchain - uses: dtolnay/rust-toolchain@stable + - name: Verify prebaked Android SDK + run: | + set -euo pipefail + test "${ANDROID_HOME:-}" = /opt/android-sdk + test -x "$ANDROID_HOME/ndk/$CI_ANDROID_NDK/ndk-build" + test -x "$ANDROID_HOME/build-tools/$CI_ANDROID_BUILD_TOOLS/aapt2" + test -f "$ANDROID_HOME/platforms/android-$CI_ANDROID_API/android.jar" + test -f "$ANDROID_HOME/system-images/android-$CI_ANDROID_API/default/x86_64/system.img" + command -v ci-android-emulator + command -v adb + # Do not run setup-android/sdkmanager: the SDK is pinned and root-owned. + + - name: Set up repository-pinned Rust toolchain + uses: ./.github/actions/rust with: - targets: x86_64-linux-android + target: x86_64-linux-android + cache: false - # Pinned: this runner is persistent, so an unpinned `cargo install` - # leaves whatever version happened to be current on the day it first ran, - # and every later job silently builds with it. Assert after installing so - # a drifted host fails here instead of somewhere in the NDK build. - - name: Ensure cargo-ndk v4.1.2 is installed + # Pinned: this runner is persistent, so cargo-ndk is provisioned in the + # image and checked here before the NDK build can start. + - name: Verify repository-pinned cargo-ndk run: | set -euo pipefail - if ! cargo ndk --version 2>/dev/null | grep -qx 'cargo-ndk 4.1.2'; then - cargo install cargo-ndk --version 4.1.2 --locked --force - fi cargo ndk --version - cargo ndk --version | grep -qx 'cargo-ndk 4.1.2' + cargo ndk --version | grep -Fx "cargo-ndk $CI_CARGO_NDK_VERSION" - - name: Ensure protoc v32.0 is installed (repo-standard; apt's 3.21 breaks tenderdash-proto) + - name: Verify repository-pinned protoc run: | set -euo pipefail - if ! protoc --version 2>/dev/null | grep -qx 'libprotoc 32.0'; then - curl -fsSL -o /tmp/protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-x86_64.zip - sudo unzip -o /tmp/protoc.zip -d /usr/local 'bin/protoc' 'include/*' - fi protoc --version - # A stale protoc earlier on PATH would shadow the one just unpacked - # into /usr/local; catch that here rather than in a codegen failure. - protoc --version | grep -qx 'libprotoc 32.0' + # protoc is part of the runner image. Installing it into /usr/local + # from a job required sudo and made the CI container mutable. + protoc --version | grep -Fx "libprotoc $CI_PROTOC_VERSION" + + # KVM is the only host device this job needs. The container receives it + # through the kvm group; no Docker socket or host package-management + # access is required. + - name: Verify KVM access + run: | + test -c /dev/kvm + test -r /dev/kvm + test -w /dev/kvm + ls -l /dev/kvm - name: Build native library (x86_64, dev profile) working-directory: packages/kotlin-sdk - env: - ANDROID_NDK_HOME: ${{ env.ANDROID_SDK_ROOT }}/ndk/28.1.13356709 run: ./build_android.sh --abi x86_64 --profile dev --verify - name: Setup Gradle @@ -144,79 +182,9 @@ jobs: working-directory: packages/kotlin-sdk run: ./gradlew :sdk:compileDebugAndroidTestKotlin --stacktrace - - name: Verify KVM access - run: | - test -c /dev/kvm - test -r /dev/kvm - test -w /dev/kvm - ls -l /dev/kvm - - - name: Align Android emulator configuration paths - run: | - # android-emulator-runner puts AVD data in $HOME/.android/avd. - # An inherited user/emulator-home override can make avdmanager - # write the .ini elsewhere, leaving the emulator unable to find it. - mkdir -p "$HOME/.android/avd" - { - printf 'ANDROID_USER_HOME=%s/.android\n' "$HOME" - printf 'ANDROID_EMULATOR_HOME=%s/.android\n' "$HOME" - printf 'ANDROID_SDK_HOME=%s\n' "$HOME" - } >> "$GITHUB_ENV" - - - name: Run instrumented FFI smoke test (API 35 emulator) - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 35 - arch: x86_64 - profile: pixel_6 - # Avoid sharing the default "test" AVD with other jobs on this host. - avd-name: platform-${{ github.run_id }}-${{ github.run_attempt }} - disable-animations: true - emulator-options: -no-snapshot -no-window -no-audio -no-boot-anim -camera-back none -dns-server 8.8.8.8,1.1.1.1 - working-directory: packages/kotlin-sdk - script: | - # NOTE: android-emulator-runner executes this script line by line — - # each line is its own `sh -c` with no shared state — so every - # command must be self-contained on a single line (multi-line loops - # or cross-line variables silently fail). - - # Keep the screen on and never time out. THIS is the fix: the flake - # was the emulator screen turning off mid-run and RE-LOCKING the - # device. The mnemonic MASTER_ALIAS AES key is - # setUnlockedDeviceRequired(true), so once the device re-locks even - # an ENCRYPT throws `InvalidKeyException: Keystore operation failed` - # (see WalletStorage.storeMnemonic). Unlocking alone is not enough — - # a slower suite (more instrumented tests) crosses the screen-off - # deadline before its wallet tests run, which is why this branch - # failed where lighter ones passed. Prevent the re-lock outright. - adb shell settings put system screen_off_timeout 2147483647 - adb shell svc power stayon true - - # Enroll a device-wide secure lock screen (PIN) — Android Keystore - # refuses to generate an auth-required key - # (setUserAuthenticationRequired(true), used for the identity-key - # KEYS_ALIAS RSA pair) without one enrolled, even though nothing - # here ever prompts for it (private-key ENCRYPT is never auth-gated; - # only DECRYPT is). - adb shell locksettings set-pin 1234 - - # Unlock the keyguard. With the screen kept on above, the device now - # stays unlocked for the whole run instead of re-locking. - # Credential acceptance and keyguard dismissal can race during a - # cold emulator boot. Retry the complete sequence atomically, then - # fail loudly before tests if the device never reaches unlocked. - for attempt in 1 2 3; do adb shell input keyevent KEYCODE_WAKEUP; adb shell wm dismiss-keyguard; adb shell input text 1234; adb shell input keyevent KEYCODE_ENTER; sleep 2; adb shell wm dismiss-keyguard; sleep 1; adb shell dumpsys trust | grep -q 'deviceLocked=0' && exit 0; done; echo "::error::Emulator is still locked (deviceLocked=1); Keystore-backed tests would fail spuriously."; adb shell dumpsys trust; exit 1 - - ./gradlew :sdk:connectedDebugAndroidTest --stacktrace - - - name: Remove this run's emulator device - if: always() - env: - CI_AVD_NAME: platform-${{ github.run_id }}-${{ github.run_attempt }} - run: | - if [ -f "$HOME/.android/avd/$CI_AVD_NAME.ini" ]; then - avdmanager delete avd --name "$CI_AVD_NAME" - fi + - name: Run instrumented FFI smoke test (prebaked emulator) + working-directory: packages/kotlin-sdk + run: ci-android-emulator bash ../../.github/scripts/kotlin-instrumented-tests.sh - name: Upload test reports on failure if: failure() diff --git a/.github/workflows/kotlin-sdk-nightly.yml b/.github/workflows/kotlin-sdk-nightly.yml index c53510ddc11..14fab4520a1 100644 --- a/.github/workflows/kotlin-sdk-nightly.yml +++ b/.github/workflows/kotlin-sdk-nightly.yml @@ -54,8 +54,8 @@ jobs: restore-keys: | kotlin-sdk-cargo- - - name: Install cargo-ndk - run: cargo install cargo-ndk --locked + - name: Install pinned cargo-ndk v4.1.2 (ephemeral runner) + run: cargo install cargo-ndk --version 4.1.2 --locked - name: Install protoc v32.0 (repo-standard; apt's 3.21 breaks tenderdash-proto) run: | diff --git a/.github/workflows/tests-build-js.yml b/.github/workflows/tests-build-js.yml index 75d1324ee40..992ab762929 100644 --- a/.github/workflows/tests-build-js.yml +++ b/.github/workflows/tests-build-js.yml @@ -5,7 +5,8 @@ jobs: build-js: name: Build JS runs-on: ubuntu-24.04 - timeout-minutes: 15 + # A cold build can finish near 15 minutes; leave time for artifact upload. + timeout-minutes: 20 steps: - uses: softwareforgood/check-artifact-v4-existence@v0 id: check-artifact diff --git a/.github/workflows/tests-rs-wallet.yml b/.github/workflows/tests-rs-wallet.yml index 386d6bfe7be..a63e96c4fe5 100644 --- a/.github/workflows/tests-rs-wallet.yml +++ b/.github/workflows/tests-rs-wallet.yml @@ -131,7 +131,7 @@ jobs: - name: Find unused dependencies run: | - cargo install cargo-machete 2>/dev/null || true + test "$(cargo machete --version | awk '{print $NF}')" = "$CI_CARGO_MACHETE_VERSION" cargo machete - name: Detect immutable structure changes diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index fb5632dc6da..e9cb35fed0f 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -26,6 +26,32 @@ on: default: false jobs: + select-runner: + name: Select compatible runner image + if: >- + github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name == github.repository + || github.event.pull_request.head.repo.owner.login == 'thepastaclaw' + runs-on: ubuntu-24.04 + # Allow the separate 90-minute build and publication/queue window to finish. + timeout-minutes: 135 + permissions: + contents: read + pull-requests: read + statuses: read + actions: read + outputs: + labels: ${{ steps.select.outputs.labels }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Select provisioned pool or wait for the exact PR candidate + id: select + env: + GH_TOKEN: ${{ github.token }} + run: python3 .github/scripts/runner-image.py select --kind rust + test: name: Tests # Scheduled onto whichever self-hosted runner is free — the macOS boxes or @@ -33,7 +59,8 @@ jobs: # runners; pairing it with `self-hosted` keeps the job off GitHub-hosted # runners entirely, so untrusted code never reaches a hosted Linux VM by # way of a label collision. - runs-on: [self-hosted, rust-ci] + needs: select-runner + runs-on: ${{ fromJSON(needs.select-runner.outputs.labels) }} # Fork PRs must not execute on any persistent runner, macOS or Linux. if: >- github.event_name != 'pull_request' @@ -130,43 +157,29 @@ jobs: done exit "$missing" - # clang, llvm and libsnappy are installed by ./.github/actions/rust on - # Linux; this covers what the rest of the job needs and what a bare - # self-hosted image doesn't ship. Every branch is a no-op once the - # persistent runner has been provisioned by the first run. - - name: Install build dependencies (Linux) + # The persistent runner image is the versioned CI environment. Runtime + # apt/sudo would let a job mutate the runner and is unnecessary once the + # image contract is provisioned. + - name: Verify build dependencies (Linux) if: runner.os == 'Linux' run: | set -euo pipefail - MISSING=() - for pkg in build-essential cmake libgmp-dev libssl-dev pkg-config jq zip; do - dpkg -s "$pkg" >/dev/null 2>&1 || MISSING+=("$pkg") + missing=() + for tool in clang cmake gh jq llvm-config rustup zip; do + command -v "$tool" >/dev/null 2>&1 || missing+=("$tool") done - if [ ${#MISSING[@]} -gt 0 ]; then - echo "Installing: ${MISSING[*]}" - sudo apt-get update -qq - sudo apt-get install -qq --yes "${MISSING[@]}" - fi - - # Needed by the immutable-structure check below. - if ! command -v gh >/dev/null 2>&1; then - sudo apt-get install -qq --yes gh || { - curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ - | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null - sudo apt-get update -qq - sudo apt-get install -qq --yes gh - } + for pkg in build-essential clang libgmp-dev libsnappy-dev libssl-dev llvm pkg-config; do + dpkg -s "$pkg" >/dev/null 2>&1 || missing+=("$pkg") + done + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Self-hosted runner image is missing: ${missing[*]}" + echo "::error::Provision these in the pinned runner image; CI jobs must not install host packages." + exit 1 fi - # dtolnay/rust-toolchain drives rustup; it must already exist. - if ! command -v rustup >/dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --no-modify-path --default-toolchain none - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - fi + # rustup is prebaked; Setup Rust may install the repository-selected + # toolchain under the runner user's home, without root. - name: Setup Rust uses: ./.github/actions/rust @@ -174,26 +187,17 @@ jobs: cache: false components: llvm-tools, rustfmt, clippy - # Install only when missing, and fail HERE, loudly, if the tool still - # doesn't work afterwards — the previous `cargo install ... 2>/dev/null - # || true` silently swallowed a failed nextest install on a freshly - # provisioned runner, and the job then died 10 minutes later in the - # test step with "no such command: nextest". cargo-llvm-cov is only - # needed by the nightly coverage run. - - name: Install cargo-llvm-cov + # These helpers are part of the pinned runner image. Version checks make + # image drift fail before the test phase instead of installing tools from + # a job or silently swallowing a failed installation. Coverage tooling + # is only needed when the caller requests an instrumented run. + - name: Verify repository-pinned cargo-llvm-cov if: ${{ inputs.coverage }} - run: | - if ! cargo llvm-cov --version >/dev/null 2>&1; then - cargo install cargo-llvm-cov --locked - fi - cargo llvm-cov --version + run: cargo llvm-cov --version | grep -Fx "cargo-llvm-cov $CI_CARGO_LLVM_COV_VERSION" - - name: Install cargo-nextest - run: | - if ! cargo nextest --version >/dev/null 2>&1; then - cargo install cargo-nextest --locked - fi - cargo nextest --version + - name: Verify repository-pinned cargo-nextest + # Release binaries include commit/host metadata after the version. + run: test "$(cargo nextest --version | awk 'NR == 1 {print $2}')" = "$CI_CARGO_NEXTEST_VERSION" - name: Check formatting run: cargo fmt --check --all @@ -219,7 +223,7 @@ jobs: - name: Find unused dependencies run: | - cargo install cargo-machete 2>/dev/null || true + test "$(cargo machete --version | awk '{print $NF}')" = "$CI_CARGO_MACHETE_VERSION" cargo machete # The transport-free cuts are how embedders with their own networking diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f1ad788ecfc..b8016259dcc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -76,6 +76,11 @@ jobs: - name: Verify self-hosted Swift runner policy run: python3 .github/scripts/check-swift-self-hosted-runner.py + - name: Verify candidate-image routing and manifest inputs + run: | + python3 .github/scripts/runner-image.py env + python3 -m unittest discover -s .github/scripts/tests -v + - uses: dorny/paths-filter@v4 id: filter-js if: ${{ github.event_name != 'workflow_dispatch' }} @@ -104,6 +109,9 @@ jobs: - .github/workflows/tests-rs-wallet.yml - .github/workflows/tests.yml - .github/scripts/check-wallet-closure.py + - .github/runner-requirements.json + - .github/scripts/runner-image.py + - .github/actions/rust/** - uses: dorny/paths-filter@v4 id: filter-e2e @@ -286,7 +294,7 @@ jobs: exit 0 fi - if echo "$CHANGED" | grep -qE '(^|/)Cargo\.(toml|lock)$|^rust-toolchain\.toml$|^\.github/actions/rust/|^\.github/workflows/tests\.yml$|^\.github/workflows/tests-rs-workspace\.yml$'; then + if echo "$CHANGED" | grep -qE '(^|/)Cargo\.(toml|lock)$|^rust-toolchain\.toml$|^\.github/runner-requirements\.json$|^\.github/scripts/runner-image\.py$|^\.github/actions/rust/|^\.github/workflows/tests\.yml$|^\.github/workflows/tests-rs-workspace\.yml$'; then echo "shielded-changed=true" >> "$GITHUB_OUTPUT" echo "Build configuration changed — shielded tests will run" exit 0 From 9c39e66596fe690d5cbcf474c4def78e550f2728 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 00:01:44 +0700 Subject: [PATCH 036/113] docs(platform): say why in-place edits to shipped generations are inert (#5054) Co-authored-by: Claude Opus 5.5 --- .../class_methods/try_from_schema/common/mod.rs | 12 +++++++++++- .../engine/finalize_block_proposal/v0/mod.rs | 4 +++- .../masternode_vote/balance/v0/mod.rs | 5 ++++- .../src/drive/document/index_level_tree_types.rs | 9 +++++++++ .../src/drive/document/ranked_index_tree_type.rs | 12 ++++++++++++ packages/rs-platform-value/src/eq.rs | 7 +++++++ 6 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 9266e6938f8..9fa64c4609e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -1196,6 +1196,11 @@ fn parse_indices( // storage level), so two indexes sharing a grid on one field share one // level's subtrees — and a level cannot have two lifecycles. Identical // grids must declare identical TTLs (including both declaring none). + // + // Document type generations 1-2 (protocol versions 9-13) run this loop too, but only the + // generation 3 index grammar admits `timeRange`, so every index they parse has + // `time_range: None` and the loop changes nothing for them. Generation 0 (protocol + // versions 1-8) parses its indices inline and never reaches this loop. for (name_a, index_a) in indices.iter() { let Some(transform_a) = &index_a.time_range else { continue; @@ -1461,7 +1466,12 @@ fn parse_token_costs( .transpose()? .unwrap_or(DocumentActionTokenEffect::TransferTokenToContractOwner); // Whether a transition may skip the token payment and have its signer pay - // the gas in credits instead (the v3 meta-schema admits the flag) + // the gas in credits instead. Only the v3 meta-schema admits the flag. + // Document type generations 1-2 (protocol versions 9-13) also run this + // parser, but their meta-schemas (v0-v2) set `additionalProperties: false` + // on `documentActionTokenCost`, so no contract they accept carries the key + // and this reads `false` for them, as before the flag existed. Generation 0 + // (protocol versions 1-8) never reaches this parser. let optional = action_cost .get_optional_bool("optional")? .unwrap_or_default(); diff --git a/packages/rs-drive-abci/src/execution/engine/finalize_block_proposal/v0/mod.rs b/packages/rs-drive-abci/src/execution/engine/finalize_block_proposal/v0/mod.rs index 2a13cc17780..0c07f6ecbe0 100644 --- a/packages/rs-drive-abci/src/execution/engine/finalize_block_proposal/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/engine/finalize_block_proposal/v0/mod.rs @@ -272,7 +272,9 @@ where // Withdrawal transactions pooled in this block (or left over from a backlog) wait for // the next block to sign them. Ask Tenderdash for that block right away instead of - // after the empty-block interval. + // after the empty-block interval. Added in place to this shipped generation: the read + // is unbilled and only sets a hint for Tenderdash, so it changes no fee, state or app + // hash at any protocol version. let propose_next_block_immediately = self.has_pending_withdrawal_work(Some(transaction), platform_version)?; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs index b3e93ea69ce..bdabab16e3d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs @@ -65,7 +65,10 @@ impl MasternodeVoteTransitionBalanceValidationV0 for MasternodeVoteTransition { // What executing the vote deducts from the fund. Until 4.2 the vote's minimum fee was // required here instead, a smaller amount, so a fund between the two passed this check - // and the vote failed inside execution. + // and the vote failed inside execution. Edited in place: it cannot change a block at any + // protocol version, because a vote refused here is refused unpaid and a vote that failed + // inside execution was an internal error, and both are left out of every block (see the + // prefunded balance pre-check in the state transition processor). let single_vote_cost = platform_version .fee_version .vote_resolution_fund_fees diff --git a/packages/rs-drive/src/drive/document/index_level_tree_types.rs b/packages/rs-drive/src/drive/document/index_level_tree_types.rs index 2245319577f..dd9593a88d8 100644 --- a/packages/rs-drive/src/drive/document/index_level_tree_types.rs +++ b/packages/rs-drive/src/drive/document/index_level_tree_types.rs @@ -224,6 +224,15 @@ pub(crate) fn time_range_index_keys<'a>( /// × stored/indexOnly): the insert side creates the tree and the delete /// side emits `EstimatedLayerInformation` describing it, and any drift /// produces dry-run fees that disagree with applied fees. +/// +/// Shipped generations depend on this function: +/// `add_reference_for_index_level_for_contract_operations` v0 (protocol +/// versions 1-14) and `remove_reference_for_index_level_for_contract_operations` +/// v0 (protocol versions 1-13) call it for every stored-type index terminal; +/// protocol version 14's remove-reference v1 calls it too. +/// Changing what it returns for an index shape protocol versions 1-13 can +/// declare changes the trees and fees of those versions; make such a change a +/// new versioned method instead of editing this function. pub(crate) fn terminal_member_tree_type(index_type: &IndexLevelTypeInfo) -> TreeType { let count_provable = matches!( index_type.countable, diff --git a/packages/rs-drive/src/drive/document/ranked_index_tree_type.rs b/packages/rs-drive/src/drive/document/ranked_index_tree_type.rs index a514cc63581..c7b1f630c47 100644 --- a/packages/rs-drive/src/drive/document/ranked_index_tree_type.rs +++ b/packages/rs-drive/src/drive/document/ranked_index_tree_type.rs @@ -127,6 +127,10 @@ pub(crate) fn ranked_property_name_tree_type( /// Callers pass the `has_index_with_type()` of the level *named after the /// property* — `None` for pure prefix levels, which resolve to /// `(NormalTree, [])`. +/// +/// Shipped generations depend on this function through +/// [`property_name_tree_type_and_ranked_axes_for_level`]: see the note there +/// before changing what it returns. pub(crate) fn property_name_tree_type_and_ranked_axes( index_level_info: Option<&IndexLevelTypeInfo>, ) -> Result<(TreeType, Vec), Error> { @@ -163,6 +167,14 @@ pub(crate) fn property_name_tree_type_and_ranked_axes( /// rs-dpp's structural validation guarantees no index terminates at a /// grouping or propagating level; both fail closed here on a stamped /// terminator rather than pick one of two contradictory layouts. +/// +/// Shipped generations depend on this function: the `insert_contract` v0 and +/// `update_contract` v0 operations call it to choose the tree type of every +/// top-level index level they create, and every later generation of both +/// composes those operations, so every protocol version reaches it. Changing what it returns +/// for an index level protocol versions 1-13 can declare changes the trees +/// and fees of those versions; make such a change a new versioned method +/// instead of editing this function. pub(crate) fn property_name_tree_type_and_ranked_axes_for_level( level: &IndexLevel, ) -> Result<(TreeType, Vec), Error> { diff --git a/packages/rs-platform-value/src/eq.rs b/packages/rs-platform-value/src/eq.rs index f32943f1a70..d76c6121f06 100644 --- a/packages/rs-platform-value/src/eq.rs +++ b/packages/rs-platform-value/src/eq.rs @@ -159,6 +159,13 @@ impl Value { /// stored at a narrower width, or an object whose members were /// reordered by schema position, still compares equal. /// * Otherwise falls back to normal `==` (`PartialEq`) behaviour. + /// + /// Shipped generations call this at every protocol version: the document + /// replace transition transformer v0 uses it to decide which fields a + /// replace changed, and `is_equal_ignoring_timestamps` v0 uses it when a + /// client verifies a state transition proof. A change to its result for + /// some pair of values changes their output everywhere; make such a change + /// a new versioned method instead of editing this function. pub fn equal_underlying_data(&self, other: &Value) -> bool { // 1) bytes-like cross-variant equality if let (Ok(a), Ok(b)) = (self.as_bytes_slice(), other.as_bytes_slice()) { From 5c8c4dd254027f5c96ff780dd9fb0270831cad44 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 00:02:05 +0700 Subject: [PATCH 037/113] refactor(platform): fold DRIVE_ABCI_QUERY_VERSIONS_V3 into V2 (#5057) Co-authored-by: Claude Opus 5.5 --- book/src/versioning/feature-versions.md | 15 +++-- book/src/versioning/platform-version.md | 2 +- .../drive_abci_query_versions/mod.rs | 1 - .../drive_abci_query_versions/v2.rs | 55 +++++++++++++------ .../drive_abci_query_versions/v3.rs | 46 ---------------- .../rs-platform-version/src/version/v14.rs | 6 +- 6 files changed, 50 insertions(+), 75 deletions(-) delete mode 100644 packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v3.rs diff --git a/book/src/versioning/feature-versions.md b/book/src/versioning/feature-versions.md index 0301f170b06..f2395089285 100644 --- a/book/src/versioning/feature-versions.md +++ b/book/src/versioning/feature-versions.md @@ -485,16 +485,19 @@ slots edited. Newer ones use struct update syntax, so that the file *is* the diff: ```rust -// packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v3.rs +// packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v2.rs -/// Differs from v2 in exactly one slot: +/// Differs from v1 in two slots. /// `document_query_helpers.compute_aggregate_mode_and_check_limit` is 2 -/// rather than 1. That is the boolean-`HAVING` routing gate. ... -pub const DRIVE_ABCI_QUERY_VERSIONS_V3: DriveAbciQueryVersions = DriveAbciQueryVersions { +/// rather than 0: the ranked and boolean-`HAVING` routing gate. ... +pub const DRIVE_ABCI_QUERY_VERSIONS_V2: DriveAbciQueryVersions = DriveAbciQueryVersions { document_query_helpers: DriveAbciDocumentQueryHelperVersions { compute_aggregate_mode_and_check_limit: 2, }, - ..DRIVE_ABCI_QUERY_VERSIONS_V2 + data_contract_query_helpers: DriveAbciDataContractQueryHelperVersions { + latest_versions_read: 1, + }, + ..DRIVE_ABCI_QUERY_VERSIONS_V1 }; ``` @@ -564,7 +567,7 @@ version/ v1.rs .. v10.rs drive_abci_query_versions/ mod.rs - v0.rs .. v3.rs + v0.rs .. v2.rs drive_abci_withdrawal_constants/ mod.rs # DriveAbciWithdrawalConstants (parameters, not method versions) v1.rs .. v3.rs diff --git a/book/src/versioning/platform-version.md b/book/src/versioning/platform-version.md index e7317d3b924..32e7db207f5 100644 --- a/book/src/versioning/platform-version.md +++ b/book/src/versioning/platform-version.md @@ -171,7 +171,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { methods: DRIVE_ABCI_METHOD_VERSIONS_V10, // changed: records the per-block total credits history validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo validation withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, // changed: prune bound for the total credits history - query: DRIVE_ABCI_QUERY_VERSIONS_V3, // changed: ranked + boolean-HAVING routing gate + query: DRIVE_ABCI_QUERY_VERSIONS_V2, // changed: ranked + boolean-HAVING routing gate checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, }, dpp: DPPVersion { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/mod.rs index 17483a5b79c..fff1a461416 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/mod.rs @@ -1,7 +1,6 @@ pub mod v0; pub mod v1; pub mod v2; -pub mod v3; use versioned_feature_core::{FeatureVersion, FeatureVersionBounds}; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v2.rs index f70c889fcf6..9ed60e2d313 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v2.rs @@ -1,29 +1,48 @@ use crate::version::drive_abci_versions::drive_abci_query_versions::v1::DRIVE_ABCI_QUERY_VERSIONS_V1; use crate::version::drive_abci_versions::drive_abci_query_versions::{ - DriveAbciDocumentQueryHelperVersions, DriveAbciQueryVersions, + DriveAbciDataContractQueryHelperVersions, DriveAbciDocumentQueryHelperVersions, + DriveAbciQueryVersions, }; -/// Version 2 of the Drive ABCI query versions. +/// Version 2 of the Drive ABCI query versions, selected by protocol version 14. /// -/// Differs from v1 in exactly one slot: -/// `document_query_helpers.compute_aggregate_mode_and_check_limit` is 1 -/// rather than 0. That is the ranked-routing gate. The v0 helper has no -/// ranked path at all; the v1 helper routes a grouped aggregate whose -/// single `order_by` names the selected aggregate (`ORDER BY -/// [ASC|DESC] LIMIT n [OFFSET m]`) to the ranked executor, and otherwise -/// behaves exactly as v0. Both reject every non-empty `having` with -/// "HAVING clause is not yet implemented". +/// Differs from v1 in two slots. /// -/// Keeping the flip in v14's own table is what lets a mixed-version network -/// agree: protocol version 13 and earlier keep the v1 table, so those nodes -/// still reject ranked queries, while v14 nodes answer them. The wire -/// surface is unchanged — `document_query` stays at v1 because -/// `GetDocumentsRequestV1` already carries `selects` / `group_by` / -/// `having`, and the ranked *response* shape is an additive -/// `ResultData.ranked` variant older clients simply never receive. +/// `document_query_helpers.compute_aggregate_mode_and_check_limit` is 2 +/// rather than 0. That opens two routes on the v1 document-query handler: +/// the ranked path, a grouped aggregate whose single `order_by` names the +/// selected aggregate (`ORDER BY [ASC|DESC] LIMIT n [OFFSET m]`), +/// served by the ranked executor; and the boolean-`HAVING` range path, a +/// grouped aggregate carrying exactly one `having` clause +/// (`GROUP BY p HAVING LIMIT n`), served as a +/// value-bounded range read of the covering ranked index's axis secondary. +/// Everything else, including multi-clause `having` and `having` on a +/// select with no ranked axis, is rejected as before ("HAVING clause is not +/// yet implemented"). +/// +/// `data_contract_query_helpers.latest_versions_read` is 1 rather than 0: +/// from protocol version 14 every contract carries a four-byte version item +/// beside it (`[64, id, 2] / 64`, backfilled on the first block of the version), +/// so `getDataContractsLatestVersions` without `include_contracts` reads that +/// item and proves it instead of the contracts. The tables protocol versions +/// 1 to 13 select keep helper version 0, which reads and proves the contracts +/// a state without the items still has. +/// +/// Mixed-network safety comes from the shipped tables: protocol versions +/// 1-11 select `DRIVE_ABCI_QUERY_VERSIONS_V0` and 12-13 select +/// `DRIVE_ABCI_QUERY_VERSIONS_V1`. Both use helper version 0 and reject +/// ranked and `HAVING` shapes, so nodes agree until the protocol version 14 +/// upgrade carries. The wire surface is unchanged: `document_query` stays at +/// v1 because `GetDocumentsRequestV1` already carries `selects` / `group_by` +/// / `having`, and the ranked and range responses reuse the additive +/// `ResultData.ranked` entries shape (with `skipped` unset for a range page, +/// which has no rank base), which older clients never receive. pub const DRIVE_ABCI_QUERY_VERSIONS_V2: DriveAbciQueryVersions = DriveAbciQueryVersions { document_query_helpers: DriveAbciDocumentQueryHelperVersions { - compute_aggregate_mode_and_check_limit: 1, + compute_aggregate_mode_and_check_limit: 2, + }, + data_contract_query_helpers: DriveAbciDataContractQueryHelperVersions { + latest_versions_read: 1, }, ..DRIVE_ABCI_QUERY_VERSIONS_V1 }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v3.rs deleted file mode 100644 index 0da6b62f902..00000000000 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_query_versions/v3.rs +++ /dev/null @@ -1,46 +0,0 @@ -use crate::version::drive_abci_versions::drive_abci_query_versions::v2::DRIVE_ABCI_QUERY_VERSIONS_V2; -use crate::version::drive_abci_versions::drive_abci_query_versions::{ - DriveAbciDataContractQueryHelperVersions, DriveAbciDocumentQueryHelperVersions, - DriveAbciQueryVersions, -}; - -/// Version 3 of the Drive ABCI query versions. -/// -/// Differs from v2 in two slots. -/// -/// `data_contract_query_helpers.latest_versions_read` is 1 rather than 0: -/// from protocol version 14 every contract carries a four-byte version item -/// beside it (`[64, id, 2] / 64`, backfilled on the first block of the version), -/// so `getDataContractsLatestVersions` without `include_contracts` reads that -/// item and proves it instead of the contracts. The tables protocol versions -/// 1 to 13 select keep helper version 0, which reads and proves the contracts -/// a state without the items still has. -/// -/// `document_query_helpers.compute_aggregate_mode_and_check_limit` is 2 -/// rather than 1. That is the boolean-`HAVING` routing gate. The v1 -/// helper rejects every non-empty `having` ("HAVING clause is not yet -/// implemented"); the v2 helper routes a grouped aggregate carrying -/// exactly one `having` clause (`GROUP BY p HAVING -/// LIMIT n`) to the having-range executor, which serves it as a -/// value-bounded range read of the covering ranked index's axis -/// secondary. Everything else — including multi-clause `having` and -/// `having` on a select with no ranked axis — keeps the v1 behavior. -/// -/// Mixed-network safety comes from the shipped tables: protocol -/// versions 1–11 select `DRIVE_ABCI_QUERY_VERSIONS_V0`, and versions -/// 12–13 select `DRIVE_ABCI_QUERY_VERSIONS_V1`. Both use helper -/// version 0 and reject ranked and `HAVING` shapes, so nodes agree -/// until the PV14 upgrade carries. The wire surface is unchanged — -/// `GetDocumentsRequestV1.having` has been wire-stable since the v1 -/// document query, and the response reuses the additive -/// `ResultData.ranked` entries shape (with `skipped` unset, since a -/// range page has no rank base). -pub const DRIVE_ABCI_QUERY_VERSIONS_V3: DriveAbciQueryVersions = DriveAbciQueryVersions { - document_query_helpers: DriveAbciDocumentQueryHelperVersions { - compute_aggregate_mode_and_check_limit: 2, - }, - data_contract_query_helpers: DriveAbciDataContractQueryHelperVersions { - latest_versions_read: 1, - }, - ..DRIVE_ABCI_QUERY_VERSIONS_V2 -}; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 5c0236287d8..a8289c2d0ef 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -16,7 +16,7 @@ use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; use crate::version::dpp_versions::DPPVersion; use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1; use crate::version::drive_abci_versions::drive_abci_method_versions::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; -use crate::version::drive_abci_versions::drive_abci_query_versions::v3::DRIVE_ABCI_QUERY_VERSIONS_V3; +use crate::version::drive_abci_versions::drive_abci_query_versions::v2::DRIVE_ABCI_QUERY_VERSIONS_V2; use crate::version::drive_abci_versions::drive_abci_structure_versions::v2::DRIVE_ABCI_STRUCTURE_VERSIONS_V2; use crate::version::drive_abci_versions::drive_abci_validation_versions::v10::DRIVE_ABCI_VALIDATION_VERSIONS_V10; use crate::version::drive_abci_versions::drive_abci_withdrawal_constants::v3::DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3; @@ -181,7 +181,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// moderation election (an `electedCharter` contest) runs on its target /// contract's join and vote windows, which the document create join check /// and the contested insert read. -/// * `DRIVE_ABCI_QUERY_VERSIONS_V3` bumps +/// * `DRIVE_ABCI_QUERY_VERSIONS_V2` bumps /// `document_query_helpers.compute_aggregate_mode_and_check_limit` 0 → 2, /// opening two routes on the v1 document-query handler: the ranked path /// (a grouped aggregate whose single `order_by` names the selected @@ -1389,7 +1389,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { methods: DRIVE_ABCI_METHOD_VERSIONS_V10, // changed: records the per-block total credits history for the daily withdrawal limit validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation; the ContractUserModeration gates and the batch transformer's contract_moderation_gate; a contest accepts at most max_contenders_per_contest contenders and maximum_contenders_to_consider rises to 10,000; a contender's fund doubles past 250 contenders and for every 50 more withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, // changed: prune bound for the total credits history - query: DRIVE_ABCI_QUERY_VERSIONS_V3, // changed: ranked + boolean-HAVING routing gate; the v1 handler also resolves IN_TIME_RANGE from committed block time + query: DRIVE_ABCI_QUERY_VERSIONS_V2, // changed: ranked + boolean-HAVING routing gate; the v1 handler also resolves IN_TIME_RANGE from committed block time checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, }, dpp: DPPVersion { From 013dce6830d572aadbd232d94342ab32b5984230 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 00:03:53 +0700 Subject: [PATCH 038/113] test: follow the test conventions in tests added in 4.1 and 4.2 (#5062) Co-authored-by: Claude Opus 5.5 --- .../distribution_function/evaluate.rs | 7 +- .../try_from_schema/v3/keep_history_tests.rs | 10 +- .../class_methods/try_from_schema/v3/mod.rs | 116 +++++++++--------- .../v1/mod.rs | 15 +-- .../src/account_label.rs | 10 +- packages/rs-platform-encryption/src/aes.rs | 6 +- .../src/compact_xpub.rs | 10 +- packages/rs-platform-encryption/src/ecdh.rs | 8 +- .../rs-platform-version/src/version/v14.rs | 44 +------ 9 files changed, 93 insertions(+), 133 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_function/evaluate.rs b/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_function/evaluate.rs index d07240290bf..ea6c13e0210 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_function/evaluate.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_function/evaluate.rs @@ -738,14 +738,15 @@ mod tests { min_value: None, max_value: None, }; - let v14 = PlatformVersion::get(14).expect("v14 must exist"); + let latest = PlatformVersion::latest(); assert_eq!( - v14.dpp + latest + .dpp .token_versions .distribution_function_evaluate_version, 1 ); - assert_eq!(distribution.evaluate(0, 1, v14).unwrap(), 31_402); + assert_eq!(distribution.evaluate(0, 1, latest).unwrap(), 31_402); } #[test] diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/keep_history_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/keep_history_tests.rs index f0ceb8f5dcc..2fad7e6ab76 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/keep_history_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/keep_history_tests.rs @@ -235,7 +235,7 @@ fn should_repair_legacy_keep_history_delete_flag_at_protocol_14() { let repaired = parse_at_version(repair_schema(true, false), 14, true).unwrap(); let result = old .as_ref() - .validate_update(repaired.as_ref(), 2, PlatformVersion::get(14).unwrap()) + .validate_update(repaired.as_ref(), 2, PlatformVersion::latest()) .expect("repair must reach a consensus result"); assert!(result.is_valid(), "repair rejected: {:?}", result.errors); } @@ -276,7 +276,7 @@ fn should_reject_other_delete_and_history_flag_changes_at_protocol_14() { let new = parse_at_version(repair_schema(new_flags.0, new_flags.1), 14, false).unwrap(); let result = old .as_ref() - .validate_update(new.as_ref(), 2, PlatformVersion::get(14).unwrap()) + .validate_update(new.as_ref(), 2, PlatformVersion::latest()) .unwrap(); assert!( !result.is_valid(), @@ -304,7 +304,7 @@ fn should_reject_incompatible_properties_during_keep_history_repair() { .unwrap(); let result = old .as_ref() - .validate_update(new.as_ref(), 2, PlatformVersion::get(14).unwrap()) + .validate_update(new.as_ref(), 2, PlatformVersion::latest()) .unwrap(); assert!( !result.is_valid(), @@ -320,7 +320,7 @@ fn should_reject_mutability_change_during_keep_history_repair() { let new = parse_at_version(schema, 14, true).unwrap(); let result = old .as_ref() - .validate_update(new.as_ref(), 2, PlatformVersion::get(14).unwrap()) + .validate_update(new.as_ref(), 2, PlatformVersion::latest()) .unwrap(); assert!( !result.is_valid(), @@ -353,7 +353,7 @@ fn should_still_validate_property_named_can_be_deleted_during_keep_history_repai let new = parse_at_version(new_schema, 14, true).unwrap(); let result = old .as_ref() - .validate_update(new.as_ref(), 2, PlatformVersion::get(14).unwrap()) + .validate_update(new.as_ref(), 2, PlatformVersion::latest()) .unwrap(); assert!( !result.is_valid(), diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 6fff6574cbd..36f2ef75fd6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -1204,23 +1204,21 @@ mod tests { PlatformVersion::get(13).expect("protocol version 13 exists") } - /// Generation-specific tests must pin a protocol version that actually - /// selects their own generation: `pv14()` silently - /// retargets these tests onto a different parser generation and a - /// different document meta-schema whenever LATEST moves. PV14 is the - /// first protocol version whose `try_from_schema` selects generation 3. - fn pv14() -> &'static PlatformVersion { - PlatformVersion::get(14).expect("protocol version 14 exists") + /// Generation 3 is the latest parser generation, so its tests run at the latest protocol + /// version. When a later protocol version selects a new generation, pin these tests to the + /// last version that selects generation 3 (see the coding conventions). + fn latest() -> &'static PlatformVersion { + PlatformVersion::latest() } - /// PV14 accepts the ranked keywords and carries them onto the parsed index + /// The latest protocol version accepts the ranked keywords and carries them onto the parsed index /// — both when parsed through this generation directly and when reached /// the way production reaches it, through the dispatcher. The dispatcher /// half is what pins that `try_from_schema: 3` actually routes here. #[test] - fn ranked_keywords_accepted_at_pv14() { + fn ranked_keywords_accepted_at_latest() { let schema = ranked_review_schema(vec![("rankedAverageable", true)]); - let v2 = parse_with(schema.clone(), pv14(), true) + let v2 = parse_with(schema.clone(), latest(), true) .expect("meta-schema v3 must accept the ranked index keywords"); let index = v2 @@ -1233,8 +1231,8 @@ mod tests { assert!(index.range_countable && index.range_summable); // Same schema, same platform version, through the real dispatcher. - let dispatched = parse_dispatched(schema, pv14(), true) - .expect("the dispatcher must route PV14 to a generation that accepts the keywords"); + let dispatched = parse_dispatched(schema, latest(), true) + .expect("the dispatcher must route the latest protocol version to a generation that accepts the keywords"); let DocumentType::V2(dispatched) = dispatched else { panic!("generation 3 produces a V2-shaped document type"); }; @@ -1244,7 +1242,7 @@ mod tests { .get("byRestaurant") .expect("index parsed under its name") .ranked_averageable, - "dispatching at PV14 must reach generation 3, not an earlier generation" + "dispatching at the latest protocol version must reach generation 3, not an earlier generation" ); } @@ -1286,13 +1284,14 @@ mod tests { } } - /// Same schema, PV14, no full validation: accepted. Pins that the gate is - /// the parser *generation* and not the validation mode. + /// Same schema, latest protocol version, no full validation: accepted. Pins that the gate + /// is the parser *generation* and not the validation mode. #[test] - fn ranked_keywords_accepted_at_pv14_without_full_validation() { + fn ranked_keywords_accepted_at_latest_without_full_validation() { let schema = ranked_review_schema(vec![("rankedAverageable", true)]); - let v2 = parse_with(schema, pv14(), false) - .expect("PV14 structural parse must accept the ranked keywords"); + let v2 = parse_with(schema, latest(), false).expect( + "the latest protocol version's structural parse must accept the ranked keywords", + ); assert!( v2.indices .get("byRestaurant") @@ -1313,7 +1312,7 @@ mod tests { #[test] fn ranked_countable_satisfied_by_range_averageable_in_meta_schema() { let schema = ranked_review_schema(vec![("rankedCountable", true)]); - let v2 = parse_with(schema, pv14(), true) + let v2 = parse_with(schema, latest(), true) .expect("rangeAverageable satisfies rankedCountable's range prerequisite"); let index = v2 .indices @@ -1381,9 +1380,10 @@ mod tests { #[test] fn ranked_flags_written_out_as_false_do_not_require_a_range_axis() { for key in ["rankedCountable", "rankedSummable", "rankedAverageable"] { - let v2 = parse_with(bare_ranked_index_schema(key, false), pv14(), true).unwrap_or_else( - |e| panic!("`{key}: false` is an opt-out and must pass full validation: {e:?}"), - ); + let v2 = parse_with(bare_ranked_index_schema(key, false), latest(), true) + .unwrap_or_else(|e| { + panic!("`{key}: false` is an opt-out and must pass full validation: {e:?}") + }); let index = v2 .indices @@ -1407,7 +1407,7 @@ mod tests { #[test] fn ranked_flags_set_true_still_require_their_range_axis() { for key in ["rankedCountable", "rankedSummable", "rankedAverageable"] { - let result = parse_with(bare_ranked_index_schema(key, true), pv14(), true); + let result = parse_with(bare_ranked_index_schema(key, true), latest(), true); assert!( result.is_err(), "`{key}: true` with no range axis must be rejected under full validation" @@ -1437,7 +1437,7 @@ mod tests { "rankedCountable": true, }], }); - let result = parse_with(schema, pv14(), false); + let result = parse_with(schema, latest(), false); assert!( result.is_err(), "rankedCountable with no range-count layout must be rejected structurally" @@ -1579,7 +1579,7 @@ mod tests { } fn parse_bound(schema: Value) -> Result { - parse_with(schema, pv14(), true) + parse_with(schema, latest(), true) } /// Count-ranked and sum-ranked indexes share the 8-byte sort key, so both @@ -1968,9 +1968,9 @@ mod tests { /// paths — and the ranked flags land on the index alongside the /// range axes they require. #[test] - fn compound_ranked_index_accepted_at_pv14() { + fn compound_ranked_index_accepted_at_latest() { for full_validation in [true, false] { - let v2 = parse_with(compound_ranked_schema(vec![]), pv14(), full_validation) + let v2 = parse_with(compound_ranked_schema(vec![]), latest(), full_validation) .unwrap_or_else(|e| { panic!( "a compound ranked index must parse \ @@ -2003,7 +2003,7 @@ mod tests { vec![("countable", Value::Text("countable".to_string()))], )]); for full_validation in [true, false] { - let error = parse_with(schema.clone(), pv14(), full_validation).expect_err( + let error = parse_with(schema.clone(), latest(), full_validation).expect_err( "an aggregating index on the ranked compound's full prefix must be rejected", ); let message = format!("{error:?}"); @@ -2031,14 +2031,14 @@ mod tests { "restaurantId", vec![("countable", Value::Text("countable".to_string()))], )]); - parse_with(aggregating_elsewhere, pv14(), true) + parse_with(aggregating_elsewhere, latest(), true) .expect("an aggregating index off the prefix must not conflict"); // A plain index on the prefix property: terminates at [region] // but carries no aggregates, so its value trees stay normal and // no wrapper shell is ever needed. let plain_prefix = compound_ranked_schema(vec![("byRegion", "region", vec![])]); - parse_with(plain_prefix, pv14(), true) + parse_with(plain_prefix, latest(), true) .expect("a non-aggregating index on the prefix must not conflict"); } @@ -2168,7 +2168,7 @@ mod tests { /// structural path, landing in `ranked_countable_at` with the boolean /// terminal axis off. #[test] - fn prefix_ranked_at_accepted_at_pv14() { + fn prefix_ranked_at_accepted_at_latest() { for full_validation in [true, false] { let schema = prefix_at_schema( &["region", "restaurantId"], @@ -2177,7 +2177,7 @@ mod tests { 32, vec![], ); - let v2 = parse_with(schema, pv14(), full_validation).unwrap_or_else(|e| { + let v2 = parse_with(schema, latest(), full_validation).unwrap_or_else(|e| { panic!("the at form must parse (full_validation: {full_validation}): {e}") }); let index = v2.indices.get("byMain").expect("index parsed"); @@ -2189,7 +2189,7 @@ mod tests { /// The array form naming both levels passes the meta-schema and /// parses into both flags — the both-rankings-on-one-index shape. #[test] - fn prefix_ranked_at_array_form_accepted_at_pv14() { + fn prefix_ranked_at_array_form_accepted_at_latest() { for full_validation in [true, false] { let schema = prefix_at_schema( &["region", "restaurantId"], @@ -2204,7 +2204,7 @@ mod tests { 32, vec![], ); - let v2 = parse_with(schema, pv14(), full_validation).unwrap_or_else(|e| { + let v2 = parse_with(schema, latest(), full_validation).unwrap_or_else(|e| { panic!("the array form must parse (full_validation: {full_validation}): {e}") }); let index = v2.indices.get("byMain").expect("index parsed"); @@ -2245,7 +2245,7 @@ mod tests { vec![], ); assert!( - parse_with(schema, pv14(), true).is_err(), + parse_with(schema, latest(), true).is_err(), "meta-schema v3 must demand rangeCountable alongside the at form" ); } @@ -2272,7 +2272,7 @@ mod tests { ] { let schema = prefix_at_schema(&["region", "restaurantId"], value, true, 32, vec![]); assert!( - parse_with(schema, pv14(), true).is_err(), + parse_with(schema, latest(), true).is_err(), "the meta-schema must reject the {label} object form" ); } @@ -2305,7 +2305,7 @@ mod tests { 32, vec![(name, properties, flags.clone())], ); - let error = parse_with(schema, pv14(), full_validation) + let error = parse_with(schema, latest(), full_validation) .expect_err("an index conflicting with the at level must be rejected"); let message = format!("{error:?}"); assert!( @@ -2326,7 +2326,7 @@ mod tests { 32, vec![("byRegionGrade", &["region", "grade"], vec![])], ); - let v2 = parse_with(schema, pv14(), full_validation).unwrap_or_else(|e| { + let v2 = parse_with(schema, latest(), full_validation).unwrap_or_else(|e| { panic!( "a plain continuing sibling must be admitted \ (full_validation: {full_validation}): {e}" @@ -2352,7 +2352,7 @@ mod tests { 32, vec![("byGrade", &["grade"], vec![])], ); - parse_with(schema, pv14(), true) + parse_with(schema, latest(), true) .expect("an index over a different leading property must not conflict"); } @@ -2377,7 +2377,7 @@ mod tests { vec![("countable", Value::Text("countable".to_string()))], )], ); - let error = parse_with(schema, pv14(), full_validation) + let error = parse_with(schema, latest(), full_validation) .expect_err("an aggregating index above the at level must be rejected"); let message = format!("{error:?}"); assert!( @@ -2396,7 +2396,7 @@ mod tests { 32, vec![("byRegion", &["region"], vec![])], ); - parse_with(schema, pv14(), true) + parse_with(schema, latest(), true) .expect("a plain index on the prefix above the at level must not conflict"); } @@ -2412,7 +2412,7 @@ mod tests { 61, vec![], ); - parse_with(schema, pv14(), true).expect("61 characters fits the 247-byte ceiling"); + parse_with(schema, latest(), true).expect("61 characters fits the 247-byte ceiling"); // 62 * 4 = 248 > 247: rejected, naming the at property's bound. let schema = prefix_at_schema( @@ -2422,7 +2422,7 @@ mod tests { 62, vec![], ); - let error = parse_with(schema, pv14(), true) + let error = parse_with(schema, latest(), true) .expect_err("62 characters exceeds the 247-byte ceiling"); let msg = format!("{error:?}"); assert!( @@ -2537,7 +2537,7 @@ mod tests { #[test] fn averageable_sugar_satisfies_ranked_countable_under_full_validation() { let schema = schema_with_index_entry(sugar_multi_axis_index_entry()); - let v2 = parse_with(schema, pv14(), true) + let v2 = parse_with(schema, latest(), true) .expect("the sugar form satisfies every prerequisite in both layers"); let index = v2 .indices @@ -2555,8 +2555,8 @@ mod tests { ("rangeAverageable", Value::Bool(true)), ("rankedSummable", Value::Bool(true)), ])); - let v2 = - parse_with(schema, pv14(), true).expect("rangeAverageable stands in for rangeSummable"); + let v2 = parse_with(schema, latest(), true) + .expect("rangeAverageable stands in for rangeSummable"); let index = v2 .indices .get("storeRating") @@ -2576,7 +2576,7 @@ mod tests { ("rangeSummable", Value::Bool(true)), ("rankedAverageable", Value::Bool(true)), ])); - let v2 = parse_with(schema, pv14(), true) + let v2 = parse_with(schema, latest(), true) .expect("rangeCountable + rangeSummable stand in for rangeAverageable"); let index = v2 .indices @@ -2596,7 +2596,7 @@ mod tests { ("averageable", Value::Text("grade".to_string())), ("rankedCountable", Value::Bool(true)), ])); - let error = parse_with(schema, pv14(), true) + let error = parse_with(schema, latest(), true) .expect_err("a Count ranking needs a range axis, however it is spelled"); let msg = format!("{error:?}"); assert!( @@ -2613,7 +2613,7 @@ mod tests { ("averageable", Value::Text("grade".to_string())), ("rankedCountable", Value::Bool(false)), ])); - let v2 = parse_with(schema, pv14(), true) + let v2 = parse_with(schema, latest(), true) .expect("an explicit rankedCountable: false asks for no ranking axis"); let index = v2 .indices @@ -2630,7 +2630,7 @@ mod tests { fn range_countable_alone_implies_countable_under_full_validation() { let schema = schema_with_index_entry(index_entry(vec![("rangeCountable", Value::Bool(true))])); - let v2 = parse_with(schema, pv14(), true) + let v2 = parse_with(schema, latest(), true) .expect("rangeCountable alone is a complete declaration"); let index = v2 .indices @@ -2647,7 +2647,7 @@ mod tests { let schema = schema_with_index_entry(index_entry(vec![("rangeSummable", Value::Bool(true))])); let error = - parse_with(schema, pv14(), true).expect_err("rangeSummable needs something to sum"); + parse_with(schema, latest(), true).expect_err("rangeSummable needs something to sum"); let msg = format!("{error:?}"); assert!( msg.contains("summable"), @@ -2663,7 +2663,7 @@ mod tests { ("countable", Value::Text("notCountable".to_string())), ("rangeCountable", Value::Bool(true)), ])); - let error = parse_with(schema, pv14(), true) + let error = parse_with(schema, latest(), true) .expect_err("countable: notCountable contradicts rangeCountable: true"); let msg = format!("{error:?}"); assert!( @@ -2703,7 +2703,7 @@ mod tests { Value::Text("rangeCountable".to_string()), Value::Bool(false), )); - let error = parse_with(schema_with_index_entry(entry), pv14(), true) + let error = parse_with(schema_with_index_entry(entry), latest(), true) .expect_err("rangeAverageable: true contradicts an explicit rangeCountable: false"); let msg = format!("{error:?}"); assert!( @@ -2723,7 +2723,7 @@ mod tests { ], index_entry(vec![]), ); - parse_with(schema, pv14(), true).expect( + parse_with(schema, latest(), true).expect( "documentsAverageable implies documentsSummable, so rangeSummable is satisfied", ); @@ -2731,7 +2731,7 @@ mod tests { vec![("rangeSummable", Value::Bool(true))], index_entry(vec![]), ); - let error = parse_with(schema, pv14(), true) + let error = parse_with(schema, latest(), true) .expect_err("a doctype-level rangeSummable with nothing to sum is refused"); let msg = format!("{error:?}"); assert!( @@ -2746,7 +2746,7 @@ mod tests { #[test] fn sugar_form_parses_without_full_validation() { let schema = schema_with_index_entry(sugar_multi_axis_index_entry()); - let v2 = parse_with(schema, pv14(), false) + let v2 = parse_with(schema, latest(), false) .expect("the structural parser is sugar-aware and accepts the short form"); let index = v2 .indices @@ -2819,7 +2819,7 @@ mod tests { "rankedAverageable": true, "rankedCountable": true }); - DataContract::from_json(contract_json(short_form), true, pv14()) + DataContract::from_json(contract_json(short_form), true, latest()) .expect("the sugar short form registers, so the SDK must accept it too"); let no_range_axis = json!({ @@ -2828,7 +2828,7 @@ mod tests { "averageable": "grade", "rankedCountable": true }); - let error = DataContract::from_json(contract_json(no_range_axis), true, pv14()) + let error = DataContract::from_json(contract_json(no_range_axis), true, latest()) .expect_err("a ranking with no range axis is refused by both layers"); let msg = format!("{error:?}"); assert!( diff --git a/packages/rs-dpp/src/withdrawal/document_try_into_asset_unlock_base_transaction_info/v1/mod.rs b/packages/rs-dpp/src/withdrawal/document_try_into_asset_unlock_base_transaction_info/v1/mod.rs index 779e715434c..b727a9380a7 100644 --- a/packages/rs-dpp/src/withdrawal/document_try_into_asset_unlock_base_transaction_info/v1/mod.rs +++ b/packages/rs-dpp/src/withdrawal/document_try_into_asset_unlock_base_transaction_info/v1/mod.rs @@ -126,10 +126,7 @@ mod tests { fn stamped_withdrawal_reserves_output_and_core_fee_from_one_amount() { let amount = 2_000_000_000u64; let tx = withdrawal_document(Some(1), amount) - .try_into_asset_unlock_base_transaction_info( - 1, - PlatformVersion::get(14).expect("platform version 14"), - ) + .try_into_asset_unlock_base_transaction_info(1, PlatformVersion::latest()) .expect("asset unlock info"); assert_eq!( @@ -143,10 +140,7 @@ mod tests { fn unstamped_queued_withdrawal_is_bounded_by_its_reserved_amount() { let amount = 1_000_000u64; let tx = withdrawal_document(None, amount) - .try_into_asset_unlock_base_transaction_info( - 1, - PlatformVersion::get(14).expect("platform version 14"), - ) + .try_into_asset_unlock_base_transaction_info(1, PlatformVersion::latest()) .expect("asset unlock info"); assert_eq!( @@ -202,10 +196,7 @@ mod tests { // 500 duffs: below the 546-duff P2PKH dust threshold, legal under the pre-v12 floor. let amount = 500_000u64; let tx = withdrawal_document(None, amount) - .try_into_asset_unlock_base_transaction_info( - 1, - PlatformVersion::get(14).expect("platform version 14"), - ) + .try_into_asset_unlock_base_transaction_info(1, PlatformVersion::latest()) .expect("a legacy dust amount must convert rather than abort the block"); assert_eq!(tx.base_payload.fee, 0); diff --git a/packages/rs-platform-encryption/src/account_label.rs b/packages/rs-platform-encryption/src/account_label.rs index 9f281be8c60..3f5bd60067c 100644 --- a/packages/rs-platform-encryption/src/account_label.rs +++ b/packages/rs-platform-encryption/src/account_label.rs @@ -113,21 +113,23 @@ pub fn decrypt_account_label( mod tests { use super::*; use crate::ecdh::derive_shared_key_ecdh; - use secp256k1::rand::{thread_rng, RngCore}; + use secp256k1::rand::rngs::StdRng; + use secp256k1::rand::{RngCore, SeedableRng}; use secp256k1::Secp256k1; #[test] fn test_account_label_encryption() { + let mut rng = StdRng::seed_from_u64(4); let secp = Secp256k1::new(); - let (secret1, _public1) = secp.generate_keypair(&mut thread_rng()); - let (_secret2, public2) = secp.generate_keypair(&mut thread_rng()); + let (secret1, _public1) = secp.generate_keypair(&mut rng); + let (_secret2, public2) = secp.generate_keypair(&mut rng); // Derive shared key let shared_key = derive_shared_key_ecdh(&secret1, &public2); // Generate random IV let mut iv = [0u8; 16]; - thread_rng().fill_bytes(&mut iv); + rng.fill_bytes(&mut iv); let label = "My DashPay Account"; diff --git a/packages/rs-platform-encryption/src/aes.rs b/packages/rs-platform-encryption/src/aes.rs index c1609592fd7..3082a3218b4 100644 --- a/packages/rs-platform-encryption/src/aes.rs +++ b/packages/rs-platform-encryption/src/aes.rs @@ -63,13 +63,15 @@ pub fn decrypt_aes_256_cbc( #[cfg(test)] mod tests { use super::*; - use secp256k1::rand::{thread_rng, RngCore}; + use secp256k1::rand::rngs::StdRng; + use secp256k1::rand::{RngCore, SeedableRng}; #[test] fn test_aes_encryption_decryption() { + let mut rng = StdRng::seed_from_u64(2); let key = [0u8; 32]; let mut iv = [0u8; 16]; - thread_rng().fill_bytes(&mut iv); + rng.fill_bytes(&mut iv); let plaintext = b"Hello, DashPay!"; diff --git a/packages/rs-platform-encryption/src/compact_xpub.rs b/packages/rs-platform-encryption/src/compact_xpub.rs index 01816f34006..80101600948 100644 --- a/packages/rs-platform-encryption/src/compact_xpub.rs +++ b/packages/rs-platform-encryption/src/compact_xpub.rs @@ -139,21 +139,23 @@ pub fn parse_compact_xpub(bytes: &[u8]) -> Result { mod tests { use super::*; use crate::ecdh::derive_shared_key_ecdh; - use secp256k1::rand::{thread_rng, RngCore}; + use secp256k1::rand::rngs::StdRng; + use secp256k1::rand::{RngCore, SeedableRng}; use secp256k1::Secp256k1; #[test] fn test_extended_public_key_encryption() { + let mut rng = StdRng::seed_from_u64(3); let secp = Secp256k1::new(); - let (secret1, _public1) = secp.generate_keypair(&mut thread_rng()); - let (_secret2, public2) = secp.generate_keypair(&mut thread_rng()); + let (secret1, _public1) = secp.generate_keypair(&mut rng); + let (_secret2, public2) = secp.generate_keypair(&mut rng); // Derive shared key let shared_key = derive_shared_key_ecdh(&secret1, &public2); // Generate random IV let mut iv = [0u8; 16]; - thread_rng().fill_bytes(&mut iv); + rng.fill_bytes(&mut iv); // DIP-15 compact xpub plaintext (69 bytes). 69 → PKCS7 → 80, + 16-byte // IV = exactly 96 bytes, matching the contract's minItems/maxItems: 96. diff --git a/packages/rs-platform-encryption/src/ecdh.rs b/packages/rs-platform-encryption/src/ecdh.rs index 7e165365eec..0b2ae3bc707 100644 --- a/packages/rs-platform-encryption/src/ecdh.rs +++ b/packages/rs-platform-encryption/src/ecdh.rs @@ -28,16 +28,18 @@ pub fn derive_shared_key_ecdh(private_key: &SecretKey, public_key: &PublicKey) - #[cfg(test)] mod tests { use super::*; - use secp256k1::rand::thread_rng; + use secp256k1::rand::rngs::StdRng; + use secp256k1::rand::SeedableRng; use secp256k1::Secp256k1; #[test] fn test_ecdh_key_derivation() { + let mut rng = StdRng::seed_from_u64(1); let secp = Secp256k1::new(); // Generate two key pairs - let (secret1, public1) = secp.generate_keypair(&mut thread_rng()); - let (secret2, public2) = secp.generate_keypair(&mut thread_rng()); + let (secret1, public1) = secp.generate_keypair(&mut rng); + let (secret2, public2) = secp.generate_keypair(&mut rng); // Derive shared keys from both sides let shared1 = derive_shared_key_ecdh(&secret1, &public2); diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index a8289c2d0ef..e3dd5281e00 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1524,8 +1524,6 @@ mod tests { ); } - /// The ranked index keywords are gated by the meta-schema version, so v14 - /// must select meta-schema v3 while v13 stays on v2. /// Contested indexes without a Lock choice (item 23): the three method /// versions that read the resolution are selected by v14 only, so a v13 /// replay keeps the shipped rules (a full poll for every contest, ties to @@ -1586,6 +1584,8 @@ mod tests { ); } + /// The ranked index keywords are gated by the meta-schema version, so v14 + /// must select meta-schema v3 while v13 stays on v2. #[test] fn ranked_index_keywords_are_gated_by_meta_schema_v3() { assert_eq!( @@ -1653,46 +1653,6 @@ mod tests { ); } - /// v14 introduces the slots but activates none of them yet. If a later - /// change flips one of these, it must do so deliberately — and update this - /// test — rather than by inheriting a default. - #[test] - fn ranked_feature_slots_exist_but_are_dormant() { - assert_eq!( - PLATFORM_V14.drive.methods.document.query.detect_ranked_mode, - 0 - ); - assert_eq!( - PLATFORM_V14.drive.methods.document.query.detect_having_mode, - 0 - ); - assert_eq!( - PLATFORM_V14 - .drive - .methods - .verify - .document_ranked - .verify_ranked_top_k_proof, - 0 - ); - assert_eq!( - PLATFORM_V14 - .drive - .methods - .verify - .document_ranked - .verify_having_range_proof, - 0 - ); - let grove = &PLATFORM_V14.drive.grove_methods.batch; - assert_eq!(grove.batch_insert_empty_provable_count_indexed_tree, 0); - assert_eq!(grove.batch_insert_empty_provable_sum_indexed_tree, 0); - assert_eq!( - grove.batch_insert_empty_provable_count_provable_sum_indexed_tree, - 0 - ); - } - /// The contested vote poll index cross-check changes accept/reject /// behavior for document create transitions, so it lives in v14's own /// validation table: a v13 node keeps running structure validation v0, From d3b96b34ce5783c93e27b4c008b736e831e94ca0 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 00:05:15 +0700 Subject: [PATCH 039/113] refactor(platform): import instead of inline crate paths in 4.1 and 4.2 code (#5065) Co-authored-by: Claude Opus 5.5 --- .../src/address_funds/fee_strategy/mod.rs | 8 +- .../src/address_funds/platform_address.rs | 8 +- packages/rs-dpp/src/address_funds/witness.rs | 8 +- packages/rs-dpp/src/asset_lock/mod.rs | 8 +- .../reduced_asset_lock_value/mod.rs | 8 +- packages/rs-dpp/src/block/epoch/mod.rs | 8 +- .../rs-dpp/src/core_types/validator/mod.rs | 8 +- .../src/core_types/validator_set/mod.rs | 8 +- .../token_configuration_item.rs | 8 +- .../token_distribution_key.rs | 20 ++- .../distribution_function/mod.rs | 8 +- .../distribution_recipient.rs | 12 +- .../reward_distribution_moment/mod.rs | 8 +- .../reward_distribution_type/mod.rs | 8 +- .../action_fees/agreement/mod.rs | 8 +- .../try_from_schema/common/mod.rs | 4 +- .../data_contract/document_type/index/mod.rs | 32 ++-- .../document_type/property/array.rs | 8 +- .../methods/registration_cost/v2/mod.rs | 8 +- .../keys_for_document_type.rs | 8 +- .../rs-dpp/src/document/document_patch/mod.rs | 8 +- .../src/document/extended_document/mod.rs | 8 +- packages/rs-dpp/src/document/mod.rs | 8 +- ...proof_locked_transaction_mismatch_error.rs | 5 +- ...sset_lock_state_transition_replay_error.rs | 3 +- ...action_out_point_already_consumed_error.rs | 3 +- ...tion_out_point_not_enough_balance_error.rs | 3 +- ..._lock_proof_chain_lock_validation_error.rs | 3 +- .../rs-dpp/src/group/group_action_status.rs | 8 +- packages/rs-dpp/src/group/mod.rs | 8 +- .../state_transition/asset_lock_proof/mod.rs | 8 +- packages/rs-dpp/src/metadata.rs | 8 +- .../src/serialization/json/safe_fields.rs | 76 +++++----- .../identity_top_up_from_shielded_pool.rs | 6 +- packages/rs-dpp/src/shielded/mod.rs | 8 +- packages/rs-dpp/src/state_transition/mod.rs | 8 +- .../src/state_transition/proof_result.rs | 8 +- .../document_base_transition/mod.rs | 9 +- .../document_create_transition/mod.rs | 8 +- .../document_delete_transition/mod.rs | 8 +- .../mod.rs | 8 +- .../document_purchase_transition/mod.rs | 8 +- .../document_replace_transition/mod.rs | 8 +- .../document_transfer_transition/mod.rs | 8 +- .../batched_transition/document_transition.rs | 8 +- .../document_update_price_transition/mod.rs | 8 +- .../batched_transition/mod.rs | 8 +- .../token_base_transition/mod.rs | 9 +- .../token_burn_transition/mod.rs | 8 +- .../token_claim_transition/mod.rs | 8 +- .../token_config_update_transition/mod.rs | 8 +- .../mod.rs | 8 +- .../token_direct_purchase_transition/mod.rs | 8 +- .../token_emergency_action_transition/mod.rs | 8 +- .../token_freeze_transition/mod.rs | 8 +- .../token_mint_transition/mod.rs | 8 +- .../mod.rs | 8 +- .../token_transfer_transition/mod.rs | 8 +- .../batched_transition/token_transition.rs | 8 +- .../token_unfreeze_transition/mod.rs | 8 +- .../document/batch_transition/mod.rs | 8 +- .../rs-dpp/src/tokens/contract_info/mod.rs | 8 +- .../rs-dpp/src/tokens/emergency_action.rs | 8 +- .../rs-dpp/src/tokens/gas_fees_paid_by.rs | 8 +- packages/rs-dpp/src/tokens/token_event.rs | 8 +- .../src/tokens/token_payment_info/mod.rs | 8 +- .../src/tokens/token_pricing_schedule.rs | 8 +- .../yes_no_abstain_vote_choice/mod.rs | 8 +- .../rs-drive-abci/src/abci/app/check_tx.rs | 3 +- .../src/abci/handler/finalize_block.rs | 11 +- .../engine/run_block_proposal/mod.rs | 4 +- .../v0/mod.rs | 8 +- .../v1/mod.rs | 12 +- .../rs-drive/src/drive/document/index_only.rs | 4 +- .../v1/mod.rs | 6 +- .../src/query/chained_document_query/mod.rs | 31 ++-- packages/rs-drive/src/query/conditions.rs | 3 +- .../query/drive_document_ranked_query/mod.rs | 14 +- .../drive_dispatcher.rs | 6 +- .../src/query/index_only_synthesis.rs | 139 ++++++++---------- 80 files changed, 539 insertions(+), 327 deletions(-) diff --git a/packages/rs-dpp/src/address_funds/fee_strategy/mod.rs b/packages/rs-dpp/src/address_funds/fee_strategy/mod.rs index b8d165086fe..2acaaaf1aef 100644 --- a/packages/rs-dpp/src/address_funds/fee_strategy/mod.rs +++ b/packages/rs-dpp/src/address_funds/fee_strategy/mod.rs @@ -2,6 +2,10 @@ pub mod deduct_fee_from_inputs_and_outputs; pub use deduct_fee_from_inputs_and_outputs::FeeDeductionResult; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; @@ -178,10 +182,10 @@ mod tests { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for AddressFundsFeeStrategyStep {} +impl JsonConvertible for AddressFundsFeeStrategyStep {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for AddressFundsFeeStrategyStep {} +impl ValueConvertible for AddressFundsFeeStrategyStep {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/address_funds/platform_address.rs b/packages/rs-dpp/src/address_funds/platform_address.rs index 77a94d5b724..3d8c9dd7be0 100644 --- a/packages/rs-dpp/src/address_funds/platform_address.rs +++ b/packages/rs-dpp/src/address_funds/platform_address.rs @@ -1,6 +1,10 @@ use crate::address_funds::AddressWitness; use crate::address_funds::AddressWitnessVerificationOperations; use crate::prelude::AddressNonce; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::ProtocolError; use bech32::{Bech32m, Hrp}; use bincode::{Decode, DecodeUntrusted, Encode}; @@ -52,10 +56,10 @@ pub enum PlatformAddress { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for PlatformAddress {} +impl JsonConvertible for PlatformAddress {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for PlatformAddress {} +impl ValueConvertible for PlatformAddress {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/address_funds/witness.rs b/packages/rs-dpp/src/address_funds/witness.rs index a6f69d9933b..394c80be8a6 100644 --- a/packages/rs-dpp/src/address_funds/witness.rs +++ b/packages/rs-dpp/src/address_funds/witness.rs @@ -1,5 +1,9 @@ #[cfg(feature = "json-conversion")] use crate::serialization::json_safe_fields; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::de::BorrowDecoder; use bincode::enc::Encoder; use bincode::error::{DecodeError, EncodeError}; @@ -646,10 +650,10 @@ mod tests { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for AddressWitness {} +impl JsonConvertible for AddressWitness {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for AddressWitness {} +impl ValueConvertible for AddressWitness {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/asset_lock/mod.rs b/packages/rs-dpp/src/asset_lock/mod.rs index c27ede918a8..ec5f6c7a52c 100644 --- a/packages/rs-dpp/src/asset_lock/mod.rs +++ b/packages/rs-dpp/src/asset_lock/mod.rs @@ -1,4 +1,8 @@ use crate::asset_lock::reduced_asset_lock_value::AssetLockValue; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; pub mod reduced_asset_lock_value; @@ -18,10 +22,10 @@ pub enum StoredAssetLockInfo { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for StoredAssetLockInfo {} +impl JsonConvertible for StoredAssetLockInfo {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for StoredAssetLockInfo {} +impl ValueConvertible for StoredAssetLockInfo {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/asset_lock/reduced_asset_lock_value/mod.rs b/packages/rs-dpp/src/asset_lock/reduced_asset_lock_value/mod.rs index 7522a9eb8f2..422ce4aebf5 100644 --- a/packages/rs-dpp/src/asset_lock/reduced_asset_lock_value/mod.rs +++ b/packages/rs-dpp/src/asset_lock/reduced_asset_lock_value/mod.rs @@ -1,5 +1,9 @@ use crate::asset_lock::reduced_asset_lock_value::v0::AssetLockValueV0; use crate::fee::Credits; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::From; @@ -40,10 +44,10 @@ pub enum AssetLockValue { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for AssetLockValue {} +impl JsonConvertible for AssetLockValue {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for AssetLockValue {} +impl ValueConvertible for AssetLockValue {} impl AssetLockValue { pub fn new( diff --git a/packages/rs-dpp/src/block/epoch/mod.rs b/packages/rs-dpp/src/block/epoch/mod.rs index e1ea6b7f222..7b6029b753c 100644 --- a/packages/rs-dpp/src/block/epoch/mod.rs +++ b/packages/rs-dpp/src/block/epoch/mod.rs @@ -1,3 +1,7 @@ +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::{InvalidVectorSizeError, ProtocolError}; use bincode::{BorrowDecode, Encode}; use serde::{Deserialize, Serialize}; @@ -129,10 +133,10 @@ impl<'de, C> BorrowDecode<'de, C> for Epoch { // --- canonical conversion trait impls (unification pass 1) --- #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for Epoch {} +impl JsonConvertible for Epoch {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for Epoch {} +impl ValueConvertible for Epoch {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/core_types/validator/mod.rs b/packages/rs-dpp/src/core_types/validator/mod.rs index b0fef4654a3..5cc5681b205 100644 --- a/packages/rs-dpp/src/core_types/validator/mod.rs +++ b/packages/rs-dpp/src/core_types/validator/mod.rs @@ -1,5 +1,9 @@ use crate::bls_signatures::{Bls12381G2Impl, PublicKey as BlsPublicKey}; use crate::core_types::validator::v0::{ValidatorV0, ValidatorV0Getters, ValidatorV0Setters}; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use dashcore::{ProTxHash, PubkeyHash}; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; @@ -21,10 +25,10 @@ pub enum Validator { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for Validator {} +impl JsonConvertible for Validator {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for Validator {} +impl ValueConvertible for Validator {} impl ValidatorV0Getters for Validator { fn pro_tx_hash(&self) -> &ProTxHash { diff --git a/packages/rs-dpp/src/core_types/validator_set/mod.rs b/packages/rs-dpp/src/core_types/validator_set/mod.rs index 7a14485110f..13c934cdf2f 100644 --- a/packages/rs-dpp/src/core_types/validator_set/mod.rs +++ b/packages/rs-dpp/src/core_types/validator_set/mod.rs @@ -3,6 +3,10 @@ use crate::core_types::validator::v0::ValidatorV0; use crate::core_types::validator_set::v0::{ ValidatorSetV0, ValidatorSetV0Getters, ValidatorSetV0Setters, }; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; #[cfg(feature = "core-types-serialization")] use crate::ProtocolError; #[cfg(feature = "core-types-serialization")] @@ -47,10 +51,10 @@ pub enum ValidatorSet { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for ValidatorSet {} +impl JsonConvertible for ValidatorSet {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for ValidatorSet {} +impl ValueConvertible for ValidatorSet {} impl Display for ValidatorSet { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_configuration_item.rs b/packages/rs-dpp/src/data_contract/associated_token/token_configuration_item.rs index 0476a43c43c..94a7cf1d8bb 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_configuration_item.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_configuration_item.rs @@ -4,6 +4,10 @@ use crate::data_contract::associated_token::token_marketplace_rules::v0::TokenTr use crate::data_contract::associated_token::token_perpetual_distribution::TokenPerpetualDistribution; use crate::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; use crate::data_contract::GroupContractPosition; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::ProtocolError; use bincode::{DecodeUntrusted, Encode}; use platform_serialization::de::Decode; @@ -841,10 +845,10 @@ mod tests { // --- canonical conversion trait impls (unification pass 1) --- #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenConfigurationChangeItem {} +impl JsonConvertible for TokenConfigurationChangeItem {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenConfigurationChangeItem {} +impl ValueConvertible for TokenConfigurationChangeItem {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_distribution_key.rs b/packages/rs-dpp/src/data_contract/associated_token/token_distribution_key.rs index 08b3ed2bc89..8a5acab07df 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_distribution_key.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_distribution_key.rs @@ -7,6 +7,10 @@ use serde::{Deserialize, Serialize}; use std::fmt; use crate::data_contract::associated_token::token_perpetual_distribution::reward_distribution_moment::RewardDistributionMoment; use crate::prelude::TimestampMillis; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; /// Represents the type of token distribution. /// @@ -265,28 +269,28 @@ pub struct TokenDistributionKey { // --- canonical conversion trait impls (unification pass 1) --- #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenDistributionTypeWithResolvedRecipient {} +impl JsonConvertible for TokenDistributionTypeWithResolvedRecipient {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenDistributionTypeWithResolvedRecipient {} +impl ValueConvertible for TokenDistributionTypeWithResolvedRecipient {} #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenDistributionInfo {} +impl JsonConvertible for TokenDistributionInfo {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenDistributionInfo {} +impl ValueConvertible for TokenDistributionInfo {} #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenDistributionType {} +impl JsonConvertible for TokenDistributionType {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenDistributionType {} +impl ValueConvertible for TokenDistributionType {} #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenDistributionKey {} +impl JsonConvertible for TokenDistributionKey {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenDistributionKey {} +impl ValueConvertible for TokenDistributionKey {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_function/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_function/mod.rs index fcdd0a83852..56b16a417a9 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_function/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_function/mod.rs @@ -1,6 +1,10 @@ use crate::balances::credits::TokenAmount; #[cfg(feature = "json-conversion")] use crate::serialization::json_safe_fields; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fmt; @@ -1299,10 +1303,10 @@ mod tests { // --- canonical conversion trait impls (unification pass 1) --- #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for DistributionFunction {} +impl JsonConvertible for DistributionFunction {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for DistributionFunction {} +impl ValueConvertible for DistributionFunction {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_recipient.rs b/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_recipient.rs index 728a709d599..026263b286f 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_recipient.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/distribution_recipient.rs @@ -2,6 +2,10 @@ use crate::data_contract::associated_token::token_distribution_key::{ TokenDistributionType, TokenDistributionTypeWithResolvedRecipient, }; use crate::errors::ProtocolError; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use platform_serialization_derive::PlatformSerialize; use platform_value::Identifier; @@ -133,10 +137,10 @@ impl<'de> Deserialize<'de> for TokenDistributionRecipient { // Manual impls because TokenDistributionRecipient is a flat enum (not versioned V0/V1). #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenDistributionRecipient {} +impl JsonConvertible for TokenDistributionRecipient {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenDistributionRecipient {} +impl ValueConvertible for TokenDistributionRecipient {} impl TokenDistributionRecipient { /// Simple resolve matches the contract owner but does not try to resolve the evonodes @@ -302,10 +306,10 @@ impl<'de> Deserialize<'de> for TokenDistributionResolvedRecipient { // Manual impls because TokenDistributionResolvedRecipient is a flat enum (not versioned V0/V1). #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenDistributionResolvedRecipient {} +impl JsonConvertible for TokenDistributionResolvedRecipient {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenDistributionResolvedRecipient {} +impl ValueConvertible for TokenDistributionResolvedRecipient {} impl From for TokenDistributionRecipient { fn from(value: TokenDistributionResolvedRecipient) -> Self { diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/reward_distribution_moment/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/reward_distribution_moment/mod.rs index cf88f7c2199..da449f87d75 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/reward_distribution_moment/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/reward_distribution_moment/mod.rs @@ -8,6 +8,10 @@ use std::ops::{Add, Div}; use crate::block::block_info::BlockInfo; use crate::data_contract::associated_token::token_perpetual_distribution::reward_distribution_type::RewardDistributionType; use crate::ProtocolError; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; #[derive( Serialize, @@ -97,10 +101,10 @@ impl From for RewardDistributionMoment { } } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for RewardDistributionMoment {} +impl JsonConvertible for RewardDistributionMoment {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for RewardDistributionMoment {} +impl ValueConvertible for RewardDistributionMoment {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/reward_distribution_type/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/reward_distribution_type/mod.rs index 1fe4f3a4a13..c51dd115f67 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/reward_distribution_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_perpetual_distribution/reward_distribution_type/mod.rs @@ -13,6 +13,10 @@ use std::fmt; use crate::data_contract::accessors::v1::DataContractV1Getters; use crate::data_contract::associated_token::token_perpetual_distribution::reward_distribution_moment::RewardDistributionMoment; use crate::ProtocolError; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; #[cfg_attr(feature = "json-conversion", json_safe_fields)] #[derive( @@ -567,10 +571,10 @@ impl fmt::Display for RewardDistributionType { // --- canonical conversion trait impls (unification pass 1) --- #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for RewardDistributionType {} +impl JsonConvertible for RewardDistributionType {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for RewardDistributionType {} +impl ValueConvertible for RewardDistributionType {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/data_contract/document_type/action_fees/agreement/mod.rs b/packages/rs-dpp/src/data_contract/document_type/action_fees/agreement/mod.rs index 61f44cdc475..cfc1f707d02 100644 --- a/packages/rs-dpp/src/data_contract/document_type/action_fees/agreement/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/action_fees/agreement/mod.rs @@ -16,6 +16,10 @@ use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; use crate::data_contract::document_type::action_fees::{ActionFeePricing, DocumentActionFee}; use crate::data_contract::document_type::DocumentTypeRef; use crate::prelude::FeeMultiplier; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; #[cfg(feature = "json-conversion")] use crate::serialization::JsonSafeFields; use crate::state_transition::batch_transition::batched_transition::document_transition_action_type::DocumentTransitionActionType; @@ -77,10 +81,10 @@ pub enum DocumentActionFeeAgreement { impl JsonSafeFields for DocumentActionFeeAgreement {} #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for DocumentActionFeeAgreement {} +impl JsonConvertible for DocumentActionFeeAgreement {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for DocumentActionFeeAgreement {} +impl ValueConvertible for DocumentActionFeeAgreement {} impl DocumentActionFeeAgreement { /// The agreement to `fee` as a document type declares it under `pricing`. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 9fa64c4609e..714e964f7cb 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -22,7 +22,7 @@ use crate::data_contract::config::v0::DataContractConfigGettersV0; use crate::data_contract::config::v2::DataContractConfigGettersV2; use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::class_methods::consensus_or_protocol_value_error; -use crate::data_contract::document_type::index::Index; +use crate::data_contract::document_type::index::{Index, IndexGrammarAdmissions}; use crate::data_contract::document_type::index_level::IndexLevel; use crate::data_contract::document_type::property::DocumentProperty; use crate::data_contract::document_type::property::DocumentPropertyType; @@ -873,7 +873,7 @@ fn parse_indices( .to_map() .map_err(consensus_or_protocol_value_error)? .as_slice(), - crate::data_contract::document_type::index::IndexGrammarAdmissions { + IndexGrammarAdmissions { ranked: ctx.generation.admit_ranked, time_range: ctx.generation.admit_time_range, terminal: ctx.generation.admit_index_terminal, diff --git a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs index c9fa0eeab26..b0da6c26635 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs @@ -1,3 +1,7 @@ +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; @@ -6168,46 +6172,46 @@ mod tests { // --- canonical conversion trait impls (unification pass 1) --- #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for OrderBy {} +impl JsonConvertible for OrderBy {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for OrderBy {} +impl ValueConvertible for OrderBy {} #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for ContestedIndexResolution {} +impl JsonConvertible for ContestedIndexResolution {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for ContestedIndexResolution {} +impl ValueConvertible for ContestedIndexResolution {} #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for ContestedIndexFieldMatch {} +impl JsonConvertible for ContestedIndexFieldMatch {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for ContestedIndexFieldMatch {} +impl ValueConvertible for ContestedIndexFieldMatch {} #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for ContestedIndexInformation {} +impl JsonConvertible for ContestedIndexInformation {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for ContestedIndexInformation {} +impl ValueConvertible for ContestedIndexInformation {} #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for Index {} +impl JsonConvertible for Index {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for Index {} +impl ValueConvertible for Index {} #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for IndexProperty {} +impl JsonConvertible for IndexProperty {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for IndexProperty {} +impl ValueConvertible for IndexProperty {} #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for IndexCountability {} +impl JsonConvertible for IndexCountability {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for IndexCountability {} +impl ValueConvertible for IndexCountability {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/data_contract/document_type/property/array.rs b/packages/rs-dpp/src/data_contract/document_type/property/array.rs index 8e40b10d86a..e6cdec40c75 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/array.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/array.rs @@ -1,5 +1,9 @@ use crate::data_contract::document_type::property::DocumentPropertyType; use crate::data_contract::errors::DataContractError; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::ProtocolError; use byteorder::{BigEndian, ReadBytesExt}; use integer_encoding::{VarInt, VarIntReader}; @@ -1184,10 +1188,10 @@ mod tests { // --- canonical conversion trait impls (unification pass 1) --- #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for ArrayItemType {} +impl JsonConvertible for ArrayItemType {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for ArrayItemType {} +impl ValueConvertible for ArrayItemType {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs b/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs index a56a15e66a2..1a866480a9b 100644 --- a/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs @@ -4,6 +4,7 @@ use crate::data_contract::associated_token::token_configuration::accessors::v0:: use crate::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use crate::data_contract::associated_token::token_distribution_rules::accessors::v1::TokenDistributionRulesV1Getters; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::property_names::INDICES; use crate::data_contract::document_type::{Index, IndexGrammarAdmissions}; use crate::data_contract::serialized_version::DataContractInSerializationFormat; use crate::fee::Credits; @@ -118,10 +119,9 @@ impl DataContractInSerializationFormat { // If this is not okay the registration will fail on basic validation if let Ok(schema_map) = document_type_schema.to_map() { // Initialize indices - if let Ok(Some(index_values)) = Value::inner_optional_array_slice_value( - schema_map, - crate::data_contract::document_type::property_names::INDICES, - ) { + if let Ok(Some(index_values)) = + Value::inner_optional_array_slice_value(schema_map, INDICES) + { for index_value in index_values { if let Ok(index_value_map) = index_value.to_map() { // Same keyword gates the document type parser diff --git a/packages/rs-dpp/src/data_contract/storage_requirements/keys_for_document_type.rs b/packages/rs-dpp/src/data_contract/storage_requirements/keys_for_document_type.rs index 17876c17dd7..f5560f91758 100644 --- a/packages/rs-dpp/src/data_contract/storage_requirements/keys_for_document_type.rs +++ b/packages/rs-dpp/src/data_contract/storage_requirements/keys_for_document_type.rs @@ -1,6 +1,10 @@ use crate::consensus::basic::data_contract::UnknownStorageKeyRequirementsError; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; use serde_repr::*; @@ -63,10 +67,10 @@ impl TryFrom for StorageKeyRequirements { // --- canonical conversion trait impls (unification pass 1) --- #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for StorageKeyRequirements {} +impl JsonConvertible for StorageKeyRequirements {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for StorageKeyRequirements {} +impl ValueConvertible for StorageKeyRequirements {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/document/document_patch/mod.rs b/packages/rs-dpp/src/document/document_patch/mod.rs index d007b862bce..02e5a2f1e8f 100644 --- a/packages/rs-dpp/src/document/document_patch/mod.rs +++ b/packages/rs-dpp/src/document/document_patch/mod.rs @@ -1,5 +1,9 @@ use crate::identity::TimestampMillis; use crate::prelude::Revision; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use platform_value::{Identifier, Value}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -25,10 +29,10 @@ pub struct DocumentPatch { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for DocumentPatch {} +impl JsonConvertible for DocumentPatch {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for DocumentPatch {} +impl ValueConvertible for DocumentPatch {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/document/extended_document/mod.rs b/packages/rs-dpp/src/document/extended_document/mod.rs index 19c2371a840..2e6ada5e2c9 100644 --- a/packages/rs-dpp/src/document/extended_document/mod.rs +++ b/packages/rs-dpp/src/document/extended_document/mod.rs @@ -10,6 +10,10 @@ use crate::data_contract::DataContract; use crate::ProtocolError; use crate::document::extended_document::v0::ExtendedDocumentV0; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; #[cfg(feature = "validation")] use crate::validation::SimpleConsensusValidationResult; @@ -34,10 +38,10 @@ pub enum ExtendedDocument { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for ExtendedDocument {} +impl JsonConvertible for ExtendedDocument {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for ExtendedDocument {} +impl ValueConvertible for ExtendedDocument {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/document/mod.rs b/packages/rs-dpp/src/document/mod.rs index ce4d97fba80..fca98580715 100644 --- a/packages/rs-dpp/src/document/mod.rs +++ b/packages/rs-dpp/src/document/mod.rs @@ -22,6 +22,10 @@ mod v0; pub use accessors::*; pub use v0::*; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; #[cfg(feature = "extended-document")] pub use extended_document::property_names as extended_document_property_names; #[cfg(feature = "extended-document")] @@ -58,10 +62,10 @@ pub enum Document { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for Document {} +impl JsonConvertible for Document {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for Document {} +impl ValueConvertible for Document {} impl fmt::Display for Document { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { diff --git a/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_proof_locked_transaction_mismatch_error.rs b/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_proof_locked_transaction_mismatch_error.rs index 014d4f09d61..044da048208 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_proof_locked_transaction_mismatch_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_proof_locked_transaction_mismatch_error.rs @@ -1,6 +1,7 @@ use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::errors::ProtocolError; +use crate::serialization::untrusted::decode_txid; use bincode::{Decode, DecodeUntrusted, Encode}; use dashcore::Txid; use platform_serialization_derive::{ @@ -63,8 +64,8 @@ impl DecodeUntrusted for IdentityAssetLockProofLockedTransactionMismatchEr decoder: &mut D, ) -> Result { Ok(Self { - instant_lock_transaction_id: crate::serialization::untrusted::decode_txid(decoder)?, - asset_lock_transaction_id: crate::serialization::untrusted::decode_txid(decoder)?, + instant_lock_transaction_id: decode_txid(decoder)?, + asset_lock_transaction_id: decode_txid(decoder)?, }) } } diff --git a/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_state_transition_replay_error.rs b/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_state_transition_replay_error.rs index ba9762f9da0..5b02a639d7b 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_state_transition_replay_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_state_transition_replay_error.rs @@ -1,6 +1,7 @@ use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::errors::ProtocolError; +use crate::serialization::untrusted::decode_txid; use bincode::{Decode, DecodeUntrusted, Encode}; use dashcore::Txid; use platform_serialization_derive::{ @@ -71,7 +72,7 @@ impl DecodeUntrusted for IdentityAssetLockStateTransitionReplayError { decoder: &mut D, ) -> Result { Ok(Self { - transaction_id: crate::serialization::untrusted::decode_txid(decoder)?, + transaction_id: decode_txid(decoder)?, output_index: DecodeUntrusted::decode_untrusted(decoder)?, state_transition_id: DecodeUntrusted::decode_untrusted(decoder)?, }) diff --git a/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_transaction_out_point_already_consumed_error.rs b/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_transaction_out_point_already_consumed_error.rs index 86085898124..b6ba24891c1 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_transaction_out_point_already_consumed_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_transaction_out_point_already_consumed_error.rs @@ -1,6 +1,7 @@ use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::errors::ProtocolError; +use crate::serialization::untrusted::decode_txid; use bincode::{Decode, DecodeUntrusted, Encode}; use dashcore::Txid; use platform_serialization_derive::{ @@ -62,7 +63,7 @@ impl DecodeUntrusted for IdentityAssetLockTransactionOutPointAlreadyConsum decoder: &mut D, ) -> Result { Ok(Self { - transaction_id: crate::serialization::untrusted::decode_txid(decoder)?, + transaction_id: decode_txid(decoder)?, output_index: DecodeUntrusted::decode_untrusted(decoder)?, }) } diff --git a/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_transaction_out_point_not_enough_balance_error.rs b/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_transaction_out_point_not_enough_balance_error.rs index 1536cf00f50..8743fb8cacd 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_transaction_out_point_not_enough_balance_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/identity/identity_asset_lock_transaction_out_point_not_enough_balance_error.rs @@ -2,6 +2,7 @@ use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::errors::ProtocolError; use crate::fee::Credits; +use crate::serialization::untrusted::decode_txid; use bincode::{Decode, DecodeUntrusted, Encode}; use dashcore::Txid; use platform_serialization_derive::{ @@ -87,7 +88,7 @@ impl DecodeUntrusted for IdentityAssetLockTransactionOutPointNotEnoughBala decoder: &mut D, ) -> Result { Ok(Self { - transaction_id: crate::serialization::untrusted::decode_txid(decoder)?, + transaction_id: decode_txid(decoder)?, output_index: DecodeUntrusted::decode_untrusted(decoder)?, initial_asset_lock_credits: DecodeUntrusted::decode_untrusted(decoder)?, credits_left: DecodeUntrusted::decode_untrusted(decoder)?, diff --git a/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_identity_asset_lock_proof_chain_lock_validation_error.rs b/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_identity_asset_lock_proof_chain_lock_validation_error.rs index 398d10918e3..6c4b487bf12 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_identity_asset_lock_proof_chain_lock_validation_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_identity_asset_lock_proof_chain_lock_validation_error.rs @@ -1,4 +1,5 @@ use crate::errors::ProtocolError; +use crate::serialization::untrusted::decode_txid; use bincode::{Decode, DecodeUntrusted, Encode}; use dashcore::Txid; use platform_serialization_derive::{ @@ -49,7 +50,7 @@ impl DecodeUntrusted for InvalidIdentityAssetLockProofChainLockValidationE decoder: &mut D, ) -> Result { Ok(Self { - transaction_id: crate::serialization::untrusted::decode_txid(decoder)?, + transaction_id: decode_txid(decoder)?, height_reported_not_locked: DecodeUntrusted::decode_untrusted(decoder)?, }) } diff --git a/packages/rs-dpp/src/group/group_action_status.rs b/packages/rs-dpp/src/group/group_action_status.rs index 3d8fe1ca1c7..fc1f25e38c1 100644 --- a/packages/rs-dpp/src/group/group_action_status.rs +++ b/packages/rs-dpp/src/group/group_action_status.rs @@ -1,3 +1,7 @@ +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use anyhow::bail; #[derive(Debug, PartialEq, PartialOrd, Clone, Copy, Eq)] @@ -12,10 +16,10 @@ pub enum GroupActionStatus { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for GroupActionStatus {} +impl JsonConvertible for GroupActionStatus {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for GroupActionStatus {} +impl ValueConvertible for GroupActionStatus {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/group/mod.rs b/packages/rs-dpp/src/group/mod.rs index 6ca13ac8c23..425bbf91091 100644 --- a/packages/rs-dpp/src/group/mod.rs +++ b/packages/rs-dpp/src/group/mod.rs @@ -2,6 +2,10 @@ use crate::data_contract::group::{Group, GroupMemberPower}; use crate::data_contract::GroupContractPosition; #[cfg(feature = "json-conversion")] use crate::serialization::json_safe_fields; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::Display; use platform_value::Identifier; @@ -53,10 +57,10 @@ pub struct GroupStateTransitionInfo { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for GroupStateTransitionInfo {} +impl JsonConvertible for GroupStateTransitionInfo {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for GroupStateTransitionInfo {} +impl ValueConvertible for GroupStateTransitionInfo {} #[derive(Debug, Clone, PartialEq)] pub struct GroupStateTransitionResolvedInfo { diff --git a/packages/rs-dpp/src/identity/state_transition/asset_lock_proof/mod.rs b/packages/rs-dpp/src/identity/state_transition/asset_lock_proof/mod.rs index 1b18ace5388..4f809d5ba92 100644 --- a/packages/rs-dpp/src/identity/state_transition/asset_lock_proof/mod.rs +++ b/packages/rs-dpp/src/identity/state_transition/asset_lock_proof/mod.rs @@ -14,6 +14,10 @@ use serde::de::Error; use crate::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use crate::prelude::Identifier; +#[cfg(feature = "json-conversion")] +use crate::serialization::JsonConvertible; +#[cfg(feature = "value-conversion")] +use crate::serialization::ValueConvertible; #[cfg(feature = "validation")] use crate::validation::SimpleConsensusValidationResult; use crate::{ProtocolError, SerdeParsingError}; @@ -89,10 +93,10 @@ impl Default for AssetLockProof { } #[cfg(feature = "json-conversion")] -impl crate::serialization::JsonConvertible for AssetLockProof {} +impl JsonConvertible for AssetLockProof {} #[cfg(feature = "value-conversion")] -impl crate::serialization::ValueConvertible for AssetLockProof {} +impl ValueConvertible for AssetLockProof {} impl AsRef for AssetLockProof { fn as_ref(&self) -> &AssetLockProof { diff --git a/packages/rs-dpp/src/metadata.rs b/packages/rs-dpp/src/metadata.rs index e750321e4f2..a941d8401bc 100644 --- a/packages/rs-dpp/src/metadata.rs +++ b/packages/rs-dpp/src/metadata.rs @@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "json-conversion")] use crate::serialization::json_safe_fields; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::{errors::ProtocolError, prelude::TimestampMillis, util::deserializer::ProtocolVersion}; #[cfg_attr(feature = "json-conversion", json_safe_fields)] @@ -42,10 +46,10 @@ impl std::convert::TryFrom<&str> for Metadata { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for Metadata {} +impl JsonConvertible for Metadata {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for Metadata {} +impl ValueConvertible for Metadata {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/serialization/json/safe_fields.rs b/packages/rs-dpp/src/serialization/json/safe_fields.rs index 149ba871c1f..cacaa59f0d1 100644 --- a/packages/rs-dpp/src/serialization/json/safe_fields.rs +++ b/packages/rs-dpp/src/serialization/json/safe_fields.rs @@ -1,3 +1,20 @@ +use crate::contract_group::{ContractGroupMembership, ContractGroupRegistration}; +use crate::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; +use crate::data_contract::associated_token::token_distribution_key::{ + TokenDistributionInfo, TokenDistributionType, +}; +use crate::data_contract::associated_token::token_perpetual_distribution::reward_distribution_moment::RewardDistributionMoment; +use crate::data_contract::document_type::ContestedIndexFieldMatch; +use crate::state_transition::batch_transition::batched_transition::{ + BatchedTransition, DocumentTransition, TokenTransition, +}; +use crate::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; +use crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition; +use crate::tokens::emergency_action::TokenEmergencyAction; +use crate::tokens::gas_fees_paid_by::GasFeesPaidBy; +use crate::tokens::token_payment_info::TokenPaymentInfo; +use crate::tokens::token_pricing_schedule::TokenPricingSchedule; + /// Marker trait proving a type's u64/i64 fields are protected for JS-safe JSON serialization. /// /// # How it works @@ -105,40 +122,25 @@ impl JsonSafeFields for crate::voting::votes::Vote {} // `DocumentBaseTransition` wraps `DocumentBaseTransitionV0` / `V1`, both of // which are `#[json_safe_fields]`-annotated, so the wrapper enum is safe by // induction: every u64 inside is protected by `json_safe_u64`. -impl JsonSafeFields - for crate::state_transition::batch_transition::document_base_transition::DocumentBaseTransition -{ -} +impl JsonSafeFields for DocumentBaseTransition {} // `TokenPaymentInfo` (v0 wrapper) — V0 is `#[json_safe_fields]`-annotated. -impl JsonSafeFields for crate::tokens::token_payment_info::TokenPaymentInfo {} +impl JsonSafeFields for TokenPaymentInfo {} // `GasFeesPaidBy` is a unit-variant enum (no u64). -impl JsonSafeFields for crate::tokens::gas_fees_paid_by::GasFeesPaidBy {} -impl JsonSafeFields for crate::contract_group::ContractGroupRegistration {} -impl JsonSafeFields for crate::contract_group::ContractGroupMembership {} +impl JsonSafeFields for GasFeesPaidBy {} +impl JsonSafeFields for ContractGroupRegistration {} +impl JsonSafeFields for ContractGroupMembership {} // `GroupStateTransitionInfo` is verified via `#[json_safe_fields]` on the type // itself (named `u16` / `Identifier` / `bool` fields) — no manual marker needed. // `TokenBaseTransition` wraps `TokenBaseTransitionV0` which is // `#[json_safe_fields]`-annotated, so the wrapper is safe by induction. -impl JsonSafeFields - for crate::state_transition::batch_transition::token_base_transition::TokenBaseTransition -{ -} +impl JsonSafeFields for TokenBaseTransition {} // BatchTransition family wrappers — each variant's outer enum is itself // safe by induction (every V0 inner is `#[json_safe_fields]`-annotated; // the outer-enum manual `impl JsonConvertible` doesn't auto-impl // JsonSafeFields, so we declare it explicitly here). -impl JsonSafeFields - for crate::state_transition::batch_transition::batched_transition::DocumentTransition -{ -} -impl JsonSafeFields - for crate::state_transition::batch_transition::batched_transition::TokenTransition -{ -} -impl JsonSafeFields - for crate::state_transition::batch_transition::batched_transition::BatchedTransition -{ -} +impl JsonSafeFields for DocumentTransition {} +impl JsonSafeFields for TokenTransition {} +impl JsonSafeFields for BatchedTransition {} impl JsonSafeFields for crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice {} impl JsonSafeFields for crate::group::action_event::GroupActionEvent {} // TokenEvent contains u64 aliases (TokenAmount, Credits) in tuple variants that @@ -146,40 +148,28 @@ impl JsonSafeFields for crate::group::action_event::GroupActionEvent {} // JS-safe serialization of these fields. See token_event.rs for details. impl JsonSafeFields for crate::tokens::token_event::TokenEvent {} // `TokenEmergencyAction` is a unit-variant enum (Pause / Resume). -impl JsonSafeFields for crate::tokens::emergency_action::TokenEmergencyAction {} +impl JsonSafeFields for TokenEmergencyAction {} // `TokenDistributionType` is a unit-variant enum. -impl JsonSafeFields - for crate::data_contract::associated_token::token_distribution_key::TokenDistributionType -{ -} +impl JsonSafeFields for TokenDistributionType {} // `TokenPricingSchedule` has tuple variants holding `Credits` (u64) and // `BTreeMap`. `#[json_safe_fields]` can't auto-annotate // variant-internal u64s, so it serializes through an internally-`$type`-tagged // `Repr` that routes both through `json_safe_u64` / `json_safe_u64_u64_map` — // this marker is therefore truthful, not a bare escape hatch. -impl JsonSafeFields for crate::tokens::token_pricing_schedule::TokenPricingSchedule {} +impl JsonSafeFields for TokenPricingSchedule {} // `TokenConfigurationChangeItem` has tuple variants with `Option` // and `Option` (u64-shaped). Same escape-hatch pattern. -impl JsonSafeFields - for crate::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem -{ -} +impl JsonSafeFields for TokenConfigurationChangeItem {} // `RewardDistributionMoment` carries `BlockHeight`/`TimestampMillis` (u64) in // tuple variants. Unlike the bare escape-hatches above, its u64 fields are // *actually* JS-safe: `#[serde(with = "json_safe_u64")]` is applied directly on // the variant fields (see reward_distribution_moment/mod.rs). -impl JsonSafeFields - for crate::data_contract::associated_token::token_perpetual_distribution::reward_distribution_moment::RewardDistributionMoment -{ -} +impl JsonSafeFields for RewardDistributionMoment {} // `ContestedIndexFieldMatch::PositiveIntegerMatch(u128)` is made JS-safe via // `#[serde(with = "json_safe_u128")]` on the variant field (see // document_type/index/mod.rs); `Regex(LazyRegex)` round-trips as a string. -impl JsonSafeFields for crate::data_contract::document_type::ContestedIndexFieldMatch {} +impl JsonSafeFields for ContestedIndexFieldMatch {} // `TokenDistributionInfo::PreProgrammed` carries a `TimestampMillis` (u64) made // JS-safe via `#[serde(with = "json_safe_u64")]`; `Perpetual`'s // `RewardDistributionMoment` is JS-safe via its own annotation. -impl JsonSafeFields - for crate::data_contract::associated_token::token_distribution_key::TokenDistributionInfo -{ -} +impl JsonSafeFields for TokenDistributionInfo {} diff --git a/packages/rs-dpp/src/shielded/builder/identity_top_up_from_shielded_pool.rs b/packages/rs-dpp/src/shielded/builder/identity_top_up_from_shielded_pool.rs index e3b587b52c0..04330d4a105 100644 --- a/packages/rs-dpp/src/shielded/builder/identity_top_up_from_shielded_pool.rs +++ b/packages/rs-dpp/src/shielded/builder/identity_top_up_from_shielded_pool.rs @@ -2,7 +2,9 @@ use grovedb_commitment_tree::{Anchor, FullViewingKey, SpendAuthorizingKey}; use crate::address_funds::OrchardAddress; use crate::fee::Credits; -use crate::shielded::compute_shielded_identity_top_up_fee; +use crate::shielded::{ + compute_shielded_identity_top_up_fee, identity_top_up_from_shielded_extra_sighash_data, +}; use crate::state_transition::identity_top_up_from_shielded_pool_transition::methods::IdentityTopUpFromShieldedPoolTransitionMethodsV0; use crate::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; use crate::state_transition::StateTransition; @@ -54,7 +56,7 @@ pub fn build_identity_top_up_from_shielded_pool_transition( let change_amount = total_spent - required; - let extra_sighash_data = crate::shielded::identity_top_up_from_shielded_extra_sighash_data( + let extra_sighash_data = identity_top_up_from_shielded_extra_sighash_data( &identity_id.to_buffer(), required, platform_version, diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index 2298376f250..881549234f3 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -23,6 +23,10 @@ pub use compute_minimum_shielded_fee::{ // Re-exported so the public paths stay `dpp::shielded::` after moving the sighash preimage // builders into their own file. Both the version-dispatching wrappers and their `_v0` impls are // re-exported (callers use the wrappers; byte-layout tests use the `_v0` impls). +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; pub use sighash::{ compute_platform_sighash, identity_create_from_shielded_extra_sighash_data, identity_create_from_shielded_extra_sighash_data_v0, @@ -215,10 +219,10 @@ pub struct SerializedAction { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for SerializedAction {} +impl JsonConvertible for SerializedAction {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for SerializedAction {} +impl ValueConvertible for SerializedAction {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index cebaefab47b..b9fb45a0a56 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -82,6 +82,10 @@ use crate::identity::Purpose; use crate::identity::{IdentityPublicKey, KeyType}; use crate::identity::{KeyID, SecurityLevel}; use crate::prelude::{AddressNonce, AssetLockProof, UserFeeIncrease}; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::serialization::{PlatformDeserializableUntrusted, Signable}; use crate::state_transition::address_credit_withdrawal_transition::{ AddressCreditWithdrawalTransition, AddressCreditWithdrawalTransitionSignable, @@ -569,10 +573,10 @@ pub enum StateTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for StateTransition {} +impl JsonConvertible for StateTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for StateTransition {} +impl ValueConvertible for StateTransition {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/state_transition/proof_result.rs b/packages/rs-dpp/src/state_transition/proof_result.rs index f2f4b51cec7..ebd2d5b4132 100644 --- a/packages/rs-dpp/src/state_transition/proof_result.rs +++ b/packages/rs-dpp/src/state_transition/proof_result.rs @@ -12,6 +12,10 @@ use crate::fee::Credits; use crate::group::group_action_status::GroupActionStatus; use crate::identity::{Identity, PartialIdentity}; use crate::prelude::AddressNonce; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::tokens::info::IdentityTokenInfo; use crate::tokens::status::TokenStatus; use crate::tokens::token_pricing_schedule::TokenPricingSchedule; @@ -326,10 +330,10 @@ mod json_safe_address_info_map { // --- canonical conversion trait impls (unification pass 1) --- #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for StateTransitionProofResult {} +impl JsonConvertible for StateTransitionProofResult {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for StateTransitionProofResult {} +impl ValueConvertible for StateTransitionProofResult {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_base_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_base_transition/mod.rs index 89b5534aaa0..1ebc2354090 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_base_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_base_transition/mod.rs @@ -10,6 +10,11 @@ mod v2_methods; #[cfg(any(feature = "value-conversion", feature = "json-conversion"))] use crate::data_contract::DataContract; +#[cfg(all( + feature = "serde-conversion", + any(feature = "json-conversion", feature = "value-conversion") +))] +use crate::serialization; use crate::state_transition::batch_transition::document_base_transition::v0::{ DocumentBaseTransitionV0, DocumentTransitionObjectLike, }; @@ -64,10 +69,10 @@ pub enum DocumentBaseTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for DocumentBaseTransition {} +impl serialization::JsonConvertible for DocumentBaseTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for DocumentBaseTransition {} +impl serialization::ValueConvertible for DocumentBaseTransition {} impl Default for DocumentBaseTransition { fn default() -> Self { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/mod.rs index cffd79d53e1..1419e785296 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/mod.rs @@ -7,6 +7,10 @@ use crate::block::block_info::BlockInfo; use crate::data_contract::document_type::DocumentTypeRef; use crate::document::Document; use crate::prelude::DataContract; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::state_transition::batch_transition::document_create_transition::v0::DocumentFromCreateTransitionV0; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; @@ -30,10 +34,10 @@ pub enum DocumentCreateTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for DocumentCreateTransition {} +impl JsonConvertible for DocumentCreateTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for DocumentCreateTransition {} +impl ValueConvertible for DocumentCreateTransition {} impl Default for DocumentCreateTransition { fn default() -> Self { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/mod.rs index b90230cd14f..abd5e7a091c 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_delete_transition/mod.rs @@ -2,6 +2,10 @@ mod from_document; pub mod v0; pub mod v0_methods; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -21,10 +25,10 @@ pub enum DocumentDeleteTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for DocumentDeleteTransition {} +impl JsonConvertible for DocumentDeleteTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for DocumentDeleteTransition {} +impl ValueConvertible for DocumentDeleteTransition {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_index_only_delete_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_index_only_delete_transition/mod.rs index 2c3ac83cfea..3f7905376c5 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_index_only_delete_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_index_only_delete_transition/mod.rs @@ -2,6 +2,10 @@ mod from_document; pub mod v0; pub mod v0_methods; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -32,10 +36,10 @@ pub enum DocumentIndexOnlyDeleteTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for DocumentIndexOnlyDeleteTransition {} +impl JsonConvertible for DocumentIndexOnlyDeleteTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for DocumentIndexOnlyDeleteTransition {} +impl ValueConvertible for DocumentIndexOnlyDeleteTransition {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/mod.rs index 0b3fceabb0d..0cdfc6f610d 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_purchase_transition/mod.rs @@ -2,6 +2,10 @@ mod from_document; pub mod v0; pub mod v0_methods; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -21,10 +25,10 @@ pub enum DocumentPurchaseTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for DocumentPurchaseTransition {} +impl JsonConvertible for DocumentPurchaseTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for DocumentPurchaseTransition {} +impl ValueConvertible for DocumentPurchaseTransition {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/mod.rs index 03d3fa08797..c64726764c1 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/mod.rs @@ -6,6 +6,10 @@ use crate::block::block_info::BlockInfo; use crate::data_contract::document_type::DocumentTypeRef; use crate::document::Document; use crate::prelude::{BlockHeight, CoreBlockHeight, TimestampMillis}; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; @@ -28,10 +32,10 @@ pub enum DocumentReplaceTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for DocumentReplaceTransition {} +impl JsonConvertible for DocumentReplaceTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for DocumentReplaceTransition {} +impl ValueConvertible for DocumentReplaceTransition {} /// document from replace transition pub trait DocumentFromReplaceTransition { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/mod.rs index 2dc61cdb993..475613cd891 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transfer_transition/mod.rs @@ -2,6 +2,10 @@ mod from_document; pub mod v0; pub mod v0_methods; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -21,10 +25,10 @@ pub enum DocumentTransferTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for DocumentTransferTransition {} +impl JsonConvertible for DocumentTransferTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for DocumentTransferTransition {} +impl ValueConvertible for DocumentTransferTransition {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transition.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transition.rs index 1b9c13ddea9..6a861ffbc5f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transition.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transition.rs @@ -5,6 +5,10 @@ use derive_more::{Display, From}; use serde::{Deserialize, Serialize}; use bincode::{Encode, Decode, DecodeUntrusted}; use crate::prelude::{IdentityNonce, Revision}; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::state_transition::batch_transition::{DocumentCreateTransition, DocumentDeleteTransition, DocumentReplaceTransition, TokenBurnTransition, TokenConfigUpdateTransition, TokenDestroyFrozenFundsTransition, TokenEmergencyActionTransition, TokenFreezeTransition, TokenMintTransition, TokenClaimTransition, TokenTransferTransition, TokenUnfreezeTransition, TokenDirectPurchaseTransition, TokenSetPriceForDirectPurchaseTransition}; use crate::state_transition::batch_transition::batched_transition::{DocumentIndexOnlyDeleteTransition, DocumentPurchaseTransition, DocumentTransferTransition, DocumentUpdatePriceTransition}; use crate::state_transition::batch_transition::batched_transition::document_index_only_delete_transition::v0::v0_methods::DocumentIndexOnlyDeleteTransitionV0Methods; @@ -60,10 +64,10 @@ pub enum DocumentTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for DocumentTransition {} +impl JsonConvertible for DocumentTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for DocumentTransition {} +impl ValueConvertible for DocumentTransition {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/mod.rs index 41cf0ba73ca..5b04f189369 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_update_price_transition/mod.rs @@ -2,6 +2,10 @@ mod from_document; pub mod v0; pub mod v0_methods; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -21,10 +25,10 @@ pub enum DocumentUpdatePriceTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for DocumentUpdatePriceTransition {} +impl JsonConvertible for DocumentUpdatePriceTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for DocumentUpdatePriceTransition {} +impl ValueConvertible for DocumentUpdatePriceTransition {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs index a71a4271563..6eb8185f9dc 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/mod.rs @@ -31,6 +31,10 @@ pub mod token_transition_action_type; pub mod token_unfreeze_transition; use crate::prelude::IdentityNonce; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; use crate::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods; use derive_more::Display; @@ -67,10 +71,10 @@ pub enum BatchedTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for BatchedTransition {} +impl JsonConvertible for BatchedTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for BatchedTransition {} +impl ValueConvertible for BatchedTransition {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_base_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_base_transition/mod.rs index 0591c28720f..90d722530c7 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_base_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_base_transition/mod.rs @@ -5,6 +5,11 @@ mod v0_methods; #[cfg(any(feature = "value-conversion", feature = "json-conversion"))] use crate::data_contract::DataContract; +#[cfg(all( + feature = "serde-conversion", + any(feature = "json-conversion", feature = "value-conversion") +))] +use crate::serialization; use crate::state_transition::batch_transition::document_base_transition::v0::DocumentTransitionObjectLike; use crate::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; #[cfg(any(feature = "value-conversion", feature = "json-conversion"))] @@ -43,10 +48,10 @@ pub enum TokenBaseTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenBaseTransition {} +impl serialization::JsonConvertible for TokenBaseTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenBaseTransition {} +impl serialization::ValueConvertible for TokenBaseTransition {} impl Default for TokenBaseTransition { fn default() -> Self { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_burn_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_burn_transition/mod.rs index 7a4016c28f0..d6b8b38a25e 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_burn_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_burn_transition/mod.rs @@ -2,6 +2,10 @@ pub mod v0; mod v0_methods; pub mod validate_structure; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -21,10 +25,10 @@ pub enum TokenBurnTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenBurnTransition {} +impl JsonConvertible for TokenBurnTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenBurnTransition {} +impl ValueConvertible for TokenBurnTransition {} impl Default for TokenBurnTransition { fn default() -> Self { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_claim_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_claim_transition/mod.rs index 9391d9a90a0..8f3b8f805c0 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_claim_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_claim_transition/mod.rs @@ -2,6 +2,10 @@ pub mod v0; mod v0_methods; pub mod validate_structure; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -21,10 +25,10 @@ pub enum TokenClaimTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenClaimTransition {} +impl JsonConvertible for TokenClaimTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenClaimTransition {} +impl ValueConvertible for TokenClaimTransition {} impl Default for TokenClaimTransition { fn default() -> Self { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_config_update_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_config_update_transition/mod.rs index 7c82fec25e1..f65fa75e666 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_config_update_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_config_update_transition/mod.rs @@ -2,6 +2,10 @@ pub mod v0; mod v0_methods; pub mod validate_structure; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -21,10 +25,10 @@ pub enum TokenConfigUpdateTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenConfigUpdateTransition {} +impl JsonConvertible for TokenConfigUpdateTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenConfigUpdateTransition {} +impl ValueConvertible for TokenConfigUpdateTransition {} impl Default for TokenConfigUpdateTransition { fn default() -> Self { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_destroy_frozen_funds_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_destroy_frozen_funds_transition/mod.rs index b4db76f1426..dd2054cca4f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_destroy_frozen_funds_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_destroy_frozen_funds_transition/mod.rs @@ -2,6 +2,10 @@ pub mod v0; mod v0_methods; pub mod validate_structure; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -21,10 +25,10 @@ pub enum TokenDestroyFrozenFundsTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenDestroyFrozenFundsTransition {} +impl JsonConvertible for TokenDestroyFrozenFundsTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenDestroyFrozenFundsTransition {} +impl ValueConvertible for TokenDestroyFrozenFundsTransition {} impl Default for TokenDestroyFrozenFundsTransition { fn default() -> Self { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_direct_purchase_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_direct_purchase_transition/mod.rs index ae1e96cef72..25a321c6c14 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_direct_purchase_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_direct_purchase_transition/mod.rs @@ -2,6 +2,10 @@ pub mod v0; mod v0_methods; pub mod validate_structure; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -35,10 +39,10 @@ pub enum TokenDirectPurchaseTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenDirectPurchaseTransition {} +impl JsonConvertible for TokenDirectPurchaseTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenDirectPurchaseTransition {} +impl ValueConvertible for TokenDirectPurchaseTransition {} impl Default for TokenDirectPurchaseTransition { fn default() -> Self { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_emergency_action_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_emergency_action_transition/mod.rs index 0c062ff60a0..907de9d2af5 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_emergency_action_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_emergency_action_transition/mod.rs @@ -2,6 +2,10 @@ pub mod v0; mod v0_methods; pub mod validate_structure; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -21,10 +25,10 @@ pub enum TokenEmergencyActionTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenEmergencyActionTransition {} +impl JsonConvertible for TokenEmergencyActionTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenEmergencyActionTransition {} +impl ValueConvertible for TokenEmergencyActionTransition {} impl Default for TokenEmergencyActionTransition { fn default() -> Self { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_freeze_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_freeze_transition/mod.rs index dbdfa720d57..5af0295c697 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_freeze_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_freeze_transition/mod.rs @@ -2,6 +2,10 @@ pub mod v0; mod v0_methods; pub mod validate_structure; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -21,10 +25,10 @@ pub enum TokenFreezeTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenFreezeTransition {} +impl JsonConvertible for TokenFreezeTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenFreezeTransition {} +impl ValueConvertible for TokenFreezeTransition {} impl Default for TokenFreezeTransition { fn default() -> Self { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/mod.rs index b82c7c3a0e0..7c6520ad18d 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_mint_transition/mod.rs @@ -2,6 +2,10 @@ pub mod v0; mod v0_methods; pub mod validate_structure; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -21,10 +25,10 @@ pub enum TokenMintTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenMintTransition {} +impl JsonConvertible for TokenMintTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenMintTransition {} +impl ValueConvertible for TokenMintTransition {} impl Default for TokenMintTransition { fn default() -> Self { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_set_price_for_direct_purchase_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_set_price_for_direct_purchase_transition/mod.rs index 5bd86aa8b16..4c5065cabc2 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_set_price_for_direct_purchase_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_set_price_for_direct_purchase_transition/mod.rs @@ -2,6 +2,10 @@ pub mod v0; mod v0_methods; pub mod validate_structure; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -43,10 +47,10 @@ pub enum TokenSetPriceForDirectPurchaseTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenSetPriceForDirectPurchaseTransition {} +impl JsonConvertible for TokenSetPriceForDirectPurchaseTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenSetPriceForDirectPurchaseTransition {} +impl ValueConvertible for TokenSetPriceForDirectPurchaseTransition {} impl Default for TokenSetPriceForDirectPurchaseTransition { fn default() -> Self { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/mod.rs index c33844251cf..d5d66023cb3 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transfer_transition/mod.rs @@ -2,6 +2,10 @@ pub mod v0; pub mod v0_methods; pub mod validate_structure; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -21,10 +25,10 @@ pub enum TokenTransferTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenTransferTransition {} +impl JsonConvertible for TokenTransferTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenTransferTransition {} +impl ValueConvertible for TokenTransferTransition {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs index 78b358d4c50..fa278d4886f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_transition.rs @@ -19,6 +19,10 @@ use crate::data_contract::document_type::DocumentTypeRef; use crate::document::Document; use crate::prelude::IdentityNonce; use crate::ProtocolError; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::state_transition::batch_transition::{DocumentCreateTransition, DocumentDeleteTransition, DocumentReplaceTransition, TokenBurnTransition, TokenConfigUpdateTransition, TokenDestroyFrozenFundsTransition, TokenEmergencyActionTransition, TokenFreezeTransition, TokenMintTransition, TokenClaimTransition, TokenTransferTransition, TokenSetPriceForDirectPurchaseTransition}; use crate::state_transition::batch_transition::batched_transition::{DocumentPurchaseTransition, DocumentTransferTransition}; use crate::state_transition::batch_transition::batched_transition::multi_party_action::AllowedAsMultiPartyAction; @@ -93,10 +97,10 @@ pub enum TokenTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenTransition {} +impl JsonConvertible for TokenTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenTransition {} +impl ValueConvertible for TokenTransition {} #[cfg(all( test, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_unfreeze_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_unfreeze_transition/mod.rs index 557936417e8..fa32836ec75 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_unfreeze_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/token_unfreeze_transition/mod.rs @@ -2,6 +2,10 @@ pub mod v0; mod v0_methods; pub mod validate_structure; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::{Display, From}; #[cfg(feature = "serde-conversion")] @@ -21,10 +25,10 @@ pub enum TokenUnfreezeTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenUnfreezeTransition {} +impl JsonConvertible for TokenUnfreezeTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenUnfreezeTransition {} +impl ValueConvertible for TokenUnfreezeTransition {} impl Default for TokenUnfreezeTransition { fn default() -> Self { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs index b3abe347bbb..419af15fd24 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/mod.rs @@ -59,6 +59,10 @@ use crate::state_transition::data_contract_update_transition::{ use crate::state_transition::batch_transition::fields::property_names; use crate::identity::state_transition::OptionallyAssetLockProved; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; pub use v0::*; pub use v1::*; @@ -93,10 +97,10 @@ pub enum BatchTransition { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for BatchTransition {} +impl JsonConvertible for BatchTransition {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for BatchTransition {} +impl ValueConvertible for BatchTransition {} impl StateTransitionFieldTypes for BatchTransition { fn binary_property_paths() -> Vec<&'static str> { diff --git a/packages/rs-dpp/src/tokens/contract_info/mod.rs b/packages/rs-dpp/src/tokens/contract_info/mod.rs index 70af4cf0c3f..df7fdede946 100644 --- a/packages/rs-dpp/src/tokens/contract_info/mod.rs +++ b/packages/rs-dpp/src/tokens/contract_info/mod.rs @@ -1,4 +1,8 @@ use crate::data_contract::TokenContractPosition; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::tokens::contract_info::v0::TokenContractInfoV0; use crate::ProtocolError; use bincode::{DecodeUntrusted, Encode}; @@ -42,10 +46,10 @@ pub enum TokenContractInfo { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenContractInfo {} +impl JsonConvertible for TokenContractInfo {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenContractInfo {} +impl ValueConvertible for TokenContractInfo {} impl TokenContractInfo { pub fn new( diff --git a/packages/rs-dpp/src/tokens/emergency_action.rs b/packages/rs-dpp/src/tokens/emergency_action.rs index d7cfa758edf..7031fa6b27a 100644 --- a/packages/rs-dpp/src/tokens/emergency_action.rs +++ b/packages/rs-dpp/src/tokens/emergency_action.rs @@ -1,3 +1,7 @@ +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::tokens::status::TokenStatus; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; @@ -20,10 +24,10 @@ pub enum TokenEmergencyAction { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenEmergencyAction {} +impl JsonConvertible for TokenEmergencyAction {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenEmergencyAction {} +impl ValueConvertible for TokenEmergencyAction {} impl TokenEmergencyAction { pub fn paused(&self) -> bool { diff --git a/packages/rs-dpp/src/tokens/gas_fees_paid_by.rs b/packages/rs-dpp/src/tokens/gas_fees_paid_by.rs index df66f53bcbb..8d0b2fb9dec 100644 --- a/packages/rs-dpp/src/tokens/gas_fees_paid_by.rs +++ b/packages/rs-dpp/src/tokens/gas_fees_paid_by.rs @@ -1,6 +1,10 @@ use crate::consensus::basic::data_contract::UnknownGasFeesPaidByError; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::Display; @@ -79,10 +83,10 @@ impl GasFeesPaidBy { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for GasFeesPaidBy {} +impl JsonConvertible for GasFeesPaidBy {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for GasFeesPaidBy {} +impl ValueConvertible for GasFeesPaidBy {} impl From for u8 { fn from(value: GasFeesPaidBy) -> Self { diff --git a/packages/rs-dpp/src/tokens/token_event.rs b/packages/rs-dpp/src/tokens/token_event.rs index 5c836775f12..7a9891b04e5 100644 --- a/packages/rs-dpp/src/tokens/token_event.rs +++ b/packages/rs-dpp/src/tokens/token_event.rs @@ -10,6 +10,8 @@ use crate::fee::Credits; use crate::prelude::{ DataContract, DerivationEncryptionKeyIndex, IdentityNonce, RootEncryptionKeyIndex, }; +#[cfg(feature = "serde-conversion")] +use crate::serialization::json::safe_integer::{json_safe_option_encrypted_note, json_safe_u64}; #[cfg(feature = "json-conversion")] use crate::serialization::JsonConvertible; #[cfg(feature = "value-conversion")] @@ -188,15 +190,13 @@ impl serde::Serialize for TokenEvent { struct SafeU64<'a>(&'a u64); impl<'a> serde::Serialize for SafeU64<'a> { fn serialize(&self, s: S) -> Result { - crate::serialization::json::safe_integer::json_safe_u64::serialize(self.0, s) + json_safe_u64::serialize(self.0, s) } } struct SafeOptEncNote<'a>(&'a Option<(u32, u32, Vec)>); impl<'a> serde::Serialize for SafeOptEncNote<'a> { fn serialize(&self, s: S) -> Result { - crate::serialization::json::safe_integer::json_safe_option_encrypted_note::serialize( - self.0, s, - ) + json_safe_option_encrypted_note::serialize(self.0, s) } } diff --git a/packages/rs-dpp/src/tokens/token_payment_info/mod.rs b/packages/rs-dpp/src/tokens/token_payment_info/mod.rs index 26c00466984..0bac2a8cbb4 100644 --- a/packages/rs-dpp/src/tokens/token_payment_info/mod.rs +++ b/packages/rs-dpp/src/tokens/token_payment_info/mod.rs @@ -44,6 +44,10 @@ //! use crate::balances::credits::TokenAmount; use crate::data_contract::TokenContractPosition; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use crate::tokens::gas_fees_paid_by::GasFeesPaidBy; use crate::tokens::token_payment_info::methods::v0::TokenPaymentInfoMethodsV0; use crate::tokens::token_payment_info::v0::v0_accessors::TokenPaymentInfoAccessorsV0; @@ -100,10 +104,10 @@ pub enum TokenPaymentInfo { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenPaymentInfo {} +impl JsonConvertible for TokenPaymentInfo {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenPaymentInfo {} +impl ValueConvertible for TokenPaymentInfo {} impl TokenPaymentInfoMethodsV0 for TokenPaymentInfo {} diff --git a/packages/rs-dpp/src/tokens/token_pricing_schedule.rs b/packages/rs-dpp/src/tokens/token_pricing_schedule.rs index 9d1c3a608c6..c282ad15f33 100644 --- a/packages/rs-dpp/src/tokens/token_pricing_schedule.rs +++ b/packages/rs-dpp/src/tokens/token_pricing_schedule.rs @@ -1,6 +1,10 @@ use crate::balances::credits::TokenAmount; use crate::errors::ProtocolError; use crate::fee::Credits; +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; use platform_serialization_derive::{ PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, @@ -99,10 +103,10 @@ impl From for TokenPricingSchedule { } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for TokenPricingSchedule {} +impl JsonConvertible for TokenPricingSchedule {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for TokenPricingSchedule {} +impl ValueConvertible for TokenPricingSchedule {} impl TokenPricingSchedule { pub fn minimum_purchase_amount_and_price(&self) -> (TokenAmount, Credits) { diff --git a/packages/rs-dpp/src/voting/vote_choices/yes_no_abstain_vote_choice/mod.rs b/packages/rs-dpp/src/voting/vote_choices/yes_no_abstain_vote_choice/mod.rs index 78af1ddccee..a3e16b456a5 100644 --- a/packages/rs-dpp/src/voting/vote_choices/yes_no_abstain_vote_choice/mod.rs +++ b/packages/rs-dpp/src/voting/vote_choices/yes_no_abstain_vote_choice/mod.rs @@ -1,3 +1,7 @@ +#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] +use crate::serialization::JsonConvertible; +#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] +use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; @@ -17,10 +21,10 @@ pub enum YesNoAbstainVoteChoice { // --- canonical conversion trait impls (unification pass 1) --- #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] -impl crate::serialization::JsonConvertible for YesNoAbstainVoteChoice {} +impl JsonConvertible for YesNoAbstainVoteChoice {} #[cfg(all(feature = "value-conversion", feature = "serde-conversion"))] -impl crate::serialization::ValueConvertible for YesNoAbstainVoteChoice {} +impl ValueConvertible for YesNoAbstainVoteChoice {} #[cfg(all( test, diff --git a/packages/rs-drive-abci/src/abci/app/check_tx.rs b/packages/rs-drive-abci/src/abci/app/check_tx.rs index 170eb519599..c874ba1883f 100644 --- a/packages/rs-drive-abci/src/abci/app/check_tx.rs +++ b/packages/rs-drive-abci/src/abci/app/check_tx.rs @@ -1,5 +1,6 @@ use crate::abci::app::PlatformApplication; use crate::abci::handler; +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; @@ -96,7 +97,7 @@ where pub fn error_into_status(error: Error) -> tonic::Status { match error { - Error::Execution(crate::error::execution::ExecutionError::CheckTxProofVerificationBusy) => { + Error::Execution(ExecutionError::CheckTxProofVerificationBusy) => { tonic::Status::resource_exhausted( "check tx verification capacity is temporarily unavailable", ) diff --git a/packages/rs-drive-abci/src/abci/handler/finalize_block.rs b/packages/rs-drive-abci/src/abci/handler/finalize_block.rs index 873ad0a7890..efaa5ffe996 100644 --- a/packages/rs-drive-abci/src/abci/handler/finalize_block.rs +++ b/packages/rs-drive-abci/src/abci/handler/finalize_block.rs @@ -2,6 +2,9 @@ use crate::abci::app::{BlockExecutionApplication, PlatformApplication, Transacti use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::block_execution_context::v0::BlockExecutionContextV0Getters; +use crate::metrics; +#[cfg(debug_assertions)] +use crate::perf::{self, PhaseTimer}; use crate::platform_types::cleaned_abci_messages::finalized_block_cleaned_request::v0::FinalizeBlockCleanedRequest; use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::rpc::core::CoreRPCLike; @@ -20,7 +23,7 @@ where { let _timer = crate::metrics::abci_request_duration("finalize_block"); #[cfg(debug_assertions)] - let mut phases = crate::perf::PhaseTimer::new("finalize_block"); + let mut phases = PhaseTimer::new("finalize_block"); let transaction_guard = app.transaction().read().unwrap(); let transaction = @@ -197,11 +200,11 @@ where }); match result { Ok(()) => { - crate::metrics::abci_last_checkpoint_height(block_height); + metrics::abci_last_checkpoint_height(block_height); tracing::debug!(block_height, "created grovedb checkpoint"); } Err(error) => { - crate::metrics::abci_checkpoint_failed(); + metrics::abci_checkpoint_failed(); tracing::error!( ?error, block_height, @@ -221,7 +224,7 @@ where #[cfg(debug_assertions)] drop(phases); #[cfg(debug_assertions)] - crate::perf::end_block(block_height); + perf::end_block(block_height); Ok(proto::ResponseFinalizeBlock { retain_height: 0, diff --git a/packages/rs-drive-abci/src/execution/engine/run_block_proposal/mod.rs b/packages/rs-drive-abci/src/execution/engine/run_block_proposal/mod.rs index 16e203190a0..2f62b61c6a2 100644 --- a/packages/rs-drive-abci/src/execution/engine/run_block_proposal/mod.rs +++ b/packages/rs-drive-abci/src/execution/engine/run_block_proposal/mod.rs @@ -3,6 +3,8 @@ use crate::error::Error; use crate::execution::types::block_state_info; use crate::execution::types::block_state_info::v0::BlockStateInfoV0Methods; use crate::metrics::HistogramTiming; +#[cfg(debug_assertions)] +use crate::perf::PhaseTimer; use crate::platform_types::epoch_info::v0::{EpochInfoV0Getters, EpochInfoV0Methods}; use crate::platform_types::platform::Platform; use crate::platform_types::platform_state::PlatformState; @@ -54,7 +56,7 @@ where ) -> Result, Error> { #[cfg(debug_assertions)] - let mut phases = crate::perf::PhaseTimer::new("run_block_proposal"); + let mut phases = PhaseTimer::new("run_block_proposal"); // Epoch information is always calculated with the last committed platform version // even if we are switching to a new version in this block. diff --git a/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/v0/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/v0/mod.rs index 1f118518d03..d18c5eef8ec 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/v0/mod.rs @@ -13,6 +13,7 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; use dpp::document::Document; +use crate::drive::document::index_only_row_commitment; use crate::drive::Drive; use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; @@ -162,11 +163,8 @@ impl Drive { platform_version, )?; } else { - let expected_commitment = crate::drive::document::index_only_row_commitment( - &document, - document_type, - platform_version, - )?; + let expected_commitment = + index_only_row_commitment(&document, document_type, platform_version)?; for index in document_type.indexes().values() { let matches = self.index_only_entry_commitment_matches( contract.id(), diff --git a/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v1/mod.rs index 86e0c38e402..b92d4a98df9 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_reference_for_index_level_for_contract_operations/v1/mod.rs @@ -14,7 +14,7 @@ use crate::drive::constants::CONTRACT_DOCUMENTS_PATH_HEIGHT; use crate::drive::document::document_reference_size; use crate::drive::document::index_level_tree_types::terminal_member_tree_type; use crate::drive::document::index_only::{index_only_member_key, index_only_terminal_max_key_size}; -use crate::drive::document::index_only_entry_payload_max_size; +use crate::drive::document::{index_only_entry_payload_max_size, INDEX_ONLY_ROW_COMMITMENT_SIZE}; use crate::error::drive::DriveError; use crate::util::storage_flags::StorageFlags; @@ -78,11 +78,9 @@ impl Drive { .iter() .map(|key_info| match key_info { KnownKey(key) => Ok(key.clone()), - _ => Err(Error::Drive( - crate::error::drive::DriveError::CorruptedCodeExecution( - "expired-entry skip is stateful-only; its path must be known", - ), - )), + _ => Err(Error::Drive(DriveError::CorruptedCodeExecution( + "expired-entry skip is stateful-only; its path must be known", + ))), }) .collect::>()?; // The layouts that store the reference inside a `[0]` subtree @@ -134,7 +132,7 @@ impl Drive { let member_key_max_size = index_only_terminal_max_key_size(document_type, terminal, platform_version)?; // The stored item: the commitment plus the type's entry payload. - let entry_value_size = crate::drive::document::INDEX_ONLY_ROW_COMMITMENT_SIZE + let entry_value_size = INDEX_ONLY_ROW_COMMITMENT_SIZE + index_only_entry_payload_max_size(document_type, platform_version)?; // Sum-bearing entries (`ItemWithSumItem`) carry the i64 sum diff --git a/packages/rs-drive/src/drive/document/index_only.rs b/packages/rs-drive/src/drive/document/index_only.rs index 269afe34059..ecc75bb88bf 100644 --- a/packages/rs-drive/src/drive/document/index_only.rs +++ b/packages/rs-drive/src/drive/document/index_only.rs @@ -24,7 +24,7 @@ use crate::drive::constants::CONTRACT_DOCUMENTS_PATH_HEIGHT; use crate::drive::document::index_level_tree_types::terminal_member_tree_type; use crate::drive::document::index_only_item_estimated_value_size; use crate::drive::document::time_range_ttl::entry_key_bucket_start; -use crate::drive::Drive; +use crate::drive::{Drive, RootTree}; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; @@ -140,7 +140,7 @@ impl Drive { } let prefix: Vec> = vec![ - vec![crate::drive::RootTree::DataContractDocuments as u8], + vec![RootTree::DataContractDocuments as u8], contract_id.to_vec(), vec![1], document_type.name().as_bytes().to_vec(), diff --git a/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs index 8136163345e..780798c90f1 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/document/find_withdrawal_documents_by_status_and_transaction_indices/v1/mod.rs @@ -1,7 +1,7 @@ use crate::drive::document::query::QueryDocumentsOutcomeV0Methods; use crate::drive::Drive; use crate::error::Error; -use crate::query::{DriveDocumentQuery, InternalClauses, OrderClause, WhereClause}; +use crate::query::{DriveDocumentQuery, InternalClauses, OrderClause, WhereClause, WhereOperator}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contracts::withdrawals_contract; use dpp::data_contracts::withdrawals_contract::v1::document_types::withdrawal; @@ -38,14 +38,14 @@ impl Drive { withdrawal::properties::STATUS.to_string(), WhereClause { field: withdrawal::properties::STATUS.to_string(), - operator: crate::query::WhereOperator::Equal, + operator: WhereOperator::Equal, value: Value::U8(status as u8), }, ); let transaction_index_in_clause = WhereClause { field: withdrawal::properties::TRANSACTION_INDEX.to_string(), - operator: crate::query::WhereOperator::In, + operator: WhereOperator::In, value: Value::Array( transaction_indices .iter() diff --git a/packages/rs-drive/src/query/chained_document_query/mod.rs b/packages/rs-drive/src/query/chained_document_query/mod.rs index 7aa60c1be44..8018de12202 100644 --- a/packages/rs-drive/src/query/chained_document_query/mod.rs +++ b/packages/rs-drive/src/query/chained_document_query/mod.rs @@ -51,6 +51,7 @@ //! pagination lives on the inner query alone. use crate::error::drive::DriveError; +use crate::error::proof::ProofError; use crate::error::query::QuerySyntaxError; use crate::error::Error; use crate::query::{ @@ -437,12 +438,10 @@ impl<'a> DriveDocumentQuery<'a> { for document in outer_documents { let id = document.id(); if by_id.insert(id, document).is_some() { - return Err(Error::Proof( - crate::error::proof::ProofError::CorruptedProof(format!( - "chained outer results carry document {} twice", - id - )), - )); + return Err(Error::Proof(ProofError::CorruptedProof(format!( + "chained outer results carry document {} twice", + id + )))); } } let target_is_permanent = self.chained_join_target_is_permanent()?; @@ -452,27 +451,23 @@ impl<'a> DriveDocumentQuery<'a> { match by_id.remove(join_value) { Some(document) => ordered.push(document), None if target_is_permanent => { - return Err(Error::Proof( - crate::error::proof::ProofError::CorruptedProof(format!( - "chained outer results are missing referenced document {}: a \ + return Err(Error::Proof(ProofError::CorruptedProof(format!( + "chained outer results are missing referenced document {}: a \ permanentDocument reference cannot dangle, so the outer half \ does not prove the derived query", - join_value - )), - )); + join_value + )))); } // A deletableDocument target that is no longer in state. None => missing.push(*join_value), } } if let Some((extra_id, _)) = by_id.into_iter().next() { - return Err(Error::Proof( - crate::error::proof::ProofError::CorruptedProof(format!( - "chained outer results carry document {} that no proven join value \ + return Err(Error::Proof(ProofError::CorruptedProof(format!( + "chained outer results carry document {} that no proven join value \ references", - extra_id - )), - )); + extra_id + )))); } Ok((ordered, missing)) } diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index acc2b667abd..e428a7f571b 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -3,6 +3,7 @@ use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::where_clause_grouping::group_where_clauses; use crate::query::{QuerySyntaxSimpleValidationResult, QuerySyntaxValidationResult}; #[cfg(any(feature = "server", feature = "verify"))] use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; @@ -573,7 +574,7 @@ impl<'a> WhereClause { where_clauses: &'a [WhereClause], platform_version: &PlatformVersion, ) -> Result<(BTreeMap, Option, Vec), Error> { - crate::query::where_clause_grouping::group_where_clauses(where_clauses, platform_version) + group_where_clauses(where_clauses, platform_version) } fn split_value_for_between( diff --git a/packages/rs-drive/src/query/drive_document_ranked_query/mod.rs b/packages/rs-drive/src/query/drive_document_ranked_query/mod.rs index 5b54a342116..ada7ed1a7bf 100644 --- a/packages/rs-drive/src/query/drive_document_ranked_query/mod.rs +++ b/packages/rs-drive/src/query/drive_document_ranked_query/mod.rs @@ -75,6 +75,10 @@ //! re-sort. Ties are broken by group key — see //! [`DriveDocumentRankedQuery::descending`]. +#[cfg(any(feature = "server", feature = "verify"))] +use crate::error::query::QuerySyntaxError; +#[cfg(any(feature = "server", feature = "verify"))] +use crate::error::Error; #[cfg(any(feature = "server", feature = "verify"))] use dpp::data_contract::document_type::{DocumentTypeRef, Index}; #[cfg(any(feature = "server", feature = "verify"))] @@ -392,15 +396,13 @@ impl DriveDocumentRankedQuery<'_> { /// directly. pub(crate) fn reject_offset_with_branches(&self) -> Result<(), crate::error::Error> { if self.prefix_branches.len() > 1 && self.offset != 0 { - return Err(crate::error::Error::Query( - crate::error::query::QuerySyntaxError::InvalidLimit( - "`OFFSET` cannot combine with an `IN` prefix pin: rank-skip is attested \ + return Err(Error::Query(QuerySyntaxError::InvalidLimit( + "`OFFSET` cannot combine with an `IN` prefix pin: rank-skip is attested \ from one secondary's counted commitments, and an `IN` merges several \ secondaries with no counted structure over the union. Page one prefix \ at a time (`==` pin + `OFFSET`), or drop the offset." - .to_string(), - ), - )); + .to_string(), + ))); } Ok(()) } diff --git a/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs index da3cf1652ef..7ef02036a3e 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/drive_dispatcher.rs @@ -14,6 +14,7 @@ use crate::config::DriveConfig; use crate::drive::Drive; use crate::error::query::QuerySyntaxError; use crate::error::Error; +use crate::query::drive_document_count_query::drive_dispatcher as count_dispatcher; use crate::query::drive_document_sum_query::{ DocumentSumMode, DocumentSumRequest, DocumentSumResponse, RangeSumOptions, RangeSumWalkMode, SumMode, @@ -300,10 +301,7 @@ pub fn where_clauses_from_value( value: &Value, platform_version: &PlatformVersion, ) -> Result, Error> { - crate::query::drive_document_count_query::drive_dispatcher::where_clauses_from_value( - value, - platform_version, - ) + count_dispatcher::where_clauses_from_value(value, platform_version) } /// Parse the wire-CBOR `Value::Array` shape into structured diff --git a/packages/rs-drive/src/query/index_only_synthesis.rs b/packages/rs-drive/src/query/index_only_synthesis.rs index 569792b9f1a..007d2e9d3e5 100644 --- a/packages/rs-drive/src/query/index_only_synthesis.rs +++ b/packages/rs-drive/src/query/index_only_synthesis.rs @@ -34,9 +34,14 @@ //! the index the query resolved is an error, never a partial document. use crate::drive::document::{decode_index_only_entry_payload, INDEX_ONLY_ROW_COMMITMENT_SIZE}; +use crate::drive::RootTree; use crate::error::drive::DriveError; +use crate::error::query::QuerySyntaxError; use crate::error::Error; -use crate::query::{index_admissible_for_skip_if_absent, DriveDocumentQuery}; +use crate::query::{ + index_admissible_for_skip_if_absent, BestIndexOutcome, DriveDocumentQuery, InternalClauses, + WhereClause, +}; use crate::verify::RootHash; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; @@ -60,17 +65,17 @@ pub(crate) struct IndexOnlyTerminalRoute<'a> { pub index: &'a Index, /// The clause on the index's terminal property; `None` only for the /// first keyset page (order-by on the terminal, no cursor clause yet). - pub terminal_clause: Option<&'a crate::query::WhereClause>, + pub terminal_clause: Option<&'a WhereClause>, /// `(position, clause)` of a range / `in` clause on a prefix /// property. When present the terminal clause is always an equality. - pub prefix_pivot: Option<(usize, &'a crate::query::WhereClause)>, + pub prefix_pivot: Option<(usize, &'a WhereClause)>, /// COMPOSITE terminals only: the equality clauses on the terminal's /// leading components, in component order (`terminal_clause` is `None` /// on a composite terminal). - pub terminal_equalities: Vec<&'a crate::query::WhereClause>, + pub terminal_equalities: Vec<&'a WhereClause>, /// COMPOSITE terminals only: the one range / `in` clause on the first /// component after the equality-bound ones, if any. - pub terminal_tail: Option<&'a crate::query::WhereClause>, + pub terminal_tail: Option<&'a WhereClause>, } impl DriveDocumentQuery<'_> { @@ -131,9 +136,10 @@ impl DriveDocumentQuery<'_> { // on an index prefix property". let clause_roles = self.internal_clauses.classify_fields(self.document_type); let names_a_terminal = clause_roles.values().any(|roles| roles.terminal) - || self.order_by.keys().any(|field| { - crate::query::InternalClauses::classify_field(self.document_type, field).terminal - }); + || self + .order_by + .keys() + .any(|field| InternalClauses::classify_field(self.document_type, field).terminal); if !names_a_terminal { return Ok(None); } @@ -211,11 +217,8 @@ impl DriveDocumentQuery<'_> { } let is_component = |field: &str| components.iter().any(|component| component == field); - let shape_error = |message: &str| { - Error::Query(crate::error::query::QuerySyntaxError::Unsupported( - message.to_string(), - )) - }; + let shape_error = + |message: &str| Error::Query(QuerySyntaxError::Unsupported(message.to_string())); // A single-component terminal carries at most one clause, on that // component. A composite terminal binds its components in order: @@ -241,7 +244,7 @@ impl DriveDocumentQuery<'_> { (terminal_clause, Vec::new(), None) } None => { - let mut equalities: Vec<&crate::query::WhereClause> = Vec::new(); + let mut equalities: Vec<&WhereClause> = Vec::new(); for component in components { match self.internal_clauses.equal_clauses.get(component.as_str()) { Some(clause) => equalities.push(clause), @@ -291,7 +294,7 @@ impl DriveDocumentQuery<'_> { } direction = Some(order.ascending); } - let tail_candidates: Vec<&crate::query::WhereClause> = self + let tail_candidates: Vec<&WhereClause> = self .internal_clauses .range_clause .iter() @@ -330,7 +333,7 @@ impl DriveDocumentQuery<'_> { .iter() .position(|property| property.name == field) }; - let mut prefix_pivot: Option<(usize, &crate::query::WhereClause)> = None; + let mut prefix_pivot: Option<(usize, &WhereClause)> = None; for clause in self .internal_clauses .range_clause @@ -373,17 +376,15 @@ impl DriveDocumentQuery<'_> { orderBy limited to the index", )); } - let ranged: Option<&crate::query::WhereClause> = terminal_clause + let ranged: Option<&WhereClause> = terminal_clause .filter(|clause| clause.operator.is_range()) .or(terminal_tail.filter(|clause| clause.operator.is_range())); if let Some(ranged) = ranged { if !self.order_by.contains_key(ranged.field.as_str()) { - return Err(Error::Query( - crate::error::query::QuerySyntaxError::MissingOrderByForRange( - "a range or `in` clause on an indexOnly terminal property \ + return Err(Error::Query(QuerySyntaxError::MissingOrderByForRange( + "a range or `in` clause on an indexOnly terminal property \ requires an orderBy on that property", - ), - )); + ))); } } } @@ -422,12 +423,10 @@ impl DriveDocumentQuery<'_> { } } if !self.order_by.contains_key(pivot_clause.field.as_str()) { - return Err(Error::Query( - crate::error::query::QuerySyntaxError::MissingOrderByForRange( - "a range or `in` clause on an indexOnly prefix property \ + return Err(Error::Query(QuerySyntaxError::MissingOrderByForRange( + "a range or `in` clause on an indexOnly prefix property \ requires an orderBy on that property", - ), - )); + ))); } } } @@ -682,11 +681,9 @@ impl DriveDocumentQuery<'_> { Value::Array(values) if values.len() == 2 => { Ok((values[0].clone(), values[1].clone())) } - _ => Err(Error::Query( - crate::error::query::QuerySyntaxError::InvalidBetweenClause( - "when using between operator you must provide a tuple array of values", - ), - )), + _ => Err(Error::Query(QuerySyntaxError::InvalidBetweenClause( + "when using between operator you must provide a tuple array of values", + ))), } }; match tail.operator { @@ -748,13 +745,11 @@ impl DriveDocumentQuery<'_> { } } WhereOperator::StartsWith => { - return Err(Error::Query( - crate::error::query::QuerySyntaxError::Unsupported( - "startsWith is not supported on a composite indexOnly terminal \ + return Err(Error::Query(QuerySyntaxError::Unsupported( + "startsWith is not supported on a composite indexOnly terminal \ component" - .to_string(), - ), - )); + .to_string(), + ))); } } Ok(query) @@ -785,11 +780,11 @@ impl DriveDocumentQuery<'_> { platform_version: &PlatformVersion, ) -> Result<&Index, Error> { match self.select_best_index(platform_version)? { - crate::query::BestIndexOutcome::Matched(index) => { + BestIndexOutcome::Matched(index) => { Self::refuse_bucketed_index_only_synthesis(index)?; Ok(index) } - crate::query::BestIndexOutcome::NoIndexMatches(no_index_error) => { + BestIndexOutcome::NoIndexMatches(no_index_error) => { match self.index_only_terminal_clause_selection(platform_version)? { Some(route) => Ok(route.index), None => Err(no_index_error), @@ -810,16 +805,14 @@ impl DriveDocumentQuery<'_> { /// fires exactly on "documents in this time bucket" requests. fn refuse_bucketed_index_only_synthesis(index: &Index) -> Result<(), Error> { if index.time_range.is_some() { - return Err(Error::Query( - crate::error::query::QuerySyntaxError::Unsupported( - "IN_TIME_RANGE document queries are not supported on an indexOnly type: \ + return Err(Error::Query(QuerySyntaxError::Unsupported( + "IN_TIME_RANGE document queries are not supported on an indexOnly type: \ the bucketed entries carry bucket-start time granularity, so documents \ cannot be synthesized from them; use the count aggregate surfaces over \ the bucketed index, or query the raw entries through a non-bucketed \ index" - .to_string(), - ), - )); + .to_string(), + ))); } Ok(()) } @@ -835,7 +828,7 @@ impl DriveDocumentQuery<'_> { platform_version: &PlatformVersion, ) -> Result, Error> { match self.select_best_index(platform_version)? { - crate::query::BestIndexOutcome::Matched(index) => { + BestIndexOutcome::Matched(index) => { // Refused here as well as in `index_only_query_index` so // the prover and the no-proof executor fail before // building a path query the synthesis side would refuse. @@ -860,7 +853,7 @@ impl DriveDocumentQuery<'_> { } Ok(None) } - crate::query::BestIndexOutcome::NoIndexMatches(no_index_error) => { + BestIndexOutcome::NoIndexMatches(no_index_error) => { match self.index_only_terminal_clause_selection(platform_version)? { Some(route) => self .index_only_terminal_path_query( @@ -884,14 +877,12 @@ impl DriveDocumentQuery<'_> { platform_version: &PlatformVersion, ) -> Result<(RootHash, Vec), Error> { if self.start_at.is_some() { - return Err(Error::Query( - crate::error::query::QuerySyntaxError::Unsupported( - "startAt/startAfter cannot address an indexOnly position (the synthesized \ + return Err(Error::Query(QuerySyntaxError::Unsupported( + "startAt/startAfter cannot address an indexOnly position (the synthesized \ document id is a one-way hash of it); paginate with a range clause on \ the terminal property ordered by the terminal, with a limit" - .to_string(), - ), - )); + .to_string(), + ))); } let path_query = self.construct_path_query(None, platform_version)?; @@ -934,14 +925,12 @@ impl DriveDocumentQuery<'_> { use grovedb::query_result_type::QueryResultType; if self.start_at.is_some() { - return Err(Error::Query( - crate::error::query::QuerySyntaxError::Unsupported( - "startAt/startAfter cannot address an indexOnly position (the synthesized \ + return Err(Error::Query(QuerySyntaxError::Unsupported( + "startAt/startAfter cannot address an indexOnly position (the synthesized \ document id is a one-way hash of it); paginate with a range clause on \ the terminal property ordered by the terminal, with a limit" - .to_string(), - ), - )); + .to_string(), + ))); } let path_query = self.construct_path_query_operations( @@ -1013,13 +1002,11 @@ pub fn index_only_proof_index<'a>(document_type: &'a DocumentTypeRef) -> Result< || index.properties.iter().any(|p| p.name == CREATED_AT); carries_owner && !carries_created_at && !index.skip_if_absent }) - .ok_or(Error::Query( - crate::error::query::QuerySyntaxError::Unsupported( - "executed-transition proofs for an indexOnly type need an \ + .ok_or(Error::Query(QuerySyntaxError::Unsupported( + "executed-transition proofs for an indexOnly type need an \ $ownerId-bearing, non-skipIfAbsent index that does not involve $createdAt" - .to_string(), - ), - )) + .to_string(), + ))) } /// The grove path and member key of the entry a transition's values @@ -1050,11 +1037,9 @@ pub fn index_only_entry_path_and_key_from_values( .get_optional_at_path(property_name) .ok() .flatten() - .ok_or(Error::Query( - crate::error::query::QuerySyntaxError::Unsupported( - "the transition's values do not cover the index's properties".to_string(), - ), - ))?; + .ok_or(Error::Query(QuerySyntaxError::Unsupported( + "the transition's values do not cover the index's properties".to_string(), + )))?; document_type .serialize_value_for_key(property_name, value, platform_version) .map_err(|e| Error::Protocol(Box::new(e))) @@ -1073,7 +1058,7 @@ pub fn index_only_entry_path_and_key_from_values( } let mut path: Vec> = Vec::with_capacity(5 + index.properties.len() * 2); - path.push(vec![crate::drive::RootTree::DataContractDocuments as u8]); + path.push(vec![RootTree::DataContractDocuments as u8]); path.push(contract_id.to_vec()); path.push(vec![1]); path.push(document_type.name().as_bytes().to_vec()); @@ -1309,13 +1294,11 @@ pub fn synthesize_index_only_document( } } - let owner_id = owner_id.ok_or(Error::Query( - crate::error::query::QuerySyntaxError::Unsupported( - "documents cannot be synthesized from an index that carries no $ownerId; \ + let owner_id = owner_id.ok_or(Error::Query(QuerySyntaxError::Unsupported( + "documents cannot be synthesized from an index that carries no $ownerId; \ query through an owner-bearing index" - .to_string(), - ), - ))?; + .to_string(), + )))?; // Deterministic content-scoped id (see module docs). Every // variable-length component is length-framed (`u32_be(len) ‖ bytes`) From b60bcdb2303d902ce7019b47c3f8e30b5fe5f790 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 00:05:57 +0700 Subject: [PATCH 040/113] docs(platform): add Parameters and Returns sections to 4.1 and 4.2 dispatchers (#5063) Co-authored-by: Claude Opus 5.5 --- .../process_validation_result/mod.rs | 21 +++++++ .../record_added_balance_outputs/mod.rs | 14 +++++ .../has_pending_withdrawal_work/mod.rs | 11 ++++ .../mod.rs | 14 +++++ .../mod.rs | 14 +++++ .../data_contract_reference_validation/mod.rs | 16 ++++++ .../add_contract_document_removal/mod.rs | 19 +++++++ .../moderation/estimated_costs/mod.rs | 57 +++++++++++++++++++ .../fetch_contract_document_removals/mod.rs | 31 ++++++++++ .../mod.rs | 16 ++++++ .../prove_contract_document_removals/mod.rs | 13 +++++ .../for_insert_contract_group/mod.rs | 11 ++++ .../mod.rs | 13 +++++ .../fetch/fetch_contract_group_info/mod.rs | 41 +++++++++++++ .../fetch/fetch_contract_group_members/mod.rs | 16 ++++++ .../mod.rs | 41 +++++++++++++ .../insert/insert_contract_group/mod.rs | 30 ++++++++++ .../insert_contract_group_memberships/mod.rs | 32 +++++++++++ .../prove/prove_contract_group_info/mod.rs | 11 ++++ .../prove/prove_contract_group_members/mod.rs | 15 +++++ .../mod.rs | 12 ++++ .../mod.rs | 55 ++++++++++++++++++ .../mod.rs | 15 +++++ .../insert/add_history_operations/mod.rs | 20 +++++++ .../fetch_charter_election_windows/mod.rs | 19 +++++++ .../query/query_chained_documents/mod.rs | 26 +++++++++ .../query/query_composite_documents/mod.rs | 26 +++++++++ .../mod.rs | 13 +++++ .../withdrawals/record_credit_inflow/mod.rs | 13 +++++ .../record_total_credits_history/mod.rs | 14 +++++ .../delete_platform_state_entry/mod.rs | 12 ++++ .../fetch_platform_state_entries_bytes/mod.rs | 12 ++++ .../fetch_platform_state_recent_bytes/mod.rs | 10 ++++ .../store_platform_state_entry_bytes/mod.rs | 13 +++++ .../store_platform_state_recent_bytes/mod.rs | 11 ++++ .../add_once_per_identity_distribution/mod.rs | 15 +++++ .../mod.rs | 29 ++++++++++ .../mod.rs | 14 +++++ .../mod.rs | 12 ++++ .../mode_detection/mod.rs | 17 ++++++ .../mode_detection/mod.rs | 17 ++++++ .../multiple_in_path_query/mod.rs | 15 +++++ .../single_in_path_query/mod.rs | 14 +++++ .../src/query/where_clause_grouping/mod.rs | 13 +++++ .../verify_chained_documents_proof/mod.rs | 13 +++++ .../verify_composite_documents_proof/mod.rs | 14 +++++ .../verify_contract_group_info/mod.rs | 13 +++++ .../verify_contract_group_members/mod.rs | 15 +++++ .../mod.rs | 14 +++++ .../verify_contract_document_removals/mod.rs | 16 ++++++ .../verify_having_range_proof/mod.rs | 9 +++ .../verify_ranked_top_k_proof/mod.rs | 9 +++ 52 files changed, 956 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/mod.rs index 7754f974082..f1cb18611ed 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_validation_result/mod.rs @@ -28,6 +28,27 @@ where /// across the bump — only this helper's behavior is — so only this helper is versioned; the loop /// calls this dispatcher exactly like `execute_event_v0` calls the dispatching /// `record_added_balance_outputs`. + /// + /// # Parameters + /// + /// * `raw_state_transition`: The raw transition bytes, used to hash it for logs and to tag + /// errors. + /// * `state_transition_name`: The transition's name, used in logs and errors. + /// * `validation_result`: The validation outcome: the execution event (if any) and the + /// consensus errors. + /// * `block_info`: The block being executed. + /// * `transaction`: The GroveDB transaction. + /// * `block_credit_mints`: Accumulates the credits the applied operations mint into Platform. + /// * `platform_version`: The platform version. + /// * `previous_fee_versions`: The fee versions of earlier epochs, used to price the fees. + /// + /// # Returns + /// + /// * `Ok(StateTransitionExecutionResult)`: `SuccessfulExecution`, `PaidConsensusError`, + /// `UnpaidConsensusError` (no event to charge, a free invalid event, or an unpaid event), or + /// `InternalError` when an invalid transition could not pay for its processing. + /// * `Err(StateTransitionAwareError)` when the method version is unknown or executing the event + /// fails, tagged with the raw transition and its name. #[allow(clippy::too_many_arguments)] pub(in crate::execution) fn process_validation_result<'a>( &self, diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/record_added_balance_outputs/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/record_added_balance_outputs/mod.rs index 7d092f5200d..e0ff609c02c 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/record_added_balance_outputs/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/record_added_balance_outputs/mod.rs @@ -44,6 +44,20 @@ where /// OWN version field (`process_raw_state_transitions`), not this one — but both bump to v1 /// together in the v13 method set, so the expansion activates atomically. Neither site carries a /// version conditional inside a _v0 function; the version is chosen by dispatch. + /// + /// # Parameters + /// + /// * `address_balances_in_update`: The block's address-balance update map the credits are + /// merged into, or `None` when the caller does not track them (then nothing is recorded). + /// * `added_to_balance_outputs`: The credits the event added to each address, if any. + /// * `origin`: Which event family produced the credits; v0 drops `ShieldedSpend` credits. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(())` once the credits the version records are merged into the map (an existing + /// `SetCredits` or `AddToCredits` entry grows by the amount, saturating). + /// * `Err(Error)` when the method version is unknown. pub(in crate::execution) fn record_added_balance_outputs( &self, address_balances_in_update: Option<&mut BTreeMap>, diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/has_pending_withdrawal_work/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/has_pending_withdrawal_work/mod.rs index f3b0e269d3b..ceb74df00a9 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/has_pending_withdrawal_work/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/has_pending_withdrawal_work/mod.rs @@ -18,6 +18,17 @@ where /// Tenderdash proposes the next height without waiting for transactions or the empty-block /// interval. Not consensus: a read only, it never touches the state or the app hash, and the /// hint is local to the node that returns it. + /// + /// # Parameters + /// + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(true)` when the untied withdrawal transactions queue holds at least one transaction, + /// `Ok(false)` when it is empty. + /// * `Err(Error)` when the method version is unknown or the queue read fails. pub fn has_pending_withdrawal_work( &self, transaction: TransactionArg, diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs index 6a699fc146b..8e27a077d9b 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_credit_inflows_for_withdrawals/mod.rs @@ -21,6 +21,20 @@ where /// /// Runs as a system event once per block, so nobody pays fees for the write; a block that /// minted nothing writes nothing. + /// + /// # Parameters + /// + /// * `credit_mints`: The credits the block minted into Platform. + /// * `block_info`: The block being executed; its time sets when the inflow expires. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(())` once the inflow is recorded, or at once when `credit_mints` is zero or the + /// protocol version has no credit inflows (the method version is `None`). + /// * `Err(Error)` when the method version (or the Drive method it calls) is unknown or not + /// active, or the write fails. pub(in crate::execution) fn record_credit_inflows_for_withdrawals( &self, credit_mints: Credits, diff --git a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_total_credits_history_for_withdrawals/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_total_credits_history_for_withdrawals/mod.rs index c9b01244f19..ca885853ebe 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_total_credits_history_for_withdrawals/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/withdrawals/record_total_credits_history_for_withdrawals/mod.rs @@ -21,6 +21,20 @@ where /// move the total in a block) and before the app hash is taken; blocks that leave the total /// untouched cost one read and no write. Until an entry is a day old the limit applies its /// bootstrap rule, so pooling never depends on this block's entry. + /// + /// # Parameters + /// + /// * `block_info`: The block being executed; its time keys the new entry. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version; its withdrawal constants bound the prune. + /// + /// # Returns + /// + /// * `Ok(())` once the history holds this block's total (unchanged when the total equals the + /// latest entry), or at once when the protocol version has no history (the method version + /// is `None`). + /// * `Err(Error)` when the method version (or the Drive method it calls) is unknown or not + /// active, the total credits are missing from state, or a read or write fails. pub(in crate::execution) fn record_total_credits_history_for_withdrawals( &self, block_info: &BlockInfo, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/mod.rs index f129339b307..83a0ee89032 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/mod.rs @@ -19,6 +19,22 @@ use crate::execution::types::state_transition_execution_context::StateTransition /// named) must contain the referenced document type, and that type must forbid /// deletion. Identity, contract and token targets declare nothing beyond their /// kind, so they have nothing to validate here. +/// +/// # Parameters +/// +/// * `contract`: The contract being created or updated, whose declarations are checked. +/// * `drive`: The Drive that foreign referenced contracts are fetched from. +/// * `block_info`: The block being executed; its epoch prices the contract fetches. +/// * `execution_context`: The execution context the contract fetch fees are billed to. +/// * `transaction`: The GroveDB transaction. +/// * `platform_version`: The platform version. +/// +/// # Returns +/// +/// * `Ok(SimpleConsensusValidationResult)`: valid when every declaration holds, otherwise +/// carrying the consensus error of the first invalid declaration. +/// * `Err(Error)` when the method version is unknown, a contract fetch fails or returns no +/// fee, or checking whether a document type records creator ids fails. pub(in crate::execution::validation::state_transition) fn validate_data_contract_references( contract: &DataContract, drive: &Drive, diff --git a/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/mod.rs b/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/mod.rs index d2a6add7f7f..7cbc287c9e4 100644 --- a/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/add_contract_document_removal/mod.rs @@ -18,6 +18,25 @@ impl Drive { /// `replaces_existing` the replacement of the one the document has: a restore marks the /// record restored, and the deletion of a restored document writes a fresh record in its /// place. `moderator_id` pays for the record, or for the bytes a replacement adds. + /// + /// # Parameters + /// + /// * `contract_id`: The contract the document belonged to. + /// * `document_type_name`: The document's type. + /// * `document_id`: The deleted document's id, which keys the record. + /// * `removal`: The record: owner, moderator, time, document hash, restoration and reason. + /// * `replaces_existing`: Whether the document already has a record this one replaces. + /// * `moderator_id`: The moderator that pays for the record, named in its storage flags. + /// * `block_info`: The block being executed; its epoch goes in the storage flags. + /// * `estimated_costs_only_with_layer_info`: The estimation map, when only estimating costs. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the insert (or, outside estimation, the + /// replace) of the record. + /// * `Err(Error)` when the method version is unknown or building an operation fails. #[allow(clippy::too_many_arguments)] pub fn add_contract_document_removal_operations( &self, diff --git a/packages/rs-drive/src/drive/contract/moderation/estimated_costs/mod.rs b/packages/rs-drive/src/drive/contract/moderation/estimated_costs/mod.rs index 4e8037975a2..3d37904fdd6 100644 --- a/packages/rs-drive/src/drive/contract/moderation/estimated_costs/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/estimated_costs/mod.rs @@ -12,6 +12,17 @@ use std::collections::HashMap; impl Drive { /// Adds the estimated layer information for creating a contract's moderation list trees: /// the levels up to the contract and the contract's own subtree. + /// + /// # Parameters + /// + /// * `contract_id`: The contract whose moderation trees are created. + /// * `estimated_costs_only_with_layer_info`: The estimation map the layers are added to. + /// * `drive_version`: The drive version. + /// + /// # Returns + /// + /// * `Ok(())` once the layers are added to the map. + /// * `Err(Error)` when the method version, or that of a nested estimation, is unknown. pub(crate) fn add_estimation_costs_for_contract_moderation_trees( contract_id: [u8; 32], estimated_costs_only_with_layer_info: &mut HashMap, @@ -38,6 +49,18 @@ impl Drive { /// Adds the estimated layer information for writing one entry of a contract's moderation /// list: the levels up to the contract, the contract's subtree and the list's tree. + /// + /// # Parameters + /// + /// * `contract_id`: The contract the list belongs to. + /// * `list`: The moderation list the entry is written to. + /// * `estimated_costs_only_with_layer_info`: The estimation map the layers are added to. + /// * `drive_version`: The drive version. + /// + /// # Returns + /// + /// * `Ok(())` once the layers are added to the map. + /// * `Err(Error)` when the method version, or that of a nested estimation, is unknown. pub(crate) fn add_estimation_costs_for_contract_moderation_entry( contract_id: [u8; 32], list: ContractModerationList, @@ -67,6 +90,17 @@ impl Drive { /// Adds the estimated layer information for writing or deleting moderation action counts /// of an elected contract: the levels up to the contract, the contract's subtree and the /// tree of the counts. + /// + /// # Parameters + /// + /// * `contract_id`: The elected contract the counts belong to. + /// * `estimated_costs_only_with_layer_info`: The estimation map the layers are added to. + /// * `drive_version`: The drive version. + /// + /// # Returns + /// + /// * `Ok(())` once the layers are added to the map. + /// * `Err(Error)` when the method version, or that of a nested estimation, is unknown. pub(crate) fn add_estimation_costs_for_contract_moderation_action_counts( contract_id: [u8; 32], estimated_costs_only_with_layer_info: &mut HashMap, @@ -93,6 +127,17 @@ impl Drive { /// Adds the layers a contract insertion or update touches when it creates the trees of /// the document removal records. + /// + /// # Parameters + /// + /// * `contract_id`: The contract whose removal trees are created. + /// * `estimated_costs_only_with_layer_info`: The estimation map the layers are added to. + /// * `drive_version`: The drive version. + /// + /// # Returns + /// + /// * `Ok(())` once the layers are added to the map. + /// * `Err(Error)` when the method version, or that of a nested estimation, is unknown. pub(crate) fn add_estimation_costs_for_contract_document_removal_trees( contract_id: [u8; 32], estimated_costs_only_with_layer_info: &mut HashMap, @@ -118,6 +163,18 @@ impl Drive { } /// Adds the layers the record of a moderator's document deletion is written through. + /// + /// # Parameters + /// + /// * `contract_id`: The contract the document belonged to. + /// * `document_type_name`: The document's type, whose removal tree holds the record. + /// * `estimated_costs_only_with_layer_info`: The estimation map the layers are added to. + /// * `drive_version`: The drive version. + /// + /// # Returns + /// + /// * `Ok(())` once the layers are added to the map. + /// * `Err(Error)` when the method version, or that of a nested estimation, is unknown. pub(crate) fn add_estimation_costs_for_contract_document_removal( contract_id: [u8; 32], document_type_name: &str, diff --git a/packages/rs-drive/src/drive/contract/moderation/fetch_contract_document_removals/mod.rs b/packages/rs-drive/src/drive/contract/moderation/fetch_contract_document_removals/mod.rs index 63b49e6e674..8be78ea2cda 100644 --- a/packages/rs-drive/src/drive/contract/moderation/fetch_contract_document_removals/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/fetch_contract_document_removals/mod.rs @@ -18,6 +18,21 @@ impl Drive { /// The records of the documents a contract's moderators deleted, within one document type: /// the ones of the ids named, or one page in document id order. A contract or a document /// type that keeps no records reads as none. + /// + /// # Parameters + /// + /// * `contract_id`: The contract whose moderators deleted the documents. + /// * `query`: The document type and the selection: document ids, or a page. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the records found, in document id order; + /// empty when the contract or the document type keeps no records. + /// * `Err(Error)` when the method version is unknown, the query names no ids, too many or a + /// repeated id, or has a limit of zero or above the maximum, a read fails, or a stored + /// record is malformed. pub fn fetch_contract_document_removals( &self, contract_id: Identifier, @@ -50,6 +65,22 @@ impl Drive { /// consensus validation can bill it. The document type must be one whose documents /// moderators may delete: its records tree exists since the type was created, so a /// missing record reads as `None` and nothing else does. + /// + /// # Parameters + /// + /// * `contract_id`: The contract the document belonged to. + /// * `document_type_name`: The document's type. + /// * `document_id`: The document's id. + /// * `epoch`: The epoch the read is priced in. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok((FeeResult, Option))`: the fee of the read and the + /// record, `None` when the document has none. + /// * `Err(Error)` when the method version is unknown, the read fails, the record is + /// malformed, or the fee cannot be calculated. pub fn fetch_contract_document_removal_with_fee( &self, contract_id: Identifier, diff --git a/packages/rs-drive/src/drive/contract/moderation/insert_contract_document_removal_trees/mod.rs b/packages/rs-drive/src/drive/contract/moderation/insert_contract_document_removal_trees/mod.rs index f25d9cf9b13..aa0ba4b99ff 100644 --- a/packages/rs-drive/src/drive/contract/moderation/insert_contract_document_removal_trees/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/insert_contract_document_removal_trees/mod.rs @@ -22,6 +22,22 @@ impl Drive { /// root exists is read off the stored contract, and a type's tree is created exactly /// once, with the type. A contract without such a document type has no root, so its other /// tree keeps the shape it would have had. No tree is made lazily by the first removal. + /// + /// # Parameters + /// + /// * `contract_id`: The contract the trees belong to. + /// * `with_root`: Whether to also create the tree of all the records. + /// * `document_type_names`: The document types to create a records tree for. + /// * `storage_flags`: The storage flags of the new trees. + /// * `estimated_costs_only_with_layer_info`: The estimation map, when only estimating costs. + /// * `transaction`: The GroveDB transaction. + /// * `batch_operations`: The operations accumulator the tree inserts are appended to. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(())` once the tree inserts are appended to `batch_operations`. + /// * `Err(Error)` when the method version is unknown or building an insert fails. #[allow(clippy::too_many_arguments)] pub fn insert_contract_document_removal_trees_operations( &self, diff --git a/packages/rs-drive/src/drive/contract/moderation/prove_contract_document_removals/mod.rs b/packages/rs-drive/src/drive/contract/moderation/prove_contract_document_removals/mod.rs index d07199d061c..d53be2676ed 100644 --- a/packages/rs-drive/src/drive/contract/moderation/prove_contract_document_removals/mod.rs +++ b/packages/rs-drive/src/drive/contract/moderation/prove_contract_document_removals/mod.rs @@ -13,6 +13,19 @@ impl Drive { /// document type: the ones of the ids named (an id with no record is proved absent), or /// one page in document id order. The document type must be one whose removal tree /// exists; the caller checks that against the contract. + /// + /// # Parameters + /// + /// * `contract_id`: The contract whose moderators deleted the documents. + /// * `query`: The document type and the selection: document ids, or a page. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the GroveDB proof of the selected records. + /// * `Err(Error)` when the method version is unknown, the query names no ids, too many or a + /// repeated id, or has a limit of zero or above the maximum, or proving fails. pub fn prove_contract_document_removals( &self, contract_id: Identifier, diff --git a/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group/mod.rs b/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group/mod.rs index e212f32eb60..7aecbfd356e 100644 --- a/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group/mod.rs @@ -11,6 +11,17 @@ use std::collections::HashMap; impl Drive { /// Adds the estimated layer information for registering a contract group: the root tree, /// the `ContractGroups` tree, its `Groups` subtree and the new group's own tree. + /// + /// # Parameters + /// + /// * `contract_group_id`: The id of the group being registered. + /// * `estimated_costs_only_with_layer_info`: The estimation map the layers are added to. + /// * `drive_version`: The drive version. + /// + /// # Returns + /// + /// * `Ok(())` once the layers are added to the map. + /// * `Err(Error)` when the method version is unknown. pub(crate) fn add_estimation_costs_for_insert_contract_group( contract_group_id: [u8; 32], estimated_costs_only_with_layer_info: &mut HashMap, diff --git a/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group_memberships/mod.rs b/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group_memberships/mod.rs index 5a5eb509c4c..96f7d4ccb94 100644 --- a/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group_memberships/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/estimated_costs/for_insert_contract_group_memberships/mod.rs @@ -12,6 +12,19 @@ use std::collections::HashMap; impl Drive { /// Adds the estimated layer information for the forward and backwards entries of a new /// contract's contract group memberships. + /// + /// # Parameters + /// + /// * `contract_id`: The new contract whose memberships are written. + /// * `memberships`: The memberships, each naming a group and the part of the contract that + /// joins it. + /// * `estimated_costs_only_with_layer_info`: The estimation map the layers are added to. + /// * `drive_version`: The drive version. + /// + /// # Returns + /// + /// * `Ok(())` once the layers are added to the map. + /// * `Err(Error)` when the method version is unknown. pub(crate) fn add_estimation_costs_for_insert_contract_group_memberships( contract_id: [u8; 32], memberships: &[ContractGroupMembership], diff --git a/packages/rs-drive/src/drive/contract_groups/fetch/fetch_contract_group_info/mod.rs b/packages/rs-drive/src/drive/contract_groups/fetch/fetch_contract_group_info/mod.rs index 0265b480121..46f3d1a8b11 100644 --- a/packages/rs-drive/src/drive/contract_groups/fetch/fetch_contract_group_info/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/fetch/fetch_contract_group_info/mod.rs @@ -13,6 +13,19 @@ use platform_version::version::PlatformVersion; impl Drive { /// Fetches the stored information of a contract group, or `None` when no such group exists. + /// + /// # Parameters + /// + /// * `contract_group_id`: The group's id. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Some(ContractGroupInfo))` with the group's information, `Ok(None)` when no such + /// group exists. + /// * `Err(Error)` when the method version is unknown, the read fails, or the stored + /// information does not deserialize. pub fn fetch_contract_group_info( &self, contract_group_id: Identifier, @@ -39,6 +52,20 @@ impl Drive { /// Fetches the stored information of a contract group and the fee of the lookup, so that /// consensus validation can bill it. + /// + /// # Parameters + /// + /// * `contract_group_id`: The group's id. + /// * `epoch`: The epoch the read is priced in. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok((FeeResult, Option))`: the fee of the read and the group's + /// information, `None` when no such group exists. + /// * `Err(Error)` when the method version is unknown, the read fails, the stored + /// information does not deserialize, or the fee cannot be calculated. pub fn fetch_contract_group_info_with_fee( &self, contract_group_id: Identifier, @@ -69,6 +96,20 @@ impl Drive { /// Fetches the stored information of a contract group, recording the read in /// `drive_operations`. + /// + /// # Parameters + /// + /// * `contract_group_id`: The group's id. + /// * `transaction`: The GroveDB transaction. + /// * `drive_operations`: The operations accumulator the read is appended to, for billing. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Some(ContractGroupInfo))` with the group's information, `Ok(None)` when no such + /// group exists. + /// * `Err(Error)` when the method version is unknown, the read fails, or the stored + /// information does not deserialize. pub fn fetch_contract_group_info_add_to_operations( &self, contract_group_id: Identifier, diff --git a/packages/rs-drive/src/drive/contract_groups/fetch/fetch_contract_group_members/mod.rs b/packages/rs-drive/src/drive/contract_groups/fetch/fetch_contract_group_members/mod.rs index 12922d1c7e3..f255526caa5 100644 --- a/packages/rs-drive/src/drive/contract_groups/fetch/fetch_contract_group_members/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/fetch/fetch_contract_group_members/mod.rs @@ -14,6 +14,22 @@ impl Drive { /// use [`Drive::fetch_contract_group_info`] to tell an absent group from an empty one. /// /// `limit` must be between 1 and the configured maximum query limit. + /// + /// # Parameters + /// + /// * `contract_group_id`: The group's id. + /// * `query`: The kind of member (contracts, document types or tokens) and the cursor to + /// continue after. + /// * `limit`: The most entries the page holds. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(ContractGroupMembersPage)` with the page of the queried kind; empty when the group + /// is absent or has no more members of that kind. + /// * `Err(Error)` when the method version is unknown, `limit` is out of range, a read + /// fails, or a stored member is malformed. pub fn fetch_contract_group_members( &self, contract_group_id: Identifier, diff --git a/packages/rs-drive/src/drive/contract_groups/fetch/fetch_contract_group_memberships_for_contract/mod.rs b/packages/rs-drive/src/drive/contract_groups/fetch/fetch_contract_group_memberships_for_contract/mod.rs index cf67076b4fb..ed78bd8acf1 100644 --- a/packages/rs-drive/src/drive/contract_groups/fetch/fetch_contract_group_memberships_for_contract/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/fetch/fetch_contract_group_memberships_for_contract/mod.rs @@ -14,6 +14,19 @@ use platform_version::version::PlatformVersion; impl Drive { /// Fetches the contract groups a contract belongs to, as a whole, through its document /// types and through its tokens. Empty when the contract belongs to no group. + /// + /// # Parameters + /// + /// * `contract_id`: The contract's id. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(ContractGroupMembershipsForContract)` with the groups of the whole contract, of + /// each document type and of each token; empty when it belongs to no group. + /// * `Err(Error)` when the method version is unknown, a read fails, or a stored membership + /// is malformed. pub fn fetch_contract_group_memberships_for_contract( &self, contract_id: Identifier, @@ -42,6 +55,20 @@ impl Drive { /// Fetches the contract groups a contract belongs to and the fee of the lookup, so that /// consensus validation can bill it. + /// + /// # Parameters + /// + /// * `contract_id`: The contract's id. + /// * `epoch`: The epoch the reads are priced in. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok((FeeResult, ContractGroupMembershipsForContract))`: the fee of the reads and the + /// contract's memberships, empty when it belongs to no group. + /// * `Err(Error)` when the method version is unknown, a read fails, a stored membership is + /// malformed, or the fee cannot be calculated. pub fn fetch_contract_group_memberships_for_contract_with_fee( &self, contract_id: Identifier, @@ -72,6 +99,20 @@ impl Drive { /// Fetches the contract groups a contract belongs to, recording the reads in /// `drive_operations`. + /// + /// # Parameters + /// + /// * `contract_id`: The contract's id. + /// * `transaction`: The GroveDB transaction. + /// * `drive_operations`: The operations accumulator the reads are appended to, for billing. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(ContractGroupMembershipsForContract)` with the groups of the whole contract, of + /// each document type and of each token; empty when it belongs to no group. + /// * `Err(Error)` when the method version is unknown, a read fails, or a stored membership + /// is malformed. pub fn fetch_contract_group_memberships_for_contract_add_to_operations( &self, contract_id: Identifier, diff --git a/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group/mod.rs b/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group/mod.rs index 134460ddf67..e44bca8785d 100644 --- a/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group/mod.rs @@ -18,6 +18,21 @@ impl Drive { /// member subtrees. Applies the operations when `apply` is true, otherwise only estimates. /// /// The caller must have checked that no group with this id exists. + /// + /// # Parameters + /// + /// * `contract_group_id`: The new group's id. + /// * `info`: The group's information, stored as its info item. + /// * `block_info`: The block being executed; its epoch prices the fee. + /// * `apply`: Whether to apply the operations or only estimate their cost. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(FeeResult)` with the fee of the operations, applied or estimated. + /// * `Err(Error)` when the method version is unknown, the group already exists, the info + /// does not serialize, applying the batch fails, or the fee cannot be calculated. pub fn insert_contract_group( &self, contract_group_id: Identifier, @@ -52,6 +67,21 @@ impl Drive { /// The low level operations registering a contract group. With layer information the /// operations are built for estimation only. + /// + /// # Parameters + /// + /// * `contract_group_id`: The new group's id. + /// * `info`: The group's information, stored as its info item. + /// * `estimated_costs_only_with_layer_info`: The estimation map, when only estimating costs. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the inserts of the group's tree, its info item + /// and its three member subtrees. + /// * `Err(Error)` when the method version is unknown, the group already exists, the info + /// does not serialize, or building an operation fails. pub fn insert_contract_group_operations( &self, contract_group_id: Identifier, diff --git a/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group_memberships/mod.rs b/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group_memberships/mod.rs index cc4f5683c6d..45cf333fbcd 100644 --- a/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group_memberships/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/insert/insert_contract_group_memberships/mod.rs @@ -20,6 +20,23 @@ impl Drive { /// The contract must be new to the state: every tree keyed by the contract id is created /// here, and the groups (registered earlier or in the same batch) must exist. Consensus /// validation guarantees both. + /// + /// # Parameters + /// + /// * `contract_id`: The new contract's id. + /// * `memberships`: The memberships, each naming a group and the part of the contract that + /// joins it. + /// * `block_info`: The block being executed; its epoch prices the fee. + /// * `apply`: Whether to apply the operations or only estimate their cost. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(FeeResult)` with the fee of the operations, applied or estimated (zero operations + /// when `memberships` is empty). + /// * `Err(Error)` when the method version is unknown, building an operation or applying the + /// batch fails, or the fee cannot be calculated. pub fn insert_contract_group_memberships( &self, contract_id: Identifier, @@ -54,6 +71,21 @@ impl Drive { /// The low level operations recording a new contract's contract group memberships. With /// layer information the operations are built for estimation only. + /// + /// # Parameters + /// + /// * `contract_id`: The new contract's id. + /// * `memberships`: The memberships, each naming a group and the part of the contract that + /// joins it. + /// * `estimated_costs_only_with_layer_info`: The estimation map, when only estimating costs. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the forward entries, the backwards references + /// and the trees they need; empty when `memberships` is empty. + /// * `Err(Error)` when the method version is unknown or building an operation fails. pub fn insert_contract_group_memberships_operations( &self, contract_id: Identifier, diff --git a/packages/rs-drive/src/drive/contract_groups/prove/prove_contract_group_info/mod.rs b/packages/rs-drive/src/drive/contract_groups/prove/prove_contract_group_info/mod.rs index c1dde197f1d..d9dd3a08cda 100644 --- a/packages/rs-drive/src/drive/contract_groups/prove/prove_contract_group_info/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/prove/prove_contract_group_info/mod.rs @@ -9,6 +9,17 @@ use platform_version::version::PlatformVersion; impl Drive { /// Proves a contract group's stored information (owner, name, description), or its absence. + /// + /// # Parameters + /// + /// * `contract_group_id`: The group's id. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the GroveDB proof of the group's info item or of its absence. + /// * `Err(Error)` when the method version is unknown or proving fails. pub fn prove_contract_group_info( &self, contract_group_id: Identifier, diff --git a/packages/rs-drive/src/drive/contract_groups/prove/prove_contract_group_members/mod.rs b/packages/rs-drive/src/drive/contract_groups/prove/prove_contract_group_members/mod.rs index b793811a38d..b0c0649d348 100644 --- a/packages/rs-drive/src/drive/contract_groups/prove/prove_contract_group_members/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/prove/prove_contract_group_members/mod.rs @@ -14,6 +14,21 @@ impl Drive { /// /// `limit` must be between 1 and the configured maximum query limit, so the proof cannot /// grow with the size of the group. + /// + /// # Parameters + /// + /// * `contract_group_id`: The group's id. + /// * `query`: The kind of member (contracts, document types or tokens) and the cursor to + /// continue after. + /// * `limit`: The most entries the page holds. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the GroveDB proof of the page. + /// * `Err(Error)` when the method version is unknown, `limit` is out of range, or proving + /// fails. pub fn prove_contract_group_members( &self, contract_group_id: Identifier, diff --git a/packages/rs-drive/src/drive/contract_groups/prove/prove_contract_group_memberships_for_contract/mod.rs b/packages/rs-drive/src/drive/contract_groups/prove/prove_contract_group_memberships_for_contract/mod.rs index 1dce412a052..7ebfc342889 100644 --- a/packages/rs-drive/src/drive/contract_groups/prove/prove_contract_group_memberships_for_contract/mod.rs +++ b/packages/rs-drive/src/drive/contract_groups/prove/prove_contract_group_memberships_for_contract/mod.rs @@ -9,6 +9,18 @@ use platform_version::version::PlatformVersion; impl Drive { /// Proves the contract groups a contract belongs to, or that it belongs to none. + /// + /// # Parameters + /// + /// * `contract_id`: The contract's id. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the GroveDB proof of the contract's memberships, or of their + /// absence. + /// * `Err(Error)` when the method version is unknown or proving fails. pub fn prove_contract_group_memberships_for_contract( &self, contract_id: Identifier, diff --git a/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/mod.rs index 7e9615d6060..5f7098a5317 100644 --- a/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/delete_index_only_document_for_contract_operations/mod.rs @@ -27,6 +27,19 @@ impl Drive { /// surviving entries must match the row commitment. Paths already /// drained from expired TTL buckets are skipped in validation and apply. /// + /// # Parameters + /// + /// * `document`: The document, reconstructed from the transition's values and owner. + /// * `contract`: The contract of the document. + /// * `document_type`: The document's type, which must be indexOnly and deletable. + /// * `previous_batch_operations`: The operations already in the batch, read before the + /// stored state. + /// * `estimated_costs_only_with_layer_info`: The estimation map, when only estimating costs. + /// * `block_time_ms`: The block time; outside estimation, the TTL buckets expired by then + /// are drained first, and entries already drained are skipped. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// /// # Returns /// * `Ok(Vec)` if the operation was successful. /// * `Err(DriveError::UnknownVersionMismatch)` if the drive version does not match known versions. @@ -67,6 +80,27 @@ impl Drive { /// Build against post-drain state. The caller must prepare the whole /// batch before invoking this method; no cleanup occurs during conversion. + /// + /// # Parameters + /// + /// * `document`: The document, reconstructed from the transition's values and owner. + /// * `contract`: The contract of the document. + /// * `document_type`: The document's type, which must be indexOnly and deletable. + /// * `previous_batch_operations`: The operations already in the batch, read before the + /// stored state. + /// * `estimated_costs_only_with_layer_info`: The estimation map, when only estimating costs. + /// * `block_time_ms`: The block time; entries in TTL buckets expired and drained by then are + /// skipped. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the row-commitment probe reads and the removal + /// of every index entry of the document. + /// * `Err(Error)` when the method version is unknown, the document type is not indexOnly or + /// its documents cannot be deleted, an index entry is missing or carries another + /// document's row commitment, or a read or an operation fails. #[allow(clippy::too_many_arguments)] pub(crate) fn delete_index_only_document_for_contract_operations_without_ttl_drain( &self, @@ -113,6 +147,27 @@ impl Drive { /// `previous_fee_versions` carries the historical fee-version context /// deletion refunds are priced against, exactly as on /// `delete_document_for_contract`. + /// + /// # Parameters + /// + /// * `document`: The document, reconstructed from the transition's values and owner. + /// * `contract`: The contract of the document. + /// * `document_type`: The document's type, which must be indexOnly and deletable. + /// * `block_info`: The block being executed; its time drives the TTL drain and its epoch + /// prices the fee. + /// * `apply`: Whether to apply the operations or only estimate their cost. + /// * `transaction`: The GroveDB transaction; without one, an applied deletion runs in a + /// transaction of its own that is committed at the end. + /// * `platform_version`: The platform version. + /// * `previous_fee_versions`: The fee versions of earlier epochs the refunds are priced + /// against. + /// + /// # Returns + /// + /// * `Ok(FeeResult)` with the fee of the deletion (applied or estimated) and its refunds. + /// * `Err(Error)` when the method version is unknown, the document type is not indexOnly or + /// its documents cannot be deleted, no document with exactly these values exists for the + /// owner, or a read, the batch apply, the commit or the fee calculation fails. #[allow(clippy::too_many_arguments)] pub fn delete_index_only_document_for_contract( &self, diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/validate_restored_document_uniqueness/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/validate_restored_document_uniqueness/mod.rs index b123f420620..8326e5bb5d0 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/validate_restored_document_uniqueness/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/validate_restored_document_uniqueness/mod.rs @@ -16,6 +16,21 @@ impl Drive { /// back as it was, timestamps and heights included, so they are read off the document /// rather than off the block, and its own id is not allowed as the original: the /// document is absent while its removal record stands unrestored. + /// + /// # Parameters + /// + /// * `contract`: The contract of the document. + /// * `document_type`: The document's type, whose unique indexes are checked. + /// * `document`: The document being restored, with its original properties and timestamps. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(SimpleConsensusValidationResult)`: valid when no other document holds a value of + /// one of the document's unique indexes, otherwise carrying the duplicate unique index + /// error. + /// * `Err(Error)` when the method version is unknown or a unique index query fails. pub fn validate_restored_document_uniqueness( &self, contract: &DataContract, diff --git a/packages/rs-drive/src/drive/document/insert/add_history_operations/mod.rs b/packages/rs-drive/src/drive/document/insert/add_history_operations/mod.rs index a7ac0b0932f..8036981b7fc 100644 --- a/packages/rs-drive/src/drive/document/insert/add_history_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_history_operations/mod.rs @@ -18,6 +18,26 @@ impl Drive { /// document types that subscribed to history via the /// `keepsTransferHistory`, `keepsPurchaseHistory` and /// `keepsPricingHistory` configuration flags. + /// + /// # Parameters + /// + /// * `source_data_contract_id`: The contract of the document the event happened to. + /// * `source_document_type_name`: The type of that document. + /// * `source_document_id`: That document's id. + /// * `owner_id`: The identity that acted, which owns the history document. + /// * `owner_nonce`: The acting identity's contract nonce, which derives the history + /// document's id. + /// * `event`: The event: a transfer, a purchase or a price update. + /// * `block_info`: The block being executed. + /// * `estimated_costs_only_with_layer_info`: The estimation map, when only estimating costs. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the operations inserting the history document. + /// * `Err(Error)` when the method version is unknown, the document history contract or its + /// document type for the event cannot be loaded, or building the insert fails. #[allow(clippy::too_many_arguments)] pub fn add_document_history_operations( &self, diff --git a/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/mod.rs index c16c0fe37f4..dae3694b5fa 100644 --- a/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/mod.rs +++ b/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/mod.rs @@ -26,6 +26,15 @@ pub struct ContestWindows { impl ContestWindows { /// The generic windows of the version tables, which every contest but a moderation election /// runs on: the mainnet ones on mainnet, the shorter test ones on every other network. + /// + /// # Parameters + /// + /// * `network`: The network; mainnet picks the mainnet windows, any other the test ones. + /// * `platform_version`: The platform version, whose tables hold the windows. + /// + /// # Returns + /// + /// The join window and the poll duration of the version tables for `network`. Infallible. pub fn generic(network: Network, platform_version: &PlatformVersion) -> Self { let validation = &platform_version.dpp.validation.voting; let voting = &platform_version.dpp.voting_versions; @@ -43,6 +52,16 @@ impl ContestWindows { /// The windows an elected moderation declaration gives the elections for its contract. Its /// windows are seconds, each one day to four weeks. + /// + /// # Parameters + /// + /// * `elected`: The elected moderation declaration, with its join and vote windows in + /// seconds. + /// + /// # Returns + /// + /// The join window, and as the poll duration the join window plus the vote window, both in + /// milliseconds (saturating). Infallible. pub fn of_elected_moderators(elected: &ElectedModerators) -> Self { let join_window_ms = u64::from(elected.join_window).saturating_mul(1000); let vote_window_ms = u64::from(elected.vote_window).saturating_mul(1000); diff --git a/packages/rs-drive/src/drive/document/query/query_chained_documents/mod.rs b/packages/rs-drive/src/drive/document/query/query_chained_documents/mod.rs index 68fa1ab3ff3..b2cb9607740 100644 --- a/packages/rs-drive/src/drive/document/query/query_chained_documents/mod.rs +++ b/packages/rs-drive/src/drive/document/query/query_chained_documents/mod.rs @@ -18,6 +18,21 @@ impl Drive { /// [`DriveDocumentQuery::with_by_id_join`]) — without proofs and /// returns the materialized halves plus the processing cost (when an /// epoch is given). + /// + /// # Parameters + /// + /// * `query`: The inner query, carrying the by-id join. + /// * `epoch`: The epoch to price the reads in; `None` leaves the cost at zero. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(QueryChainedDocumentsOutcomeV0)` with the inner projections, the outer documents + /// in first-appearance order, the join values with no outer document, and the processing + /// cost (zero without an epoch). + /// * `Err(Error)` when the method version is unknown, the query is not a valid chained + /// query, a read or a document deserialization fails, or the fee cannot be calculated. pub fn query_chained_documents( &self, query: &DriveDocumentQuery, @@ -53,6 +68,17 @@ impl Drive { /// Returns the merged proof plus the materialized INNER /// projections (join values / hint / cursor derive from them); the /// outer half is covered by the proof and not materialized. + /// + /// # Parameters + /// + /// * `query`: The inner query, carrying the by-id join. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok((Vec, Vec))` with the merged proof and the inner projections. + /// * `Err(Error)` when the method version is unknown, the query is not a valid chained + /// query, a read or proving fails, or every attempt raced a block commit. pub fn query_chained_documents_with_proof( &self, query: &DriveDocumentQuery, diff --git a/packages/rs-drive/src/drive/document/query/query_composite_documents/mod.rs b/packages/rs-drive/src/drive/document/query/query_composite_documents/mod.rs index 2fd22b091b9..0450df011c5 100644 --- a/packages/rs-drive/src/drive/document/query/query_composite_documents/mod.rs +++ b/packages/rs-drive/src/drive/document/query/query_composite_documents/mod.rs @@ -16,6 +16,21 @@ impl Drive { /// page carrying derived [`sub_queries`](DriveDocumentQuery::sub_queries) /// — without proofs and returns the materialized results plus the /// processing cost (when an epoch is given). + /// + /// # Parameters + /// + /// * `query`: The page query, carrying the derived sub-queries. + /// * `epoch`: The epoch to price the reads in; `None` leaves the cost at zero. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(QueryCompositeDocumentsOutcomeV0)` with the page, one result per sub-query, the + /// ids each by-id join found no document for, and the processing cost (zero without an + /// epoch). + /// * `Err(Error)` when the method version is unknown, the query is not a valid composite + /// query, a read or a document deserialization fails, or the fee cannot be calculated. pub fn query_composite_documents( &self, query: &DriveDocumentQuery, @@ -52,6 +67,17 @@ impl Drive { /// Returns the merged proof plus the materialized page (the /// caller's pagination cursor derives from it); the sub-query /// results are covered by the proof and not materialized twice. + /// + /// # Parameters + /// + /// * `query`: The page query, carrying the derived sub-queries. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok((Vec, Vec))` with the merged proof and the page. + /// * `Err(Error)` when the method version is unknown, the query is not a valid composite + /// query, a read or proving fails, or every attempt raced a block commit. pub fn query_composite_documents_with_proof( &self, query: &DriveDocumentQuery, diff --git a/packages/rs-drive/src/drive/identity/withdrawals/fetch_total_credits_in_platform_a_day_ago/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/fetch_total_credits_in_platform_a_day_ago/mod.rs index 2eed23477d1..a60fd39c61d 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/fetch_total_credits_in_platform_a_day_ago/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/fetch_total_credits_in_platform_a_day_ago/mod.rs @@ -28,6 +28,19 @@ impl Drive { /// day (right after it started being recorded); the daily withdrawal limit then falls back /// to its bootstrap rule rather than to a younger total, so the lag cannot be skipped by /// inflating the total before or at activation. + /// + /// # Parameters + /// + /// * `time_ms`: The time to look a day back from, usually the block time. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Some(RecordedTotalCredits))` with the entry's block time and total, `Ok(None)` + /// when no entry is at least a day old. + /// * `Err(Error)` when the method version is unknown or not active, the read fails, or the + /// entry found is malformed. pub fn fetch_total_credits_in_platform_a_day_ago( &self, time_ms: TimestampMillis, diff --git a/packages/rs-drive/src/drive/identity/withdrawals/record_credit_inflow/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/record_credit_inflow/mod.rs index 650fd97ec91..0f18308b89a 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/record_credit_inflow/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/record_credit_inflow/mod.rs @@ -14,6 +14,19 @@ impl Drive { /// unexpired inflows younger than its day-old base snapshot to the daily maximum, making /// it a limit on net outflow. Called once per block with the block's total credit mints /// (asset locks, epoch Core rewards); an `amount` of zero records nothing. + /// + /// # Parameters + /// + /// * `amount`: The credits minted into Platform. + /// * `block_info`: The block being executed; its time sets when the inflow expires. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(())` once `amount` is added to the entry for its expiration time (created if + /// absent), or at once when `amount` is zero. + /// * `Err(Error)` when the method version is unknown or not active, or the write fails. pub fn record_credit_inflow( &self, amount: Credits, diff --git a/packages/rs-drive/src/drive/identity/withdrawals/record_total_credits_history/mod.rs b/packages/rs-drive/src/drive/identity/withdrawals/record_total_credits_history/mod.rs index 1ee93798c1c..aa2b99f2aef 100644 --- a/packages/rs-drive/src/drive/identity/withdrawals/record_total_credits_history/mod.rs +++ b/packages/rs-drive/src/drive/identity/withdrawals/record_total_credits_history/mod.rs @@ -16,6 +16,20 @@ impl Drive { /// `prune_limit` entries per call (`0` disables pruning). Called every block; writes only /// when the total changed, since the limit reads the latest entry at least a day old and an /// entry describes the total until the next one. + /// + /// # Parameters + /// + /// * `block_info`: The block being executed; its time keys the new entry. + /// * `prune_limit`: The most old entries one call deletes; `0` disables pruning. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(())` once the history holds the current total: a new entry (and the prune) when it + /// changed, nothing written when it equals the latest entry. + /// * `Err(Error)` when the method version is unknown or not active, the total credits are + /// missing from state, a history entry is malformed, or a read or the write fails. pub fn record_total_credits_history( &self, block_info: &BlockInfo, diff --git a/packages/rs-drive/src/drive/platform_state/delete_platform_state_entry/mod.rs b/packages/rs-drive/src/drive/platform_state/delete_platform_state_entry/mod.rs index 981d5c53460..d3fd815d0f4 100644 --- a/packages/rs-drive/src/drive/platform_state/delete_platform_state_entry/mod.rs +++ b/packages/rs-drive/src/drive/platform_state/delete_platform_state_entry/mod.rs @@ -10,6 +10,18 @@ use grovedb::TransactionArg; impl Drive { /// Delete one member of a per-entry platform state collection. Deleting a /// member that is not stored is not an error. + /// + /// # Parameters + /// + /// * `kind`: The collection: the masternodes or the validator sets. + /// * `key`: The member's key within the collection (a ProTxHash or a quorum hash). + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(())` once the member is not stored in auxiliary storage. + /// * `Err(Error)` when the method version is unknown or the auxiliary delete fails. pub fn delete_platform_state_entry( &self, kind: PlatformStateEntryKind, diff --git a/packages/rs-drive/src/drive/platform_state/fetch_platform_state_entries_bytes/mod.rs b/packages/rs-drive/src/drive/platform_state/fetch_platform_state_entries_bytes/mod.rs index c8577463845..1d6211c0bdd 100644 --- a/packages/rs-drive/src/drive/platform_state/fetch_platform_state_entries_bytes/mod.rs +++ b/packages/rs-drive/src/drive/platform_state/fetch_platform_state_entries_bytes/mod.rs @@ -11,6 +11,18 @@ impl Drive { /// Every stored member of a per-entry platform state collection, in key /// order, as `(key, bytes)` pairs with the collection's prefix removed from /// the key. + /// + /// # Parameters + /// + /// * `kind`: The collection: the masternodes or the validator sets. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the `(key, bytes)` pairs in key order; empty when + /// the collection has no members. + /// * `Err(Error)` when the method version is unknown or the auxiliary read fails. pub fn fetch_platform_state_entries_bytes( &self, kind: PlatformStateEntryKind, diff --git a/packages/rs-drive/src/drive/platform_state/fetch_platform_state_recent_bytes/mod.rs b/packages/rs-drive/src/drive/platform_state/fetch_platform_state_recent_bytes/mod.rs index 38f4b8696ab..d01c7e77687 100644 --- a/packages/rs-drive/src/drive/platform_state/fetch_platform_state_recent_bytes/mod.rs +++ b/packages/rs-drive/src/drive/platform_state/fetch_platform_state_recent_bytes/mod.rs @@ -8,6 +8,16 @@ use grovedb::TransactionArg; impl Drive { /// Fetches the per-block part of the platform state, if one was ever written. + /// + /// # Parameters + /// + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Some(Vec))` with the stored bytes, `Ok(None)` when none were ever written. + /// * `Err(Error)` when the method version is unknown or the auxiliary read fails. pub fn fetch_platform_state_recent_bytes( &self, transaction: TransactionArg, diff --git a/packages/rs-drive/src/drive/platform_state/store_platform_state_entry_bytes/mod.rs b/packages/rs-drive/src/drive/platform_state/store_platform_state_entry_bytes/mod.rs index 8dfa0a225fe..651e1f00d7c 100644 --- a/packages/rs-drive/src/drive/platform_state/store_platform_state_entry_bytes/mod.rs +++ b/packages/rs-drive/src/drive/platform_state/store_platform_state_entry_bytes/mod.rs @@ -10,6 +10,19 @@ use grovedb::TransactionArg; impl Drive { /// Store one member of a per-entry platform state collection: `bytes` under /// the collection's key prefix followed by `key`, replacing any earlier value. + /// + /// # Parameters + /// + /// * `kind`: The collection: the masternodes or the validator sets. + /// * `key`: The member's key within the collection (a ProTxHash or a quorum hash). + /// * `bytes`: The member's serialized value. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(())` once `bytes` is stored in auxiliary storage under the member's key. + /// * `Err(Error)` when the method version is unknown or the auxiliary write fails. pub fn store_platform_state_entry_bytes( &self, kind: PlatformStateEntryKind, diff --git a/packages/rs-drive/src/drive/platform_state/store_platform_state_recent_bytes/mod.rs b/packages/rs-drive/src/drive/platform_state/store_platform_state_recent_bytes/mod.rs index d9b3b6bdd2e..fb0e732d2c9 100644 --- a/packages/rs-drive/src/drive/platform_state/store_platform_state_recent_bytes/mod.rs +++ b/packages/rs-drive/src/drive/platform_state/store_platform_state_recent_bytes/mod.rs @@ -12,6 +12,17 @@ impl Drive { /// The full record under `saved_state` is rewritten only when its heavy /// fields change; this small companion carries the block info and quorum /// hashes for every block in between. + /// + /// # Parameters + /// + /// * `state_bytes`: The serialized per-block part of the platform state. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(())` once `state_bytes` replaces the stored per-block part in auxiliary storage. + /// * `Err(Error)` when the method version is unknown or the auxiliary write fails. pub fn store_platform_state_recent_bytes( &self, state_bytes: &[u8], diff --git a/packages/rs-drive/src/drive/tokens/distribution/add_once_per_identity_distribution/mod.rs b/packages/rs-drive/src/drive/tokens/distribution/add_once_per_identity_distribution/mod.rs index aa39f89a5a7..e324a60e780 100644 --- a/packages/rs-drive/src/drive/tokens/distribution/add_once_per_identity_distribution/mod.rs +++ b/packages/rs-drive/src/drive/tokens/distribution/add_once_per_identity_distribution/mod.rs @@ -19,6 +19,21 @@ impl Drive { /// Called when a contract registers a token whose distribution rules carry a /// once-per-identity distribution. The subtree starts empty; every claim inserts one item /// keyed by the claimant's identity id. + /// + /// # Parameters + /// + /// * `token_id`: The token whose claims subtree is created. + /// * `estimated_costs_only_with_layer_info`: The estimation map, when only estimating costs. + /// * `batch_operations`: The operations accumulator the tree insert is appended to. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(())` once the insert of the empty claims subtree is appended to + /// `batch_operations`. + /// * `Err(Error)` when the method version is unknown, the subtree already exists, or + /// building the insert fails. pub fn add_once_per_identity_distribution( &self, token_id: [u8; 32], diff --git a/packages/rs-drive/src/drive/tokens/distribution/fetch/once_per_identity_distribution_claim/mod.rs b/packages/rs-drive/src/drive/tokens/distribution/fetch/once_per_identity_distribution_claim/mod.rs index 8c43028f3bb..1218b05e718 100644 --- a/packages/rs-drive/src/drive/tokens/distribution/fetch/once_per_identity_distribution_claim/mod.rs +++ b/packages/rs-drive/src/drive/tokens/distribution/fetch/once_per_identity_distribution_claim/mod.rs @@ -11,6 +11,20 @@ use grovedb::TransactionArg; impl Drive { /// Fetches the block time at which an identity claimed a token's once-per-identity /// distribution, or `None` if the identity has not claimed it. + /// + /// # Parameters + /// + /// * `token_id`: The token. + /// * `identity_id`: The identity whose claim is read. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Some(TimestampMillis))` with the block time of the claim, `Ok(None)` when the + /// identity has not claimed it. + /// * `Err(Error)` when the method version is unknown, the read fails, or the stored claim + /// is not an 8 byte item. pub fn fetch_once_per_identity_distribution_claim( &self, token_id: [u8; 32], @@ -29,6 +43,21 @@ impl Drive { /// Fetches the block time at which an identity claimed a token's once-per-identity /// distribution, accumulating the read operations for fee calculation. + /// + /// # Parameters + /// + /// * `token_id`: The token. + /// * `identity_id`: The identity whose claim is read. + /// * `drive_operations`: The operations accumulator the read is appended to, for billing. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Some(TimestampMillis))` with the block time of the claim, `Ok(None)` when the + /// identity has not claimed it. + /// * `Err(Error)` when the method version is unknown, the read fails, or the stored claim + /// is not an 8 byte item. pub fn fetch_once_per_identity_distribution_claim_operations( &self, token_id: [u8; 32], diff --git a/packages/rs-drive/src/drive/tokens/distribution/mark_once_per_identity_release_as_distributed/mod.rs b/packages/rs-drive/src/drive/tokens/distribution/mark_once_per_identity_release_as_distributed/mod.rs index b9e2d6d6d0b..f21913a7f09 100644 --- a/packages/rs-drive/src/drive/tokens/distribution/mark_once_per_identity_release_as_distributed/mod.rs +++ b/packages/rs-drive/src/drive/tokens/distribution/mark_once_per_identity_release_as_distributed/mod.rs @@ -14,6 +14,20 @@ use std::collections::HashMap; impl Drive { /// Records that `recipient_id` claimed the once-per-identity distribution of a token at /// `claimed_at_ms`, so every later claim by that identity is rejected. + /// + /// # Parameters + /// + /// * `token_id`: The token. + /// * `recipient_id`: The claiming identity, which keys the claim and pays for its storage. + /// * `claimed_at_ms`: The block time of the claim, stored as the claim's value. + /// * `block_info`: The block being executed; its epoch goes in the storage flags. + /// * `estimated_costs_only_with_layer_info`: The estimation map, when only estimating costs. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the insert of the claim item. + /// * `Err(Error)` when the method version is unknown or building the insert fails. pub fn mark_once_per_identity_release_as_distributed_operations( &self, token_id: [u8; 32], diff --git a/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_once_per_identity_distribution/mod.rs b/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_once_per_identity_distribution/mod.rs index f2e24032af4..73a6bf9098c 100644 --- a/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_once_per_identity_distribution/mod.rs +++ b/packages/rs-drive/src/drive/tokens/estimated_costs/for_token_once_per_identity_distribution/mod.rs @@ -12,6 +12,18 @@ impl Drive { /// Adds the estimated layer information for the once-per-identity distribution trees: the /// token distributions root, the once-per-identity root, and, when `token_id` is given, the /// token's claims subtree. + /// + /// # Parameters + /// + /// * `token_id`: The token whose claims subtree is also estimated, or `None` for the roots + /// only. + /// * `estimated_costs_only_with_layer_info`: The estimation map the layers are added to. + /// * `drive_version`: The drive version. + /// + /// # Returns + /// + /// * `Ok(())` once the layers are added to the map. + /// * `Err(Error)` when the method version is unknown. pub(crate) fn add_estimation_costs_for_token_once_per_identity_distribution( token_id: Option<[u8; 32]>, estimated_costs_only_with_layer_info: &mut HashMap, diff --git a/packages/rs-drive/src/query/drive_document_having_query/mode_detection/mod.rs b/packages/rs-drive/src/query/drive_document_having_query/mode_detection/mod.rs index 957f69ee608..806202a29b7 100644 --- a/packages/rs-drive/src/query/drive_document_having_query/mode_detection/mod.rs +++ b/packages/rs-drive/src/query/drive_document_having_query/mode_detection/mod.rs @@ -31,6 +31,23 @@ use dpp::version::PlatformVersion; /// `platform_version.drive.methods.document.query.detect_having_mode`; /// today only `0` is defined and maps to [`detect_having_mode_v0`] /// verbatim. +/// +/// # Parameters +/// +/// * `select`: The selected aggregate (`COUNT(*)`, `SUM(f)` or `AVG(f)`). +/// * `group_by`: The `GROUP BY` properties; exactly one is accepted. +/// * `having`: The `HAVING` clauses; exactly one, on the selected aggregate, is accepted. +/// * `order_by`: The `ORDER BY` clauses; at most one, naming the selected aggregate. +/// * `where_clauses`: The `WHERE` clauses pinning the covering index's leading properties. +/// * `pagination`: The request's limit, offset and whether it carried a start cursor. +/// * `platform_version`: The platform version. +/// +/// # Returns +/// +/// * `Ok(DocumentHavingMode)` with the inclusive bounds, the direction, the limit, the group +/// property, the aggregate field and the prefix pins. +/// * `Err(Error)` with a query syntax error when the method version is unknown or the request +/// falls outside the accepted grammar. #[allow(clippy::too_many_arguments)] pub fn detect_having_mode( select: &SelectProjection, diff --git a/packages/rs-drive/src/query/drive_document_ranked_query/mode_detection/mod.rs b/packages/rs-drive/src/query/drive_document_ranked_query/mode_detection/mod.rs index 2c80fe16696..388fe824c71 100644 --- a/packages/rs-drive/src/query/drive_document_ranked_query/mode_detection/mod.rs +++ b/packages/rs-drive/src/query/drive_document_ranked_query/mode_detection/mod.rs @@ -35,6 +35,23 @@ use dpp::version::PlatformVersion; /// `platform_version.drive.methods.document.query.detect_ranked_mode`; /// today only `0` is defined and maps to [`detect_ranked_mode_v0`] /// verbatim. +/// +/// # Parameters +/// +/// * `select`: The selected aggregate (`COUNT(*)`, `SUM(f)` or `AVG(f)`), which sets the axis. +/// * `group_by`: The `GROUP BY` properties; exactly one is accepted. +/// * `having`: The `HAVING` clauses; must be empty, since a ranking cannot filter groups. +/// * `order_by`: The `ORDER BY` clauses; exactly one, naming the selected aggregate. +/// * `where_clauses`: The `WHERE` clauses pinning the covering index's leading properties. +/// * `pagination`: The request's limit, offset and whether it carried a start cursor. +/// * `platform_version`: The platform version. +/// +/// # Returns +/// +/// * `Ok(DocumentRankedMode)` with the axis, the direction, the limit, the offset, the group +/// property, the aggregate field and the prefix pins. +/// * `Err(Error)` with a query syntax error when the method version is unknown or the request +/// falls outside the accepted grammar. pub fn detect_ranked_mode( select: &SelectProjection, group_by: &[String], diff --git a/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/mod.rs b/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/mod.rs index 36da50ebeb5..74b9a4eddb6 100644 --- a/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/mod.rs +++ b/packages/rs-drive/src/query/non_primary_key_path_query/multiple_in_path_query/mod.rs @@ -18,6 +18,21 @@ impl<'a> DriveDocumentQuery<'a> { #[cfg(any(feature = "server", feature = "verify"))] /// Lowers a query with multiple `In` clauses into a path query whose /// levels carry one key set per `In` clause. + /// + /// # Parameters + /// + /// * `document_type_path`: The path of the document type's tree the query starts from. + /// * `starts_at_document`: The cursor document and whether it is included (`startAt`) or + /// excluded (`startAfter`), if any. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(PathQuery)` walking the equality prefix, one key-set level per `In` clause, then + /// the optional range level and the remaining index levels. + /// * `Err(Error)` when the method version is unknown, a cursor is given, no index fits the + /// clauses, the product of the `In` list sizes is too large, or a value does not + /// serialize. pub(in crate::query) fn get_non_primary_key_multiple_in_path_query( &self, document_type_path: Vec>, diff --git a/packages/rs-drive/src/query/non_primary_key_path_query/single_in_path_query/mod.rs b/packages/rs-drive/src/query/non_primary_key_path_query/single_in_path_query/mod.rs index 9641286cc1f..dd3528b33d1 100644 --- a/packages/rs-drive/src/query/non_primary_key_path_query/single_in_path_query/mod.rs +++ b/packages/rs-drive/src/query/non_primary_key_path_query/single_in_path_query/mod.rs @@ -17,6 +17,20 @@ use grovedb::PathQuery; impl<'a> DriveDocumentQuery<'a> { #[cfg(any(feature = "server", feature = "verify"))] /// Lowers a query with at most one `In` clause into a path query. + /// + /// # Parameters + /// + /// * `document_type_path`: The path of the document type's tree the query starts from. + /// * `starts_at_document`: The cursor document and whether it is included (`startAt`) or + /// excluded (`startAfter`), if any. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(PathQuery)` walking the index the query picks, paginated from the cursor if any. + /// * `Err(Error)` when the method version is unknown, the query has more than one `In` + /// clause, no index fits the clauses or they do not cover its prefix contiguously, or a + /// value does not serialize. pub(in crate::query) fn get_non_primary_key_single_in_path_query( &self, document_type_path: Vec>, diff --git a/packages/rs-drive/src/query/where_clause_grouping/mod.rs b/packages/rs-drive/src/query/where_clause_grouping/mod.rs index 51851b37f18..441552a4453 100644 --- a/packages/rs-drive/src/query/where_clause_grouping/mod.rs +++ b/packages/rs-drive/src/query/where_clause_grouping/mod.rs @@ -27,6 +27,19 @@ pub(crate) type GroupedWhereClauses = ( ); /// Group raw where clauses under the platform version's grammar. +/// +/// # Parameters +/// +/// * `where_clauses`: The query's raw where clauses. +/// * `platform_version`: The platform version. +/// +/// # Returns +/// +/// * `Ok(GroupedWhereClauses)`: the equality clauses by field, the range clause if any, and the +/// `In` clauses (at most one under v0, one per distinct field under v1). +/// * `Err(Error)` when the method version is unknown or the clauses cannot be grouped: more +/// than one `In` clause under v0, clauses on one field that cannot be combined, range +/// clauses on more than one field or that do not form one range, or a malformed clause. pub(crate) fn group_where_clauses( where_clauses: &[WhereClause], platform_version: &PlatformVersion, diff --git a/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/mod.rs b/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/mod.rs index e53afc2b4fb..b63557f7c15 100644 --- a/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/mod.rs +++ b/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/mod.rs @@ -35,6 +35,19 @@ impl DriveDocumentQuery<'_> { /// the returned root hash with the surrounding tenderdash /// signature — see `rs-drive-proof-verifier` for the canonical /// composition. + /// + /// # Parameters + /// + /// * `proof`: The merged proof, as `query_chained_documents_with_proof` produced it. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok((RootHash, ChainedDocumentsResult))` with the proof's root hash, the proven inner + /// projections, the proven outer documents and the join values proven to have none. + /// * `Err(Error)` when the method version is unknown, the query is not a valid chained + /// query, or the proof is invalid: it fails verification, lacks a referenced document + /// that cannot be deleted, or carries an outer document no join value asked for. pub fn verify_chained_documents_proof( &self, proof: &[u8], diff --git a/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/mod.rs b/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/mod.rs index 66d2c5e3bf6..973b77f88cc 100644 --- a/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/mod.rs +++ b/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/mod.rs @@ -33,6 +33,20 @@ impl DriveDocumentQuery<'_> { /// One proof means one root by construction; the caller combines the /// returned root hash with the surrounding tenderdash signature — see /// `rs-drive-proof-verifier` for the canonical composition. + /// + /// # Parameters + /// + /// * `proof`: The merged proof, as `query_composite_documents_with_proof` produced it. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok((RootHash, CompositeDocumentsResult))` with the proof's root hash, the proven page, + /// one proven result per sub-query, and the ids each by-id join proved absent. + /// * `Err(Error)` when the method version is unknown, the query is not a valid composite + /// query, or the proof is invalid: it fails verification, carries an entry no derivation + /// asked for, lacks a referenced document that cannot be deleted, or its page derives + /// values other than those the query was built from. pub fn verify_composite_documents_proof( &self, proof: &[u8], diff --git a/packages/rs-drive/src/verify/contract_groups/verify_contract_group_info/mod.rs b/packages/rs-drive/src/verify/contract_groups/verify_contract_group_info/mod.rs index ce0c86d9b42..f25cc63a5ca 100644 --- a/packages/rs-drive/src/verify/contract_groups/verify_contract_group_info/mod.rs +++ b/packages/rs-drive/src/verify/contract_groups/verify_contract_group_info/mod.rs @@ -13,6 +13,19 @@ impl Drive { /// /// Returns the root hash and the information, or `None` when the proof shows the group is /// absent. + /// + /// # Parameters + /// + /// * `proof`: The proof, as `prove_contract_group_info` produced it. + /// * `contract_group_id`: The group's id. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok((RootHash, Option))` with the proof's root hash and the group's + /// information, `None` when the proof shows the group is absent. + /// * `Err(Error)` when the method version is unknown, the proof fails verification, or it + /// holds anything but at most one info item that decodes. pub fn verify_contract_group_info( proof: &[u8], contract_group_id: Identifier, diff --git a/packages/rs-drive/src/verify/contract_groups/verify_contract_group_members/mod.rs b/packages/rs-drive/src/verify/contract_groups/verify_contract_group_members/mod.rs index bb2bac880ce..52d41473e00 100644 --- a/packages/rs-drive/src/verify/contract_groups/verify_contract_group_members/mod.rs +++ b/packages/rs-drive/src/verify/contract_groups/verify_contract_group_members/mod.rs @@ -14,6 +14,21 @@ impl Drive { /// /// Returns the root hash and the page, empty when the group is absent or has no members of /// that kind after the cursor. + /// + /// # Parameters + /// + /// * `proof`: The proof, as `prove_contract_group_members` produced it. + /// * `contract_group_id`: The group's id. + /// * `query`: The kind of member and the cursor the proof was built for. + /// * `limit`: The page size the proof was built for. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok((RootHash, ContractGroupMembersPage))` with the proof's root hash and the page, + /// empty when the group is absent or has no more members of that kind. + /// * `Err(Error)` when the method version is unknown, the proof fails verification, or a + /// proven member is malformed. pub fn verify_contract_group_members( proof: &[u8], contract_group_id: Identifier, diff --git a/packages/rs-drive/src/verify/contract_groups/verify_contract_group_memberships_for_contract/mod.rs b/packages/rs-drive/src/verify/contract_groups/verify_contract_group_memberships_for_contract/mod.rs index 0e664409b90..3126683c2e5 100644 --- a/packages/rs-drive/src/verify/contract_groups/verify_contract_group_memberships_for_contract/mod.rs +++ b/packages/rs-drive/src/verify/contract_groups/verify_contract_group_memberships_for_contract/mod.rs @@ -11,6 +11,20 @@ use dpp::version::PlatformVersion; impl Drive { /// Verifies a proof of the contract groups a contract belongs to, as a whole, through its /// document types and through its tokens. Empty when the contract belongs to no group. + /// + /// # Parameters + /// + /// * `proof`: The proof, as `prove_contract_group_memberships_for_contract` produced it. + /// * `contract_id`: The contract's id. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok((RootHash, ContractGroupMembershipsForContract))` with the proof's root hash and + /// the groups of the whole contract, of each document type and of each token; empty when + /// it belongs to no group. + /// * `Err(Error)` when the method version is unknown, the proof fails verification, or a + /// proven membership is malformed. pub fn verify_contract_group_memberships_for_contract( proof: &[u8], contract_id: Identifier, diff --git a/packages/rs-drive/src/verify/contract_moderation/verify_contract_document_removals/mod.rs b/packages/rs-drive/src/verify/contract_moderation/verify_contract_document_removals/mod.rs index 1fe039e4d40..ebdba2a0f19 100644 --- a/packages/rs-drive/src/verify/contract_moderation/verify_contract_document_removals/mod.rs +++ b/packages/rs-drive/src/verify/contract_moderation/verify_contract_document_removals/mod.rs @@ -18,6 +18,22 @@ impl Drive { /// The verifier rebuilds the path query from `query`, so a read by ids proves each id /// named either present with its record or absent (an id the result does not hold has no /// record), and a page proves the records after its cursor up to its limit. + /// + /// # Parameters + /// + /// * `proof`: The proof, as `prove_contract_document_removals` produced it. + /// * `contract_id`: The contract whose moderators deleted the documents. + /// * `query`: The document type and the selection the proof was built for. + /// * `verify_subset_of_proof`: Whether the proof may prove more than this query (verified as + /// a subset). + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok((RootHash, Vec))` with the proof's root hash and the + /// proven records, in document id order; ids proved absent are left out. + /// * `Err(Error)` when the method version is unknown, the proof fails verification, or a + /// proven record is malformed. pub fn verify_contract_document_removals( proof: &[u8], contract_id: Identifier, diff --git a/packages/rs-drive/src/verify/document_having/verify_having_range_proof/mod.rs b/packages/rs-drive/src/verify/document_having/verify_having_range_proof/mod.rs index 9f202485af0..4a5afe79fc3 100644 --- a/packages/rs-drive/src/verify/document_having/verify_having_range_proof/mod.rs +++ b/packages/rs-drive/src/verify/document_having/verify_having_range_proof/mod.rs @@ -29,6 +29,15 @@ impl DriveDocumentHavingQuery<'_> { /// # Arguments /// * `proof` — raw grovedb proof bytes. /// * `platform_version` — selects the method version. + /// + /// # Returns + /// + /// * `Ok((RootHash, Vec))` with the proof's root hash and the groups whose + /// aggregate falls in the bounds, at most `limit` of them, in axis order in the walk + /// direction; empty when no group matches. + /// * `Err(Error)` when the method version is unknown, the index path cannot be resolved, + /// the proof does not verify against the rebuilt traversal, or its entries have the + /// wrong axis shape or exceed the limit. pub fn verify_having_range_proof( &self, proof: &[u8], diff --git a/packages/rs-drive/src/verify/document_ranked/verify_ranked_top_k_proof/mod.rs b/packages/rs-drive/src/verify/document_ranked/verify_ranked_top_k_proof/mod.rs index 39bacf417f4..d71f1fbccfc 100644 --- a/packages/rs-drive/src/verify/document_ranked/verify_ranked_top_k_proof/mod.rs +++ b/packages/rs-drive/src/verify/document_ranked/verify_ranked_top_k_proof/mod.rs @@ -29,6 +29,15 @@ impl DriveDocumentRankedQuery<'_> { /// # Arguments /// * `proof` — raw grovedb proof bytes. /// * `platform_version` — selects the method version. + /// + /// # Returns + /// + /// * `Ok((RootHash, RankedPage))` with the proof's root hash, at most `k` entries in ranking + /// order, and the attested number of ranks skipped (below the offset, with no entries, + /// when the walk ran out during the skip). + /// * `Err(Error)` when the method version is unknown, a non-zero offset meets an `IN` + /// prefix pin, the index path cannot be resolved, the proof does not verify against the + /// rebuilt traversal, or its entries have the wrong axis shape or exceed `k`. pub fn verify_ranked_top_k_proof( &self, proof: &[u8], From 07ab909d05b61fa5c12dfdfe642f589f54876e11 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 27 Sep 2026 11:15:21 -0500 Subject: [PATCH 041/113] fix(ci): build release clients natively on unprivileged runners --- .github/NPM_RUNNER.md | 54 +++ .github/actionlint.yaml | 1 + .github/actions/npm-release-build/action.yaml | 121 +++++++ .github/actions/rust/action.yaml | 15 +- .github/runner-requirements.json | 24 +- .github/scripts/runner-image.py | 4 +- .github/scripts/tests/test_runner_image.py | 9 +- .github/workflows/npm-runner-validation.yml | 62 ++++ .github/workflows/release.yml | 138 +------- .github/workflows/test-client-codegen.yml | 42 +++ .github/workflows/tests-build-js.yml | 42 +-- .pnp.cjs | 20 +- ...tobuf-npm-3.21.4-48c47540d3-0d87fe8ef2.zip | Bin 0 -> 85906 bytes ...c-gen-npm-0.15.0-4bb1076a19-de1d526b47.zip | Bin 0 -> 64726 bytes packages/dapi-grpc/README.md | 32 ++ packages/dapi-grpc/codegen.json | 31 ++ packages/dapi-grpc/package.json | 3 +- packages/dapi-grpc/scripts/build.sh | 308 ++++-------------- .../dapi-grpc/scripts/check-packed-clients.py | 25 ++ .../dapi-grpc/scripts/patch-protobuf-js.sh | 17 +- packages/dapi-grpc/scripts/setup-codegen.py | 74 +++++ .../tests/codegen/test_generation.py | 78 +++++ yarn.lock | 19 ++ 23 files changed, 690 insertions(+), 429 deletions(-) create mode 100644 .github/NPM_RUNNER.md create mode 100644 .github/actions/npm-release-build/action.yaml create mode 100644 .github/workflows/npm-runner-validation.yml create mode 100644 .github/workflows/test-client-codegen.yml create mode 100644 .yarn/cache/google-protobuf-npm-3.21.4-48c47540d3-0d87fe8ef2.zip create mode 100644 .yarn/cache/ts-protoc-gen-npm-0.15.0-4bb1076a19-de1d526b47.zip create mode 100644 packages/dapi-grpc/codegen.json create mode 100644 packages/dapi-grpc/scripts/check-packed-clients.py create mode 100644 packages/dapi-grpc/scripts/setup-codegen.py create mode 100644 packages/dapi-grpc/tests/codegen/test_generation.py diff --git a/.github/NPM_RUNNER.md b/.github/NPM_RUNNER.md new file mode 100644 index 00000000000..3d11549f761 --- /dev/null +++ b/.github/NPM_RUNNER.md @@ -0,0 +1,54 @@ +# NPM release runners + +NPM release compilation uses `[self-hosted, Linux, X64, npm-build]`. Publishing +continues on GitHub-hosted Ubuntu with OIDC; the builder receives no publishing +credentials. The `npm-release-build` action is shared by releases and image +validation so both compile and pack with the same setup. + +## Image contract + +`.github/runner-requirements.json` pins the complete Linux image requirements and +recipe commit. The job runs `ci-image-contract verify` before compiling. The +runner must have `/opt/client-codegen` matching `packages/dapi-grpc/codegen.json`. +Its protobuf 3.18.1 compiler is intentionally separate from Rust's protoc 32.0. +TypeScript generation comes from the workspace's pinned `ts-protoc-gen` dependency. + +Image-owned native dependencies are verified, never installed using sudo. Rust, +Node and the pinned WASM tools use writable runner/user locations. Cargo targets +remain outside the checkout; each release starts with a fresh workspace. The +runner needs no Docker CLI/socket or KVM device. + +## Provisioning and promotion + +Use the reviewed `dashpay/dash-selfhosted-image` recipe and a tested immutable +image digest, not a moving tag. Register new capacity with `npm-build` only after +the NPM validation workflow succeeds on that image. Drain old registrations before +replacement; retain their image/configuration for rollback. Old release tags +still contain their original workflows and do not automatically gain this fix. + +Requirements-changing PRs select a candidate label bound to the complete PR head +and image digest. The image controller must support the `npm` job kind and +`.github/workflows/npm-runner-validation.yml`. Manifests requesting native client +generation require successful Rust, Kotlin and NPM candidate jobs for promotion; +skipped fork jobs do not qualify. Existing same-repository/trusted-fork guards +remain in effect. + +The trusted `runner-image-candidate.yml` bootstrap, controller and Rust/Kotlin +candidate routing must be installed on each consuming branch before candidate +promotion can work. Platform PRs #4702 and #4912 establish those pieces; reconcile +their requirements/selector files with this NPM extension when landing them. In +particular, update both the bootstrap's reusable-workflow SHA and its +`control_revision` to a reviewed image-repository revision supporting +`client_codegen` and `npm`. Merely changing `recipe_revision` is insufficient. +The default `v4.2-dev` and `v4.3-dev` branches must each use an explicit compatible +manifest; this change does not alter an existing release tag or deploy a runner. + +## Verification + +`npm-runner-validation.yml` runs the real release build and packing action, DAPI +unit tests, and a byte-for-byte check that packed Node/web clients match the +freshly generated files. It uploads tarballs but never publishes them. +`test-client-codegen.yml` also builds the native compilers on hosted Linux/macOS, +checks committed generated output, tests failure recovery and validates packing. + +Local setup and generator test commands are in `packages/dapi-grpc/README.md`. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 14ff10fba49..bcf2dd1828b 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -9,3 +9,4 @@ self-hosted-runner: labels: - rust-ci - kotlin-ci + - npm-build diff --git a/.github/actions/npm-release-build/action.yaml b/.github/actions/npm-release-build/action.yaml new file mode 100644 index 00000000000..8806cac1a7b --- /dev/null +++ b/.github/actions/npm-release-build/action.yaml @@ -0,0 +1,121 @@ +name: Build release NPM packages +description: Compile and pack on the provisioned unprivileged Linux image +inputs: + cache-name: + description: Persistent Cargo target cache name + default: release-npm-target +runs: + using: composite + steps: + - name: Verify provisioned runner dependencies + shell: bash + run: | + set -euo pipefail + + ci-image-contract verify .github/runner-requirements.json + python3 packages/dapi-grpc/scripts/setup-codegen.py + for pkg in build-essential cmake curl jq libgmp-dev libssl-dev pkg-config python3 unzip zip; do + dpkg-query -W -f='${Status}' "$pkg" | grep -Fx 'install ok installed' + done + test -x "$HOME/.cargo/bin/rustup" + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Setup Rust + uses: ./.github/actions/rust + with: + target: wasm32-unknown-unknown + # The runner persists the Cargo registry between runs. + cache: 'false' + system-dependencies: verify + + # The Rust action above reuses a protoc left in ~/.local by earlier jobs. + # Override it with a fresh, checksum-verified copy for the release build; + # prost-build reads PROTOC first. + - name: Install protoc v32.0 + env: + PROTOC_SHA256: 7ca037bfe5e5cabd4255ccd21dd265f79eb82d3c010117994f5dc81d2140ee88 + shell: bash + run: | + set -euo pipefail + PROTOC_DIR="$RUNNER_TEMP/protoc-32.0" + curl -fsSL -o "$RUNNER_TEMP/protoc.zip" \ + https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-x86_64.zip + echo "$PROTOC_SHA256 $RUNNER_TEMP/protoc.zip" | sha256sum -c - + rm -rf "$PROTOC_DIR" + unzip -q "$RUNNER_TEMP/protoc.zip" -d "$PROTOC_DIR" + echo "PROTOC=$PROTOC_DIR/bin/protoc" >> "$GITHUB_ENV" + echo "$PROTOC_DIR/bin" >> "$GITHUB_PATH" + "$PROTOC_DIR/bin/protoc" --version + + - name: Prepare release Cargo target cache + uses: ./.github/actions/release-cargo-target-cache + with: + name: ${{ inputs.cache-name }} + + - name: Setup Node.JS + uses: ./.github/actions/nodejs + + - name: Install Cargo binstall + uses: cargo-bins/cargo-binstall@v1.3.1 + + # Always reinstalled, never trusted from an earlier job: a binary left on + # this persistent runner can print the pinned version and still be + # something else. + - name: Install wasm-bindgen-cli + shell: bash + run: | + set -euo pipefail + cargo binstall wasm-bindgen-cli@0.2.108 --no-confirm --force + wasm-bindgen --version | grep -Fxq 'wasm-bindgen 0.2.108' + + - name: Install wasm-pack + shell: bash + run: | + set -euo pipefail + cargo binstall wasm-pack@0.15.0 --no-confirm --force + wasm-pack --version | grep -Fxq 'wasm-pack 0.15.0' + + # Fresh, checksum-verified copy in this job's temp directory rather than + # one left in ~/.local by an earlier job. + - name: Install Binaryen + env: + BINARYEN_SHA256: c90e0e295e8f8484ba5b47da92f26e5d1d18db6cd2fcc0c5cc265a5a73609f17 + shell: bash + run: | + set -euo pipefail + ARCHIVE="$RUNNER_TEMP/binaryen-version_121-x86_64-linux.tar.gz" + curl -fsSL -o "$ARCHIVE" \ + https://github.com/WebAssembly/binaryen/releases/download/version_121/binaryen-version_121-x86_64-linux.tar.gz + echo "$BINARYEN_SHA256 $ARCHIVE" | sha256sum -c - + rm -rf "$RUNNER_TEMP/binaryen-version_121" + tar -xzf "$ARCHIVE" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/binaryen-version_121/bin" >> "$GITHUB_PATH" + + - name: Build packages + shell: bash + run: yarn build + env: + CARGO_BUILD_PROFILE: release + + - name: Ignore only already cached artifacts + shell: bash + run: | + find . -name '.gitignore' -exec rm -f {} + + { + echo ".yarn" + echo "target" + echo "node_modules" + echo ".nyc_output" + echo ".idea" + echo ".ultra.cache.json" + echo "db/*" + echo "npm-packages" + } >> .gitignore + + - name: Pack public workspaces + shell: bash + run: | + mkdir -p npm-packages + yarn workspaces foreach --all --no-private --parallel pack \ + --out "$GITHUB_WORKSPACE/npm-packages/%s-%v.tgz" + test -n "$(find npm-packages -maxdepth 1 -type f -name '*.tgz' -print -quit)" diff --git a/.github/actions/rust/action.yaml b/.github/actions/rust/action.yaml index b5b0f696360..945806752e2 100644 --- a/.github/actions/rust/action.yaml +++ b/.github/actions/rust/action.yaml @@ -12,6 +12,9 @@ inputs: components: description: List of additional Rust toolchain components to install required: false + system-dependencies: + description: Install native packages on hosted runners, or verify a provisioned image + default: install cache: description: Enable Rust cache required: false @@ -21,6 +24,16 @@ inputs: runs: using: composite steps: + - name: Validate native dependency mode + shell: bash + env: + DEPENDENCY_MODE: ${{ inputs.system-dependencies }} + run: | + case "$DEPENDENCY_MODE" in + install|verify) ;; + *) echo "::error::Unknown native dependency mode"; exit 1 ;; + esac + - name: Read shared runner tool requirements shell: bash run: python3 .github/scripts/runner-image.py env @@ -144,7 +157,7 @@ runs: # This composite is also used by hosted release, nightly and book jobs. # Keep their bootstrap path; only persistent runners require a prebaked image. - name: Install native dependencies on ephemeral GitHub-hosted Linux - if: runner.os == 'Linux' && runner.environment == 'github-hosted' + if: runner.os == 'Linux' && runner.environment == 'github-hosted' && inputs.system-dependencies == 'install' shell: bash run: | sudo apt-get update diff --git a/.github/runner-requirements.json b/.github/runner-requirements.json index 352fb808d33..2987919fc83 100644 --- a/.github/runner-requirements.json +++ b/.github/runner-requirements.json @@ -1,6 +1,6 @@ { "schema": 1, - "recipe_revision": "baf8849b900555d66714e0e1fcffdff669b9e404", + "recipe_revision": "e49e8bc9977f5f961a76ba1d1f7673c72173679f", "requirements": { "schema": 2, "contract_version": "1", @@ -201,6 +201,28 @@ "cmdline_tools": "19.0", "system_image": "system-images;android-35;default;x86_64", "abi": "x86_64" + }, + "client_codegen": { + "schema": 1, + "versions": { + "protobuf": "3.18.1", + "grpc": "1.46.3", + "grpc_java": "1.42.1" + }, + "sources": [ + { + "url": "https://github.com/protocolbuffers/protobuf/releases/download/v3.18.1/protobuf-cpp-3.18.1.tar.gz", + "sha256": "6ee35eda3f79e49608d2ace8d866313fdec539d8bb14c6c54e8d2a16fa4e6780" + }, + { + "url": "https://github.com/grpc/grpc/archive/refs/tags/v1.46.3.tar.gz", + "sha256": "d6cbf22cb5007af71b61c6be316a79397469c58c82a942552a62e708bce60964" + }, + { + "url": "https://github.com/grpc/grpc-java/archive/refs/tags/v1.42.1.tar.gz", + "sha256": "33775a1ad05974bbba6ff97801cd9b326485b21fab91b08a4e1bc53e500e6326" + } + ] } } } diff --git a/.github/scripts/runner-image.py b/.github/scripts/runner-image.py index 258cf6c442c..e61ae30f263 100644 --- a/.github/scripts/runner-image.py +++ b/.github/scripts/runner-image.py @@ -77,7 +77,7 @@ def export_environment(manifest, output): def select(manifest, kind, output, wait_seconds): - fallback = ["self-hosted", "rust-ci" if kind == "rust" else "kotlin-ci"] + fallback = ["self-hosted", {"rust": "rust-ci", "kotlin": "kotlin-ci", "npm": "npm-build"}[kind]] event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) requested = event.get("pull_request") labels, changed = fallback, False @@ -130,7 +130,7 @@ def main(): parser.add_argument("--manifest", default=MANIFEST) parser.add_argument("--env", default=os.environ.get("GITHUB_ENV")) parser.add_argument("--output", default=os.environ.get("GITHUB_OUTPUT")) - parser.add_argument("--kind", choices=["rust", "kotlin"]) + parser.add_argument("--kind", choices=["rust", "kotlin", "npm"]) parser.add_argument("--wait-seconds", type=int, default=7200) args = parser.parse_args() manifest = read_manifest(args.manifest) diff --git a/.github/scripts/tests/test_runner_image.py b/.github/scripts/tests/test_runner_image.py index c670105855c..68efbbe8c12 100644 --- a/.github/scripts/tests/test_runner_image.py +++ b/.github/scripts/tests/test_runner_image.py @@ -40,11 +40,11 @@ def setUp(self): "event": "pull_request_target", "conclusion": "success"}, } - def select(self, event=None): + def select(self, event=None, kind="rust"): self.event.write_text(json.dumps(event if event is not None else {"pull_request": self.pr})) with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(self.event)}), \ patch.object(runner, "api", side_effect=lambda path: self.responses[path]): - runner.select(self.manifest, "rust", self.output, 0) + runner.select(self.manifest, kind, self.output, 0) return dict(line.split("=", 1) for line in self.output.read_text().splitlines()) def test_non_pr_and_unchanged_pr_use_existing_pool(self): @@ -58,6 +58,11 @@ def test_exact_candidate_includes_head_digest_and_kind(self): self.assertEqual(labels[-1], f"platform-image-pr-4702-{HEAD}-{DIGEST[7:]}-rust") self.assertEqual(output["image_changed"], "true") + def test_npm_candidates_and_ordinary_pool_have_distinct_labels(self): + labels = json.loads(self.select(kind="npm")["labels"]) + self.assertEqual(labels[-1], f"platform-image-pr-4702-{HEAD}-{DIGEST[7:]}-npm") + self.assertEqual(json.loads(self.select({}, kind="npm")["labels"]), ["self-hosted", "npm-build"]) + def test_new_head_or_closed_pr_rejects_stale_run(self): event = {"pull_request": copy.deepcopy(self.pr)} self.pr["head"]["sha"] = "e" * 40 diff --git a/.github/workflows/npm-runner-validation.yml b/.github/workflows/npm-runner-validation.yml new file mode 100644 index 00000000000..37c8d4998a9 --- /dev/null +++ b/.github/workflows/npm-runner-validation.yml @@ -0,0 +1,62 @@ +name: Validate NPM runner image +on: + pull_request: + paths: + - '.github/runner-requirements.json' + - '.github/actions/npm-release-build/**' + - '.github/actions/rust/**' + - '.github/actions/nodejs/**' + - '.github/workflows/npm-runner-validation.yml' + - '.github/workflows/release.yml' + - 'packages/dapi-grpc/**' + workflow_dispatch: +permissions: + contents: read +jobs: + select-runner: + if: >- + github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name == github.repository + || github.event.pull_request.head.repo.owner.login == 'thepastaclaw' + runs-on: ubuntu-24.04 + timeout-minutes: 135 + permissions: + contents: read + pull-requests: read + statuses: read + actions: read + outputs: + labels: ${{ steps.select.outputs.labels }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - id: select + env: + GH_TOKEN: ${{ github.token }} + run: python3 .github/scripts/runner-image.py select --kind npm + build: + name: NPM release build validation + needs: select-runner + runs-on: ${{ fromJSON(needs.select-runner.outputs.labels) }} + timeout-minutes: 120 + steps: + - name: Empty the previous job workspace + run: find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Run the release build and pack without publishing + uses: ./.github/actions/npm-release-build + with: + cache-name: npm-validation-target + - name: Test the generated clients + run: yarn workspace @dashevo/dapi-grpc test:unit + - name: Verify the DAPI archive contains generated clients + run: python3 packages/dapi-grpc/scripts/check-packed-clients.py npm-packages + - uses: actions/upload-artifact@v4 + with: + name: npm-validation-${{ github.sha }} + path: npm-packages/*.tgz + if-no-files-found: error + retention-days: 3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 175b25c7624..6b77a59ce61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: # Reuse the persistent Linux runner's Cargo registry and a release-only # target cache. The publish job stays on a GitHub-hosted runner because npm # trusted publishing does not support self-hosted runners. - runs-on: [self-hosted, kotlin-ci] + runs-on: [self-hosted, Linux, X64, npm-build] timeout-minutes: 120 if: github.event_name == 'release' || startsWith(inputs.tag, 'npm-test:') # In particular, do not mint an OIDC token for the persistent build host. @@ -49,8 +49,7 @@ jobs: - name: Empty the workspace left by earlier jobs if: ${{ steps.check-artifact.outputs.exists != 'true' }} run: | - find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + \ - || sudo -n find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + - name: Check out repo uses: actions/checkout@v4 @@ -58,137 +57,8 @@ jobs: persist-credentials: false if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - name: Ensure runner dependencies - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - run: | - set -euo pipefail - - MISSING=() - for pkg in build-essential cmake curl jq libgmp-dev libssl-dev pkg-config python3 unzip zip; do - dpkg -s "$pkg" >/dev/null 2>&1 || MISSING+=("$pkg") - done - if [ ${#MISSING[@]} -gt 0 ]; then - echo "Installing: ${MISSING[*]}" - sudo apt-get update -qq - sudo apt-get install -qq --yes "${MISSING[@]}" - fi - - if [ ! -x "$HOME/.cargo/bin/rustup" ]; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --no-modify-path --default-toolchain none - fi - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - docker info >/dev/null - # Always pull: a local image under this tag may have been replaced - # by an earlier job, and a pull resets the tag to the registry's. - docker pull rvolosatovs/protoc:4.0.0 - - - name: Setup Rust - uses: ./.github/actions/rust - with: - target: wasm32-unknown-unknown - # The runner persists the Cargo registry between runs. - cache: 'false' - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - # The Rust action above reuses a protoc left in ~/.local by earlier jobs. - # Override it with a fresh, checksum-verified copy for the release build; - # prost-build reads PROTOC first. - - name: Install protoc v32.0 - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - env: - PROTOC_SHA256: 7ca037bfe5e5cabd4255ccd21dd265f79eb82d3c010117994f5dc81d2140ee88 - run: | - set -euo pipefail - PROTOC_DIR="$RUNNER_TEMP/protoc-32.0" - curl -fsSL -o "$RUNNER_TEMP/protoc.zip" \ - https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-x86_64.zip - echo "$PROTOC_SHA256 $RUNNER_TEMP/protoc.zip" | sha256sum -c - - rm -rf "$PROTOC_DIR" - unzip -q "$RUNNER_TEMP/protoc.zip" -d "$PROTOC_DIR" - echo "PROTOC=$PROTOC_DIR/bin/protoc" >> "$GITHUB_ENV" - echo "$PROTOC_DIR/bin" >> "$GITHUB_PATH" - "$PROTOC_DIR/bin/protoc" --version - - - name: Prepare release Cargo target cache - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - uses: ./.github/actions/release-cargo-target-cache - with: - name: release-npm-target - - - name: Setup Node.JS - uses: ./.github/actions/nodejs - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - - name: Install Cargo binstall - uses: cargo-bins/cargo-binstall@v1.3.1 - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - # Always reinstalled, never trusted from an earlier job: a binary left on - # this persistent runner can print the pinned version and still be - # something else. - - name: Install wasm-bindgen-cli - run: | - set -euo pipefail - cargo binstall wasm-bindgen-cli@0.2.108 --no-confirm --force - wasm-bindgen --version | grep -Fxq 'wasm-bindgen 0.2.108' - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - - name: Install wasm-pack - run: | - set -euo pipefail - cargo binstall wasm-pack@0.15.0 --no-confirm --force - wasm-pack --version | grep -Fxq 'wasm-pack 0.15.0' - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - # Fresh, checksum-verified copy in this job's temp directory rather than - # one left in ~/.local by an earlier job. - - name: Install Binaryen - env: - BINARYEN_SHA256: c90e0e295e8f8484ba5b47da92f26e5d1d18db6cd2fcc0c5cc265a5a73609f17 - run: | - set -euo pipefail - ARCHIVE="$RUNNER_TEMP/binaryen-version_121-x86_64-linux.tar.gz" - curl -fsSL -o "$ARCHIVE" \ - https://github.com/WebAssembly/binaryen/releases/download/version_121/binaryen-version_121-x86_64-linux.tar.gz - echo "$BINARYEN_SHA256 $ARCHIVE" | sha256sum -c - - rm -rf "$RUNNER_TEMP/binaryen-version_121" - tar -xzf "$ARCHIVE" -C "$RUNNER_TEMP" - echo "$RUNNER_TEMP/binaryen-version_121/bin" >> "$GITHUB_PATH" - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - - name: Build packages - run: yarn build - env: - CARGO_BUILD_PROFILE: release - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - - name: Ignore only already cached artifacts - run: | - find . -name '.gitignore' -exec rm -f {} + - { - echo ".yarn" - echo "target" - echo "node_modules" - echo ".nyc_output" - echo ".idea" - echo ".ultra.cache.json" - echo "db/*" - echo "npm-packages" - } >> .gitignore - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - # Pack on the unprivileged builder. The hosted publisher receives only - # these tarballs and publishes them without running package lifecycle - # scripts, so persistent-runner output cannot execute with OIDC access. - - name: Pack NPM packages on the unprivileged builder - run: | - set -euo pipefail - mkdir -p npm-packages - yarn workspaces foreach --all --no-private --parallel pack \ - --out "$GITHUB_WORKSPACE/npm-packages/%s-%v.tgz" - test -n "$(find npm-packages -maxdepth 1 -type f -name '*.tgz' -print -quit)" + - name: Build and pack NPM packages + uses: ./.github/actions/npm-release-build if: ${{ steps.check-artifact.outputs.exists != 'true' }} - name: Get modified files diff --git a/.github/workflows/test-client-codegen.yml b/.github/workflows/test-client-codegen.yml new file mode 100644 index 00000000000..1517760dacd --- /dev/null +++ b/.github/workflows/test-client-codegen.yml @@ -0,0 +1,42 @@ +name: Native client generation +on: + pull_request: + paths: + - 'packages/dapi-grpc/**' + - '.github/workflows/test-client-codegen.yml' + - 'yarn.lock' + workflow_dispatch: +permissions: + contents: read +jobs: + generate: + strategy: + matrix: + os: [ubuntu-24.04, macos-15] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/cache@v5 + with: + path: ~/.cache/dash/client-codegen + key: client-codegen/${{ runner.os }}/${{ runner.arch }}/${{ hashFiles('packages/dapi-grpc/codegen.json') }} + - name: Build the locked native compilers + run: python3 packages/dapi-grpc/scripts/setup-codegen.py --install + - uses: ./.github/actions/nodejs + - name: Regenerate and test clients + run: | + yarn workspace @dashevo/dapi-grpc build + git diff --exit-code -- packages/dapi-grpc/clients + yarn workspace @dashevo/dapi-grpc test:unit + yarn workspace @dashevo/dapi-grpc exec python3 -m unittest discover -s tests/codegen -v + - name: Verify published client contents + run: | + mkdir -p npm-packages + yarn workspace @dashevo/dapi-grpc pack --out "$GITHUB_WORKSPACE/npm-packages/dapi-grpc.tgz" + python3 packages/dapi-grpc/scripts/check-packed-clients.py npm-packages diff --git a/.github/workflows/tests-build-js.yml b/.github/workflows/tests-build-js.yml index 992ab762929..05e7e642362 100644 --- a/.github/workflows/tests-build-js.yml +++ b/.github/workflows/tests-build-js.yml @@ -5,8 +5,8 @@ jobs: build-js: name: Build JS runs-on: ubuntu-24.04 - # A cold build can finish near 15 minutes; leave time for artifact upload. - timeout-minutes: 20 + # Allow for a cold native compiler build and the final artifact upload. + timeout-minutes: 25 steps: - uses: softwareforgood/check-artifact-v4-existence@v0 id: check-artifact @@ -19,42 +19,16 @@ jobs: fetch-depth: 0 if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - name: Check DockerHub credentials - id: check-dockerhub - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - run: | - if [ -n "$DOCKERHUB_USERNAME" ]; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - - - name: Login to DockerHub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - if: ${{ steps.check-artifact.outputs.exists != 'true' && steps.check-dockerhub.outputs.available == 'true' }} - - - name: Cache protoc Docker image - id: cache-protoc + - name: Cache native client generators uses: actions/cache@v5 with: - path: /tmp/protoc-image.tar - key: docker-rvolosatovs-protoc-4.0.0 + path: ~/.cache/dash/client-codegen + key: client-codegen/${{ runner.os }}/${{ runner.arch }}/${{ hashFiles('packages/dapi-grpc/codegen.json') }} if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - name: Load protoc image from cache - run: docker load -i /tmp/protoc-image.tar - if: ${{ steps.check-artifact.outputs.exists != 'true' && steps.cache-protoc.outputs.cache-hit == 'true' }} - - - name: Pull and cache protoc Docker image - run: | - docker pull rvolosatovs/protoc:4.0.0 - docker save rvolosatovs/protoc:4.0.0 -o /tmp/protoc-image.tar - if: ${{ steps.check-artifact.outputs.exists != 'true' && steps.cache-protoc.outputs.cache-hit != 'true' }} + - name: Install pinned native client generators + run: python3 packages/dapi-grpc/scripts/setup-codegen.py --install + if: ${{ steps.check-artifact.outputs.exists != 'true' }} - name: Setup Node.JS uses: ./.github/actions/nodejs diff --git a/.pnp.cjs b/.pnp.cjs index ddb8eee1876..c1f40457d98 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -2664,7 +2664,8 @@ const RAW_RUNTIME_STATE = ["mocha", "npm:11.1.0"],\ ["mocha-sinon", "virtual:595d7482cc8ddf98ee6aef33fc48b46393554ab5f17f851ef62e6e39315e53666c3e66226b978689aa0bc7f1e83a03081511a21db1c381362fe67614887077f9#npm:2.1.2"],\ ["sinon", "npm:18.0.1"],\ - ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"],\ + ["ts-protoc-gen", "npm:0.15.0"]\ ],\ "linkType": "SOFT"\ }]\ @@ -12883,6 +12884,13 @@ const RAW_RUNTIME_STATE = ["google-protobuf", "npm:3.19.1"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:3.21.4", {\ + "packageLocation": "./.yarn/cache/google-protobuf-npm-3.21.4-48c47540d3-0d87fe8ef2.zip/node_modules/google-protobuf/",\ + "packageDependencies": [\ + ["google-protobuf", "npm:3.21.4"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["gopd", [\ @@ -21667,6 +21675,16 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["ts-protoc-gen", [\ + ["npm:0.15.0", {\ + "packageLocation": "./.yarn/cache/ts-protoc-gen-npm-0.15.0-4bb1076a19-de1d526b47.zip/node_modules/ts-protoc-gen/",\ + "packageDependencies": [\ + ["google-protobuf", "npm:3.21.4"],\ + ["ts-protoc-gen", "npm:0.15.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["tsconfck", [\ ["npm:3.0.0", {\ "packageLocation": "./.yarn/cache/tsconfck-npm-3.0.0-f54c83f135-25789acde6.zip/node_modules/tsconfck/",\ diff --git a/.yarn/cache/google-protobuf-npm-3.21.4-48c47540d3-0d87fe8ef2.zip b/.yarn/cache/google-protobuf-npm-3.21.4-48c47540d3-0d87fe8ef2.zip new file mode 100644 index 0000000000000000000000000000000000000000..e65708da3991b8bf681ec1376dde71afe595a9a2 GIT binary patch literal 85906 zcmbTcV~j6A^CmjBZQHhO+qR7}wr$(CZJ)7!WAlu;?|<=Sle_os?NqutovJ6@A1ZkY zO0u9}Xh8q9gzDFz{`=v7Zt(xn4vuE#hW3tT?sn#`j7tBHruhH6simW%rJXswlZ&I9 zqlvr4|G|~)|IPJ(K>Z&O=>I<={BMr|0fPGPU)EI(b)A3)0zx4I0^<0;g)(;VHgqy! zuyO6vws$@1g#WoU9Ma>p^uC(RXkG4#%DFnVXO;F$kvqKhpPfRncO^}!mKwXV_}+&N zEG64^IehZTKx~pv0)pc6f)eQ?SgxKl^&cFUiBsL1aWP+M#Cj&-M!b-$dA-$-=`+2P zBssZ^I#Uc@unL@!CNm{~S5+8rPcr9T&*oPQTN8?>K`UgJaUG7qXz2KV0N}iQz zQ56|xY?1G*ac(v1-bn-YGx8Rdtf+@mRBF&T!|PF8j;)oPAwqg_9E}{ap_&TY8d-&R zatgnj6^6lak7B z-AoW%%lFuLy_1Et6rr$LYn>F()&;CMt~9p`{c~Gk!=I>`DiImSGQn`$VC5P^>oS9& zyKNDtj4fe%aC4^j$j8EGJi^_x!a0JP=EPa)0vSt{P=5VRwNUbY5L`9OM+QXXkym65ASVt( zzLzdRR=6SofZtlqOLS#7Ly=oc(SWqh1D*83ZkRLwVf98Xfrcg`<7gC<^1$X7=b0C` z5aGylqQy{xlO9DygK%wTi{>u!oiqhEvM{PPMuG>Wy-Z%cr9H!N#b75fQ`f z#TtraCm;yk&KDa_xrHDO%wz)(i&bO=#l^>fa6uSXT2W1fp?%{2wdj;18bDq?pvL? za;0||(?6n(Ja6$b$V3_Ms0&%0L+g-{sYF61tnh}g@-~Cv=*R`CR7~9FI-TeNX}4-I zm7$ERXNK-1NdojONKluI^JRADZtT|R(r=ZY3&;y0=ORvaI`T-6|0ia_l14H8+_Vxc zGA&*xGyJomX0QPg@5`zjr~GbEY&T>8Gndh} z=yg}zzt7G#56Fu%2>HHX&O?9G&IZ1mgkiG8ae#5@BcLTCU?xP*H7L+e0!QD3T7V!w za2l>?<(^_EfUS@G+h9}%O>A2PK0`0o2!`XHRAThw3d`(KPysp9D|i|{c>%lPQ^C;( znz(2__$K(Zcp*ZZhN1Lt7;FAJc7TAy@9T8{9Dgwg$V5vXB96+ulK+d<#tlYR3a@xl zAi8o9E(JOz1}3dSiuT3muN{?H>cR~(E>wiS6T-c|f>D-4c}uAL*44w#J#Y0crcyDH zyJ_eRCzQQPl@|1^MQ$%jW%i`0tt-7rX(tSFE(LVy2(gi|g{f)A&fm_QUC$KcbdOVs zlW+SCWNtRgA3CFf4XzQ2T=Ua2nF>i2+c7!D5>*}3mk^v2QUYMw@KL82d{lzr(tCbX7EXn z#zA}p#+n7C;^AEnUkdQ2&+$+Z7434!0eREHfJ*h}OQK6B)j>_FnhC2M}@f zYqpTf{;0hE#m?P#3v*f;h-vy3O*q;fRZ=?MV5Z&^96o=&SP6|8*m_-x#C5z#kX*z% z@IbM4G4W@32SqWBZ;IrmzZec70dj4PL+`R|rsQcwm6s5i>T9l8M0lAR>>&$kFa#J~LzMnV>5NENnUu3bE=y9s)O zB(pherds>A%j}1WaZ~R#Vc}*()p#()~6n{-zj(Pzn<)@B5m6zi*G~Qt=c4IgBOgKnx1KUZLj(oooYaS3GTXA>8gxl7E zx4XkV<-n2TcYqDU^4T)ig~tKSy%*2Obl&_emnQw>Fh7zlM)Etr_t)i#+J5ET;2()^ zin>1@TO(>i5%=z!;N&E#Ca$rC&+H&jsaM^KZR0gAZ(e=8ridoY zp=oK(JFNF_tRw9}wWH{+o?Ekc;m1r9oFUi3zcmj^S*)vjNs$q>%MPYCUT%_dAfV&k z^|UwajM`eQ|33eq=H!0+(nTNq+)e~N6f z7+39lvmfZ!iGuh*Pk*90t4}TnT4%o3K@AKNyfZ%yv`M;>4zqKGWJ^~O>qVxYBYUpJBKd9l2zP2i^G>ZS3CRz(_$T8C2 zOwql-p{oO|h`x4bAOb--Ii$K<6q}yavKyPAHmzSj)~oZ+74SzULys5h?cWdRIhrsTT;kGIz zXG#yOgb5M^C%HR__Vf0yr@=p-cD`;hd^{%k3((Jer^u%$zFaPL%P&!a*vc%YD~QvdIIE*j8o9K@j@t!L^hq#^}Cc1UF>|g`aw@S%)BUBOb zIQptz`?}w{(;_c;!V%lIb{{}v{y}01{Ejy0>cq$8!Rp|t*}wBhyEy%r7yAqy-Q%+Y z(gKdD*Sn@L&vx6Uo?7w8logOMfecw2;0gu7EB8MH{X2v`6xo?<*~bK_hY9={Z$uBo zSsdHt2VC%;*P$x`ns=P-BzF$%xP@&(_6U7zTfON9{`M^E75iUTBCDRsMTt5)Kf_A` zZw5Kf{YuM@Baka?I41HnxqhSFORq|n>|v_1HZi4Z7wk{C5X&cK8>R}Tvc0S6vRgjV zpB+hkt0Yzt>FpkIt9Mm`Mr(Xv*N*Ku1`U7P*@qCdmCcNbWC{{BPiSCj&ZsnIr;pL+s8IS-$8|E7p687`+^okBwFckR6$g`PFoP_X2Ab2=L(Vm9; z%1VN{I>j2v>w}O1h;UZ@e9_9`2NoAb-DpE(#p1q=lFo7BeBTRoEYRpd*+_GVXct`qhn?ZOQ~nFI3@K}NCL|Hb0*Phj_Cg6%U;HUw0TU~^4f zn7)`Wlq!Cy+0B>X2`dp3Fg~QwAxdXqkYz9g?|KY`VS(T6I~wT`jHdjBe+FmqgSh8U zYuXJwp$EA*uPDf_gSWPiFU-@b79UD*g8%D7*uNV%W|cM^EwyY#{tb@73hb=TT@c}D zJI_5ApxduAsT(GwZhk{;K82cq>J4xPg5)doAm2BCDv@ZRMo5@OklSifHx{a(+mnMvTQK|^ zq2VSc2iz@kQ^o%$1gphNsvO}qlPW{~Oh>dk_ZZP&<&L4>)ik54eJkM~n@*r>U<{m- zsreP#^rYxNf-1Y;9{EVs{;WtAb0Fora0b%EupO7Q!!nV8pdw2_;EWf3tejfbKW~{H zDZeoj^S?b^6}=hL{Ge-#qs2%@t0UbJimC5eO|NsOKAi#?AqoBklc$3zAf!5!g2icO z9%tu*-N6E^PV%=JIHj_g7?H+m7IbwNcECNNH>V~Av@u-bTUfx zz<+n?C#v4%kGu*CXp@8yM{E&hQOSw4Azw2Ot6EGjBa*^opH3wz`V)-z%;-#}(l z_FLX~dVWc-sIzI_Rl(U)^)UVTR2yvV-9>`-R=vff_yFE(NGp`@aNa0&aooR(W7_44 z?lP)<+%>yJclA>YBu`u`ZP4VBpJbqwwG5403Hp&_Ia;~V{3B93-SkimGgDA7mYhhv zMtzvPO{Ef;Q)>7U03mC1y z;iEpbU2`uKM;UDDd3*3Ds{41f!e!u@!j9PGxLFI-yTMws5m?a-ymrz6Q_KBWX`Q>1 z-%IRLq>u6{n&IQ^vA2kL)TvYIrGIkCjEg6=(u_j;L~1Il3W|Fh%B?H_J&}rddlbiOu$4+8Zz>`=2YA&SHHleWx-RIUBtw4iRcS zX^oVR|4Gb4XXCN-aw9!#oVBMncZ*0at~iVlf+R z_W6E3LNyd3L3}kSQ1Us8nCmldY4pwF5mZ`fq%If+&nh_c8%tkRu!Se?yj~udAbNr9 zRx`euQ+1rBLrbin!YF0d?xj&{hTID`WaBv3l*K$J3FV>eK8NCcD zu`K^#3xLJCRnf!hYMq9Spj6ScUF=Uu@&%i%2|50G^%Hl?D+1+2aLxp& zW`n!RT~BFpgASQ1sys`3o{2{xIGl-lGq!>L6v||=cyQn;%m{UkB8M9rbf|1m>m|*D z@kGa(~o`*FDSV1_VR9b(p63Rf$Q-WHW z`VX9FsHP0*Yp5p;h$8g_YREo|$Pl@Wf7m$m-R-)+V5lAUT+~OF4;rn|rG9}WDpj#Q zsmd!YJyR>RFW)VzEYG}N=ZFczfbW(p?cab)r` zrCxD-6?~XHIZDQcY^6`Pz3onC_MflAcc_`kG**JJyTW^(sLaA}zd=L|B%m^~Su~C{ z`Cnnimyd@uU?!p&7Ky*iyf8_ge zm_~zD!#o3BzJGZ)X7bNZ92hw9=Iri;EfAizoLnCr&HcM_V$N42NN;)fc-m|^`+YB1 z2>ylpac1p;wJaEUKa+mbeG=ySX3LHrS(@l!`glEjI4~w)z!)NYO&K#`>Fa~~{pQMw zFbwg2W6EK~6y_>eI32RXk-H_lr8t^m(en3(3Yi!?=g_lTjtTsQ6$s$Z@kgkbnX>DC z!|CmVGg!DK1S0$AwzPy+$J+%hF?6={2ZR{L!nKp&D}XcQ{+36JPV#i2#i zdAoJ?!QYcBKX~>WGW-etc4vM2r+64KAYZ=Tvacl={hRy=yp`W0dkYzJe;fYn{^tF1 zk~49EMc~}ng#85^+59J0F@!wOOYp6`i=SY3*IthBu6Z?K@s+cGr2ljG6rym8RjT>s z-g6Q!!|*@xcFWNpQ`wE5;>An+@M1iDaduyF*ACJ-p%FaQ{~)s+vY&C_1`O!Il6Mr1 zfHe$-B?>%fyXg+Of+j1NgIS*N^#n7k#qc|YGi+Sj6fG5_(|k8}d&TM&=hOKM>;|@s z-bw(NeAu-TXg(o0-4YD_H#KL5NAKT-M{lq2MTG}EhGY;fOBo{u=?<=zowFKWD9r=u z!2kxaGMDU#*>bUD=P&3Idf&4Fflz+5$=-P4fYApeH*skjoJ67uw475i`e-n0uRwV%v3-vNUl&|6JUT0>0k5?)JvA-GD#bdh-&R zqK=9HqG^6Fs#(+D=N9GtwIAbzy}!H5;LD!9A1-U_bSIL*64GE{?J=ms(8;4uPO|-d z{5$N~VvC#H2VHT1cX_vNly`J7H+!P94Y_<_XI;}LKI32*{z^I+ZyNKQq}?i*?|JaJ zJX`cPe%%#ZbC_#l{os294(j1;M*3}Y8YowO$afLHf63+L+(0B^e4~g&2$m<1&SjA+ zkR1zMk6-%|F#=ao_cH|%OOee9Y8qL|z1mn>5*t=vT;RjM@ zy~zT+SLF_50N(6b2_s`aL`%d8(hh>8a4)2jsa*LWU&yaSK|El+y-?By1RudLIOqLZ z0Bb0%Sd!|Ai=JRGD_nQ53oUl&KLt0!38YY4z%VxBkRru0;O6`v)oIxc;8GQf5q3vz za0cCAWNgrr;8x;@$wkl)Zh{qH4+B&=1Jo&-4#0eyZb=vjOzQ|aBG&_M5N>2`ZN7!I6xPA>FxbcDh+HfZTowQzrmr%E5{*kTI^ z3{j{M+xLt6NoBy{Ue6a)WeXb&O5^h4NEQY1Cjd+hb!YJ-Cus=o-cfyK5zE? zJ4zWcj@2411HKiA{Q>^2aCMSe5-bS$t%*jyrvNE`2N?rVQhi4NQK|sbx z@Q>j}&$gOcjh2s13j&OC2I{y~6Y-ox$C)N+H+5n~$cf7?q;owYP!=B7YgaN z$t5mgMM2@i*OHe@@`CNl5q=u{KY&wACPQeSZ3^loGEM%sB( znlk9Lt2HsJCR2D|0Hqd-#S%q?Pjz4fjyO`n0{lVcP){U=Sfx-3fzIMEU?*1qtoOf? zL*M7X_jGy``XDEoL2qx8L`(n#f^Xbz98?Vm!-xT(CF}bdWT|hM*W{gA!C;G;KFqHm ze^>mUen`R1Xw0C>V8OirY>8hyg^~cyeh2VG!dIl{#q907ezIKLKjOOOH9`kk@f1LY zRMd``S{T@RPdvgP{^+$INb?znLeY3{FHn&ODD}_Vc0X4j(T|(&s}DU#zrfT+uR_4& z8{ecd=ATD7El_6zW5+_VDFoBXBH0;+?q$}vye*hRgn@ZisLv;2hVtbl@0eGvaT(GI z9qm48tv+7iD>8RcD(7E~HdsO!81a*`HFvSA_daK+!5TD7bb-^J#;nbyXnq^9c#S$J z)kM&>vyo{sAh3naO?HGoGN3WTcYdJV`-!y zc#XROx;Vb7+c7x4tIcImAvwu(&duw#($&Fa%(OxpHb$99Y4nJ4L@T2~`l_n3qUy*+Ne>FD^=5m;MQ zwVZmJzTU=>dU(y}0)yG3+BpsPIgC|P`)BM@yG{+8GAX5}IE>wi`j<*g1)0hGpdTq{ z=yjR0P_Qu}nacjywp2BW_wG_kr2dQ>R47AR@~%xCMWSL|!*8(azo?O1*B{Vo&y+}K zTvwDz&nRj$mo16*C&g?g>ipi^-(h`w6|-bG1OA6TJOL0 zv>5*MrLF2&Z8MqlD%np~TTDiZHq>qMXtB;Gtx?n^rzw$Rv$=3emMPM*SfDZRlqs|k z*Ax?%AoOyWs&ks|fY!xnp~oP}&X`PC%4$U888^kU-gL@Vm!Pm5~nZ=UNa9GmSrpDsfq8R19swpo?S1{DG@hgF4BDy3E zn$Qt#I?d>)&?HIbAlP}5VET>JZlXp1O)*=}Lw7YU^&(g@uLD3dj#{j3B97XWSqdh0R+Oy0m~WjXbVSdyUhy&to_aop2M{Efhi zVPxh=cB`K2t`gNGad!i%L!2up^5i068{%<J2Z8Q4LND)s9e`EJ%Tw8iNFi|Lm`fbw_?zmv}?53$y7`BEQW*tw|N5Qp@VK4z_*W zGd}`L&XjdSCNb-yOuvxKmZJSQ-fv3+^!u~OPy*OtC61T}(YV@Fu}moMNmpb!TQ)g1 zZXBI@GIWK#a{S%m4CbO_aW5l#o~)K+n9~Po`E`VVSR!88^aK*5{`v?=2%Mh{(B(@q zO9amT+g5vspZL974+u95+X^-px9ec>9RzamgJqZZ#@DZR`nPS@ z`?@>Q&i;Xdo$+N|QZ3=uCuafq-2=lmFSZX=GT<5QFJGNaWvz*5pqr|0*=7#gp}I&< z)425MJM!aeXP~EP5_2b_j9;J$T(THHa1=P!6fMdZ%jqCP(50sDOy2g;N9F6{NmEgv zfD$Ruz?(yk=DGU;Y9*t8P!vEr-y8|}!2v=*XG$QyUb!ME2#)_e1!GVDLw|al9@smB z+PyfyGl1XLp;oOs1EW!`r@}Bao4lw!uYnRWvjh@OhvqO=?3p$muOa9lpI-^A^;6gn z3OEKGI~+$&kUuWRPcd5gWxlWyCZ#mCi#JVC>A<*|p$d~NSOS2AcYhF}@Ie?(>M)UN z{5r)b$N)3Qt^so!pGcSWZ`F-Z0^`o*=Mt^V!Xn@R2pM+*=iP6sE@>uju?$VabmK3H zPZ)=kMFFAH$X`rA^pzcKG@}}}!qsTg&EHOKpC@FcO>IzD4|ua%aHFREL)Q2J7i7Qq z{=S5MxGw&mauTY~k4tLEFXT=!QmerqT__V7$qPc0KirZY2Of{yxfHEH9M%|Bxo^wI zHp$@b_D|qVSYx6&GpIZF2SZ}Z0RFK82^WRk>}?0DaP%kxOh>mwU5*SE0w>=M@FplB+;Bca7geCF6&p`UvQON_z>bk;yCgdLlSDry2 zhph7E-r_@A)IL@$YS9OI@r9bY6sJwUW`88G%QMPNVwTt#o1yRmC3CgSKrn%5Bcl}b zbV!+FFCLHnJOR1nnW$sQHavdl3cIEx1JtPCjN;fjvz8=Ab;J_dEyHP2y)l}!hY&*x zk42=U0lKoYC@E6YE5eH3u_9Js8|bM#AVQD10^h~(a@<(jh9B+(xSwz|0>DEcnyk#w zP2lWWtlK$cKeqQs1&k~ObKmEhR#X%-8yUD=E)^{ouZu-pH^I1bP`u$rGHIYYKxaE~ zh=P>oK1Z%*^WI?w6E7MqO{;be?(N;WeRcjfA+>&q{cl93`Sg3R(msARf~8r6EbT_> z4`Rud4#zREEdWlyb8i3oSUal=SKy5H{h>Ho7VJ+xb-odzK~E5jH&$E#_&_q_3JSd& zJR0wt8P5-juVhw?K444Gpp`z9A^Hd(hhf()a_tAc_c2Zr<1bp%F!~}^bpD~|G27hpyMS_b7W5})v$AxpDrAP9JMuG25{Mt2k9&p%Eu7?;qH zgrAS~*aa1~g(_J4*z3a)GTl#F#0QHslO(W2k0VtvtE9f{(T!^x7V?KnW^;*ZDLH(~OFs(c*!TwEj3rWqc5BII>a(@T zX))J9q1|LiaKF#06OYBcGnE$JP~{i*`8z2CZ2?O!D8vcAiI5UHuKEnKi&KkqNR&UQ zJ;qwnL{dw~E`s-|@VHz$5>BmPPDb2}r4?F&|H5`!>)J24N51O@-2X%=PVOmfe@x_h z?sy!PZ;f+ncACd4OEOPP5)Dv6@?HpZfc4fuab0mZg76)F_4jzJ*+-+c$iJM;G!$sNG3Lge$1Fn8Sneg z$O_A?vC7ektqYf^)qjBtNP>GC6p4@!r_W&UHnf;=uq;@)S4w)@iC`oG@<>Du{DTT* zmt%~wHRZtX1qXS^4ypc)<)#S6VO(16s94!i1otqe3a2IkzgTf&j{I}GAt7s0$Coxk z5>dp`UB!^VCY4fpl_F?kDiUiRL!M%j$A~gZ-vev6W`zYmbnI_UR(PB$BPm@ul`|%1 z%`_R^PEP%H6BaB@Q^evgvL`Kq|M_B_OfD^pp7ze~!yQHnTEBl>X@a6A>bUsiT+6Q#T$k{%nA|BA#iZZKq7I^(LM{H6PA=(~zb~vh$)ar~ zGLt7;rnJjJCbTr(7X7atFM2VtZ4`(xn918^2!ox(-iR?-3uAD+LQsphGpp#7k~01k z%^3-8t}A&de#@nWW!fAa2iv+;a7iPL%PkfM82E-OQ>k|)+~Or6^ktvc7Q%E_Z1 zPu*Rt82dts*^r(^q!hR|R_4h>Q<9Mdqh&eHYh^=+nWf0J5)b<-gnP&WCK4Jj%RM>q?s74}vR8>Z>dS!<7$KPZ#s zYHJ@Bs*WH-X0L10-%P&|2~%&Lu3NVB{h{0VtaL{73Z_pP`U$PCZjN<1@{^=J)+o50 z+-bR#+(tUJC*oSuUE=5C{W9!Y4VwqLUCnYNbfAMP!MgfrtFAhuh>F|eMgV?Z@1iT5 z$P7DPh#l`=vzk)>$*WRY?0xd))Y1^&RC97r{Z#WdQrSPH6iDOpw&AAtj)hVs4@x~U zA3=H(w{27S(Uld{REc<0us){;_2vCU0e5-R0MtDx*X#O-Qzf&Br)QCL8?RDHG-Twe z6#X&2%T7dexdNvGNx!UGeJNqlu*b@Op^LN8m0kpGp~z;HpJZ7$nTu)g^jT8HIu$_f z?ja)56{L|drM1087v+zm6w4~~;bvN;_IZ>JwNu7@JqoifU zd}zpi>&~?11zS85lvPWfpQ&#{IPJd*if1NleemuHRNqMi=9Bv|xXy|rn{&oo{iy6-+;c0`tIF!|Gp=A_x$+TNvoq1y6?bwt!xAF@a$&`MD~UHu05mUW3zoEuDhwBdynlw*e>!wasp7CE17?#Ala}D z&lva%f%Y%M3hkB9H(8J3=brb+lv`eZ#f!^dsdjY_Qtb{uCtvdrMPCv614qraPuGg$ z+XXs9Tj~E4cQ|!Q9HvWs*HDmGU#4esY%b;s)pa#hJrz;8uz!qXRqk*e;k;{Fhsd3| zx7**&*tQZ}DKFJRdC-Rv)WBpm)046Wp=Va3V8Wwd(1dAII|dKHO4uOoU*G?-BmlE* zHs=OA;PsG87oiN-m1NPKa-Inon&iQ8HpThq4@glN?!RMU(I*P8^Vns#4wdq?T9Md0 zXc4dcq?;x`dw{vQ;GUT304+OVEoXPaG1k^Jee2QIMyi#l_Zr1i+Hiq2|O!Q@j|uWYg=rR+C>7o&j)Ar_!opI-Tnt*Sq)Etbeg@lUWYTT!i z&?!Z{(G7St7!S1R1nTjx4^Gueor*+*=y5OT4E<@*&uK)xX|YexVjYb_gqKi=-=p5! zw2qLB*9?9*j5<=JUKM@-`A;%fU9sg2Uwb9|LcFQG`^mnxtkBA;l0qx8G1dnZO7WUS zZ!ZyU`I?U!9>|8aY;Ldc5Q9*i5N`~PmOC9=mQMw)*IOuQWnt1gou7nWTB)KLchTgs z#Ak@6Oqea*JPf^9=^6V(6(iu#MrjLanwOPa&*k1#Qpm7Lr{hjuRZjW0 zS)~};O@Y0fn#4@htQM-bK@CjdXGafmE8SPsiL3G-h2{>-M>hE0@twY!N6(T&yiLbv zP1UXkg(zBRdWzRJ=Y6$G3>*AkB>_5Edb(v`fwz=-41$L)T=z5YTnQ9dBM7EZS`qdENu61CF;A) zft`)mn)*#FCjV51esNZ0EocY5jcdL!VMG=fJ5Mk7g&URd@8p{0gUBz zA)`LXdW==#>>4XkZc5G+7l;L8VtTo_pZr!M5|zF8XsK588-#z(_yCS!Iq|mGtjXUX zUU~dQTh%G(CrYHnv&P@!EgtJvHuYR?Wer@)TP}Fek`4&_95ao{7!P^z2feaw)*#7< zJtFMzcUC;o5tdRM)NO1*PHVU_>Z49`cOC)Ajtv~OL4S&&{_RCk!IrylFB43#xJ{vp z-4UZNWPjwvqHn|vc<_FzVLex-Os~v4`0!taE@>?1tRUM7w5fX5%u^`_xblp2FY6Zd zQBADnw)@!PYB$N0BQ+~cwz^HGt5x_o&xZRN*z7gnEKk7^3lwL<`gYJ}PjyMNme`Id zqw5{rb(E0w)j{$ds2rmd{#^NRad+J4LDO_~Y?+C6<-gbUbsN_V%{5)x+GI~^a*>0a zVcx>9Dg6s%mK^w*vOH4YvdwDQ-qiWx$GxPoj^t7r!5x+ zzErYz6#FHVsQe?ke>4_Wc5U@uZEwxxy*dp9p@sB58ohTJlzA`B+B6^T&yB8Z4Fh{G zAn$Di$%)P|@fz=(9_p4EUjb*@hLY*}J^h&sUxkPD*&BN$w+t^cH9 zs{kUjda%TL5~8GL1-*{jp>yoOrDc3fl$M&QUL<^VznmTEY9=-@;UntPjw@|eh^Q|c zkA9rtQT^r7og;Z!H~dkf6gT)*H_NEPY07hDnd*FCs$q z@&SstTBXG@y5qSGWO?Ov(OIV-sGhkxSMCbl1Mbv^7oSNkd1C%|hlYkfu-cL$sE+2k z_|!7>+bmt-jTXdbdix?1l}6~ZG>7zmW&y-`y_q2fcAOMp~=vrAxzctuGDJ>P(kQ9^oTHXG#+?*bZH0q6?v%hh%#$;wbnDz^x7&sOvayI zW|ktuHSmeBaZ;_m0c$ii@G7g=DA=_zs52M~DvBaB&RpO$@5V+bRRa#EN;}-g3xX=IaFqN@FwO6X&22*MDHk zLkyY$VEHZJvu5ZFaUweKO=kUDYl2#r$psS&r+h;()ElSZ2H83F^@y;HxKr|8v#TJfSLbI17r7%)zU@fnPH__4On<)e_RcL2+Dj5=1=@ZQ3312%%G`ub? z#2aPWYKBp?R(W)cpD%aMO}!`$OH2*Lw$tc~H0HNa=~3CsI}z+_f|ERcfDN>dWuL`m z#YuPLi_S#)7wfHx3^7`&aKelRe1U}?^aJfovu9J0J($CIkGBw6KQko;tSQ^oyHdM0 z8*H=GpGxbCb+pG=V6**5VS6Clr%+PP#Hn4FuiMwaC$il;5oiKnshn>!pCio(exZAn zsoKM0`)#$;Ye8{m1=Ydii;TL@`o(mE`0+AI5Es*=n{D@R>LWJbWt;!it2%SZ-kB!b z=fHPh4T!&TPPOx9*I72nW?BE!{xYao^5^Jcm6_b|)Byx2?9i|{p*GGNb@K8$ffvOl1vV$cW?Z&G9W=XRi;jbEuzlqP4 z<}@iN##!taR!Oul-c%RutY_4uDpBs&6wE`8jI_KqC_wl&?M4c4^KkQaZpDDpd;s2_ zs@FGT)P0_Ye4nHte`R{wFlo!Yl8yr45e__kjwSDPzn@ApEiEa1A1=gy`+K{&1pHbs zT^*mMa$;Iu&wL|eiWT?Z85j}wgd$lJ4Zq#kJHZNCp^y#?=HM=eE`s2oqDAVi*Yc^HW1LLsR$HcrV%lT*7Cl0ba~pgn?f7#5~Ez zBftIue}Dz;4uF=_x2NQEcB}2D=s!3-X3*3scinU<=lU~gD0rj{QDu8PV8vh6sZriL zeWR^K!&H1Lq?m$kROCJ>rE@6wIq1{}^h)gPLDiXR?*CUSZ zkPGipx)%mPmBI6{*dz5NDtVUFcQHiv3TjjfwLK?^7NE8GMx%-&IbO#QG+t$`)OF)9 z-Nvpw%fe!DieB2Vam(`HyzC5QrFgju-<=wy?ak5YysaLWqBXJk3%-7#${%sMDJt`& zn5n1um$+Qunbmymo3t8Tm@N%ANEC<3R;1$hT4T~=DDdmS@rD+|B^xI<|Mg@n_Ciq; zHrZ-O&U`#CqOAU|j;Th`m7)TqbY32bM%fw8QRAoSjOvpZ?K8gSU(k6r65TyHg!L}l znVd&mI;@JT2a%G!eUepnZz!2`*H~#AldC?`*>mjVtdcFaS9&>=$Z({I!_5^slw7LN zm_uQI2tZd6u()|?ZsCI|vWOH6OOWx}(1$h>>Sk}bSuSIUa8WU2Lm6M$gY$Olhc+7G zJ|n9nBID}jSF|gm7Ud=TK%qOXlV4}BA;~=c^fn)&Y>0mtOIGcB=2|M`0K&J4xaZv`Zi4A~3;42M`W_K2#3hoDhbW0TgrnM@zq~1#FNgk z8MBMgMjYH#(dx>n===JiI$_waV}z`XGyIU($~c3L6*Qe3K~8TKi#dTMEk9OsgwDLG z_j3l-shwn-&@vb0Vaf)JopP;}%f!BF)YPWZsl*Y})Zl##)U?SS(P^qvja00Z$rz#X3u)!mOkIKP4SI1UfE9 zQfx~yF@^(8AlB8*YrLL)^AsydqG_Nj8#&t`1B0 q2f<_{JYPaJ?PWi`-bBW-$F zA-_zLqjVN8IZ;jCi$3ng&As7INwR*>AggX;y zNnbhC;!%l+M}MbUV&$QQFV$s_YO9;$c7JnbMATA?<~~W&Hfn*y*PJbqPeik}VpyS( z=3ekNqH5nB-}0Umj@B5{s5c-OGo(?a{^vzjifz3Yx~y$VZa_*NYI2RtLkGdv!63 zLRq!i?|cEwm+}PHLvU{-bJmSccn^GDRwUfZs%AK+FiCGakq5yQ z5#M7`a-DxE<(|>H%39Ng6b?_VVl5gozD0i-DkT#&%rgXPdN>?K!7Ozp?yj(}0y?m< zzdY~=yGQ*&ii$(l=m5&Zy!7r~YBBMx+%W>n4q2chlVJ%?CAuTjAU95X8UkXfgNEE4 zSKV1~e>Pfm7?m0}Hjs3zGTHdraOObbhXA)E+G&UJ>}?5zN_%?vX&7zEWD^FU7BM_< zBLf!V7C%)lFfL9CK|jwIemM?)l)qLi(Q}X*`gCYED|-_5I&Yu* z!^{_}YUx2r&&oBeN}Dk|Nh%d+F4j(a0e8Px`tULhidUDMa^FF7dxCS+jgjJhOK&b4 zygOnOi{c<;dxJBwjTLl7FO4d@vsF@Bo_C$itwd^>%~8CB6WgP-Q#u9LZ$0KgrqMN} z^e}OgsYlf~!h(@dx%ztn^xIx5LfnXToAvE+mcZ@BJ1@ry_b8lMIYvH~`Xe(;r0RJQ zLlLnERWwr~?_N6RUDvF)j{Y%6qeMx$v8x_hIA^yuX?Nxc?IUM_mit@fnJYr3K?SeN zq$x3++6CM7wAZ2K;ghlHz~dYA@6$=yv$TtOrwG;cCUYrI@g2~5x-;dRcM_>>V0UPe)dZ#fk3KfaU`UD$r);&(*9BA zum$fH)R?k=+so+$yK;uIf?P#8QAbtOkt0S+I6JLb6=!4>_J|4c!XZZO(UBVJK#Yd4 zgYQt+)6~HL?ER}Grh34*rkKAAiAbJ`8rrwXP?KG`)hfXQm5Q(KuuqgxH|L4g5j}Y6 z3v2XXBTazl;jcSj8=~ogDTbjaW-e!^wS65ed+XozUkTyV4${H8_QoB=nq_UkFGigO zeZW@M7uQOF5<@kVh*Zl4%8>#OOM!XNs{DDLI01ASf2fhXl6I7hlhBH&_%X@030S9^ zv{u0!3iC@KYdC*6=-Lb}jMujPflJj{^$N_F^XmLD*d$fk%d!kD^#-?(MopVAY`E1_ z)pPYo7Wp|M32~$@=iUcz>WV0W^S)LSHz5cdsHC|AS?{<-*?* z@yxyizm%@j&2~Rr*}9?gD#)^?9vBhf#Sw>5&h%O=X|r9r@0gOPSs#^xr-`S12MiFj}E+L{S|`lB8D>8A)@5$6#+6EaQnCo-!}JL{{%_B^+bB zoIoa=W}wc?l)e6@c;90-bKE&i)M=~!dB(t^W1QGlM4W4+%2zH;NIWb^WDZBf4_(2! zTEZSet$@9o%A8C%3?5*5&x=Pmnnf{Vv_i>sBAlG6v!S$2$yq0{NRd9l3?fElC>t&R zBZh}oSfYV(lPLCt$tPUpc=Ogul1ypGx8;s}koRP!5H=fVlwf6K>>-Q520kvtWmmw$ z%y&4b#AVkO;}jWq9s?USJ$2MIK|#Q}Vik1U5e1HlcBD9P>YDPRWL4p>4b@zmq)2gn zoFf!OXsDBw@_wUag{(jB=ty6og>{xGy;Lh$c3^ z)Db|^HV(hm$Pjc-cz1&%=Pgkju@eaQ&St(9U4DY525MB+_sYM7e}dEst%H$C7Ia7w zVwLD25^hlcEGVa!>nGf($Q6TvEFhojK8t9kXb>CUNfyW07YcWtDpb`G#YZFv<6?aG zxVpJr!s)q4K3pg!I{Z)*B4X54iE)OZ%$^ywNf@*=NO2=b_g=lddrD@9oCzEe4%G`^BK;vC|(w z7g3*w!mO|({eCF1|2%&`n2I59sPYA&=?32ll;fCh_V8bz#`^X`%b8uSUrT?}hm>*P zi5+D|D>THj-h06uWYvWtp6*8-YjUk?u2zUH@YJ}rj=OWG3uf^8yK13lvWBP7p2p2r z_N-(4{i zS6L9OTS%?|p`Rd1V=rnn5otR8yxm7N%DZ+Jme}^AJ~`kiilB&{DH))Gek_o<3x-RP zujbzb`JlcOZVZ8f zFQ?#Y)P8MVVG>Nt?>h7`yj#X!?OLyJ?Yoa#Nb z+O_1^=6O^bKby&njm7d&n0US7p!X%*%CFz}v~~57=G8QAyKUe2X7o6kr1-(MapfV$ z8LpqmoMu%ib}u^gy4+0A02f@M1xH8&)AK?0r#MytwE_&XdfFW zn)gulo+p(nlI%YEE|*f`j4IWLZ!-iMjrrIZH5tfnK@{5009s@KEKmZ)_d*i*u<%Fc z&LR1`q3*}z?m|rJoZ@UVqtuuJa@#1@Z(gASghNuXe1ZZr8Ucv|UX(7gBG$Hg@o&fg z^Kab13UryRAm^XrCH7(x`@F_qWeObVR=8k^#yex({9#b4!+pisJWm%`aDbOsg-NW{ z6pEn+vs+Lo+LX7h2{(d+>dRb_jD&hA42{+i8X{ zI0Tf$Z*HK!f=8Ozh6(KmJ0zZgW!-Y1^fSijAz;|L*MfOxo=Y0dxsWJ2xRjVoc&07g@if<*5pxkyfkid` z@1Qhnd;yKQkdCxMg*rP-k#xnM@(gSse%^sxtys|1j3kx%y;e$scEwI}*8R$SfXjwZ z7%w@Hbs&mP#I#WUvDcA(j1wF+#VX14q(uR;>%8J?S!GJ&L12?@V5{Uv*$PJ^7{wl-kIyfwJw_W5i{6@lLe!I)% zAaE?t$^*77%mGl~5_8Y|faXR5kUzDBuNSVNpV8YKF^kzBh1#QdpZ~h-R=vJdCyc51 zZ7^E!#yeh*w95%q{ZC1t&$|$cTCSU6 z7PUI{=K7mAnrcB$^GM4P!^Vqhf}ssy0T$sy;G9kD$$);ztC%c6m1YO}hqT zWWmMH&DvCMN}OI89e5B0bm)!MO;sQNPzAsbdlZ%YAq5JVv^u%&?jC&&X|C$`kB6gq zK__9!Cd*k_61@0=tC1`vd}L3YNK3*$SBMy82ypm?qJZ*@UNhW{%9mkMI|&r?w3 z`4n-ePQVp-JuqhY2ZW^Mb4cq-$>Z(Qrb{JU7dn8TjknSS6}IFekjA@u0VwkPN(59V z7YaO{m@~ZU;>gP80asK|#!KaBO|e}w!H8CH%E<&bA5`HuS1cj*h7mxNVzfi6z<^7l zL1?_o%7AnH8NIo#STi8;R|zWdFoA*Eb|&$106Yv=%G9^2_j@MfpL^mg{lU56t8)mV zU%DF>oyo(6bR{{ZtBz-Tq8k>fvQXExMLDH(jOK3W*UT)$cX%#r#3BuciYQL7C{aig zv8#j>#6hLzu9Re%7t#bmTkwc1!(nL4v4!P>47loR7dL2jlbriAN zmJvmh2~&5^{7^0P`5|(g=PXv{$izc7duJ!13(2qlVpd0wWE*q#bzI>70rgjF7J9PkMm(I=VzWtx3Y1dQA5A!|y(%qrYu;aA7e;^ACU#>6w^9&_QPwlAq6K@^-Zde|h z8XqrqjvJ3aS8o3GC5TO=NEAk)jE%zV{7O!d4AR`8!bWxyYi4OoD{x&s{s2f=k-2Bk zjr)Oh{hc#dySXP%re07(d_rQ@G&~{fa3(ZOuG*FU2?OiHo9`2SI_;@ma+w*l7_=Ee zPH)G(g2MhtBwQ95cBp7y-(N;Wh=0e zOv3TDh4EeUg+kDCxj@Kf@yq8>Kwwi~1flqBhomS0gpoT37JXa{z%V5uNuOO%7icYHtb#g98%f0Nq#{tdYC){YKn~Sp zGhBsO`A%r`nxV++^NelPk_+{6VZ*AWieQ-5)4HXKyfHPVb)y%e+@y-8MOKR-5l+*F zhKNmR_c{dgy$3pV#FvD20!H&p!muw9-3Sv}oc`sAu{s2lB*aIih`l>%kYz}ZzuOd} zfEkAj5qtCr*bFTRorLluVzWAxLgi1AnGyU-J!u&!#rV5%4lo}b*q-g9k1lh>YP;I~ z+5zg#3utNgIpT+Nx^`2tbC*B1nic{UCpa#y4SoOFyjsU>ZAvu&%?Ii|)Tz`xAg4aj zjw3Uuj_x3k?L-m1-9l9ACNdSKj;KSdo({$89PC{$;_?h4g3ynqyo{=>7~$F>q8ZnU z?1}B(mp$_6_99bsBvUjn^dV9-wxjM6v!qpR`K_ip6*0_E?^3lD%q2S8DiK{J+IG#n zh6)3fyzTn~%9};)iWMO4!MkD8s@=LmB<6G;Rk_h*77%ks*K+Qk04@MQr!3fCOQPB4&5GQCTitjcd#5IU-)P;gfp zOwDP>4aPY{3Lmyx3$juCIZb0J5-=9z4 zU1~wlWLip{I0NyBVKpAZonW)FYm|epU`5;1v-up ztyMu$Y(Z7~%W|i{P@G?MVq;{G_HScBet`URL?CdoyZr3ucHP+8x_JG4&5u%WvH;M| zXS1`i;AUc8_o&fp&^U{_-U7vZV85pq2W36&LGV9gQv)OLWAVOR9__V1moV0SQI3R{ z+C!YH_Rk%mOV34jOG2_xGC2|Ei4o!oO_UK4IC%7vO2QWU4J0kyM0O1{ zbVYHGSw8@3@^EVfpy++FcykTP48pDOQ#Qdx4-q5U&6gA5tC&+5&)JER*e{*fNGDaJ1m`mfJ zf>k_riZVA$=I85fY>yR)DydTMQ`ls7m+XPQNt@{xY<|(IN}f1{S<(e0r$V}a{axcF zyOb6F$p?HtY2>Ptp1)9baWuXzN;MnrA~S}wLMtZ=s{-PxV=S)+>vZ0D+wUswT=!|c zbtBBL2iY9D8H5G0KTxdTVTa%{Dj8z> z+e0QhCRlFTC?Cp%gp4v<1{O9QR@Y=f}+uK z_}7{M=uCNtSJV3t)Z)3?l98Ak%$dsmWU0QUz;6p zqOCD7hIia`ys2>FjHv@jY0`#tb@sM>0zWFuP9^XwDQo4cgP4P z{Lw{gMkrbm5i6xVFnlOS?`_lyoWBz5<6EY&%(W~)ZO(+_xEHT?WnMrvb>ue>!g>C3 z;aD@oj_63&`f?^lIH(TruWcj1I>v%88}9=D8_+AW0gKKZ)pG2OQe~tur8KaM-id6G zgwvG=Zzs6nOh4zNTVzUp4}xAVBDZT@m}@7>`0l#DU@zGoi-bPCTWM5j^90eP$Qq`F;*z7q?Hr=25VYB7;!AIXmltqJ?YVW}S*3I@_4dtsi>xdhaej8>1CVO|VZzmF>k zHQ-rVTFmBhki0{=Nd-kI#q+y^yFEM(t2E#+ESzdxwv@4+I}SE&qJx$&37{YQCG&5IU`_ z-HvFCts<51lh$Aa^FAIo-D{p+RrTl~v@(+6MXa{bIzRepxoOK=T-xHB9iN9g*eScG z`4v#$Jrvj_4R|M{k!j8)Q7Q3~8_-4z$1KF7O|5tzYR<3px(X6j95Lwz)TA7iBY8xX zS^wbTLJ_wo@l1IIqA}Z620w89?jIi+^BMS&EGhBbW$l=jvB5p*ntU$F0pgufUJJFj z46w=KktqiSwIgmVQW-0v(cx<(k;iSsV$rp?4lO^a9noKGbmQ%QFHwM2Iwzr|Ix+kz zd=yrWj+^|Int8-!E;XzOnnpoT3MHTny3a3kmj-@hcU}qeWBGH2Fq)8Vk*GSt5)sWt z;gbkc{3KpAPLFVrn))|mIGq?$7Vgs;qv}sR4~AUDqrTC<1`M-W1c%+?N5g3F+&)pv zzjdRyaQ!kG_BwJYdN4t~=kFf5a#9&4Fm0P0<0zP^97F0J>I+WPIE7Cf!+T5TExk@l zLORfn`|cG_>f9djwPs8+#YHjf2cB={Ftq9SG=Vn5?=)H7K%0_B9Ok{rR*M@9f(8@< zZ&qki7RWxwO{_KypecU8#P7dW9Z_9ZLR}>`$3VVFGd>w>`De`K3-;K*Sue=pgaX?I zMULCJyoJ@8BhsTmzihMbON94`*Q9x?mn}Ffim!lt={p`ZZKWI{cxJmDYU`RP>?xBwYzX7ZiH0YDFU=r zSVc3-Nr9p{jicc*$y`?!6Qi#)r3xp^!|TE8?`qD@LCS^_870lCiMDsyNHZIFhWD=x zjQ9;IUx>#iZT1c=A1Ix`10(R&aD!L?e2%9jMEz1T5^3^S>YGSqCfd_`L@uqNidC)Q zvm%MMspMV2BZpGqkuap9PK5~mo9etqp|FMr63|l%=F}%QB-~c32h#LKFylLA0h|dZ zgh{6gsSCLhP4Yy6KJFy7z;>MpIWk;S1yP0z@#3mD=}evo2;=?pC3~qY4sucMn1S>P zCbkHJn44SEF5C@i5vZ9k{mwVPQ7i)vNA-sIA3W3SW`+J$c1Sz1L%M&uj5o15v&F2g z3HJ&(D>)D=+yNSNASrS90wDOxB?0jFoHpdBa` zn2bky3TDGtyANWZlVX)uD&1Op4B1i-=*?41xY1bam>qp1?#{pRhUc_x4&GJqis zIHA5719F})0rR(L*qzdHaI>Qy%-B7ZWphhJx6dmm?T!JFU{N)gMteJ0YtA>8&T-s9 z1$l}6vmQ*{VNu{>lrzEtuD)@RF1Ok{mG;dprTr?wI=8VwwG!D|?R~0}c`s7$p{{W| zhA&Wd+E7H%A%}y`@i;UmqI7OGi2N+ zIWA z@32mdciW(*dt6F1I_IjMXKe=?;;KYKQoH1IUlDj{Sj%6-oR>NhnkEpi6sPeujHUs6!rwyl)awpwi zTWv!=$neIaMFCfX* zvfL$0aL8(M_C4OL#Ek$Ka@@p)f7Fga(m`d?xwKY*MbK+wOs)JSI;N7}FOrb`so zzE^Bf5>`!C9*~t8V5y(fz)>n?04Mo7Gb<$VD-CAVmaC>y)-*2Dt;re{JFu!#6xl)T ztqmn(@~6!QDTBJ1<%KFiC1(k zof8P}c&eve#pvU^swW?HKt;)=BpN_0u8|Qlv z(6)RTJf}FmbB*%FkwD9l03PJ!)_~&DkESVHW43qqfyZWRIxI*3?Eh;8r^5kI}EY5Hm7in^6(`%zg1?22MZEJ z%bm!SM%774@+0CybL<=zaat5DKsrU3sJ&*%GVIoBrCG(He~km078k4u^E%&28=a=@xb>_&#Z>J^k6G)-q49nu zM@^<7y7hLtDT@)t1IPE~IhZ51v08w{X}2xMO+bff`I#nRD!TuK?tPRJn1%15>-UU> zW26h7B_)hE?fa0BS>VPbvTClTO*1N18poBSWC|sqX|nFR@FNDXtj6A*{F{Zr|tb~9B^t0T%uWoZq)V> z#F#hPDR)m-?gOgV7R&pxN-uX4uafRXf1h_eJ&iOX$JN@b0j)JG#B3I8*eD7Thr^Z~ zHKTpA&+p34`wX>@RQJXcJCIg(ib5_0es#1O4;>W)tL`^!v3gpwEbDx86!%gENbIA2 z&*T=fe!n3w?m5FHka~2&{8{#{+ayRChzi%K0yd@pRG~0Sz*FfJENwycey65!WmlJF z!&9eHj4Vl+fKC+0=>Ab`+T$!{ghVPSm0TAQjCr^~Mri;#hVo=rIEIq3Z2>|$EuV95 z#3*f9(a&1(63Vb6!-Y{1P+(}F!DX|36 zyy=d&LZ2*l?`DQqGg25Pq>Q6Sh@ugYWD{(oBTErye`2MSY@wG()#&G@tnw^Qt+Fgm zRt`xc!Ti( z_uZh)<#Xl&=MJgOACv1k8;dI{(uRO6T=y*ax{Q|!RW*TugjDqcEaYN1GZJ1kp8BBY z7G0+oP;3Wu-egSCt+a~jCQ4TJJjYQ+y-zvmJ{nP5(k9WEfj>t@6qfVmT1L||nTen4 zCwgZQia^r0AWBj9O_HP#^zE8CPLn;P75D8*Nzg+Scsgp+vUyiIfq{hOPSj&cCV#)4 zJYu7j6#kVEX*<<+Zoq=Ds+d+0I}vFI7k2L=6#V-qCim;5lBSRH>W{!bJNEKuXyQh$ zGp9we9BLnkQo*&7uaIhDa}0!0im;>(%yVM%Bjc7?PoRppJ*b>JkW$be;@u!HA_>n^ zy9PP1Bg|QVE|6a-!@+fCGr$+<*(UP5ugUKFAL|OmZ7*a^U4OO!A{787-OO4b!m> zsnlvFnsK5oCEPgbJUmaJE+v})6=P^fQ0_zxS`0a z=CR{xxO$wV%Wz0)uA+iN1#p=)jO(-!YfqHpxheDuisbPWh7#06-uH0T(%P!irp(tB z^l3Kgs`NOVg>uX2m3;X)3*2tEoT8QE&b~Ig06MuZ=v>{LgNc@b~hh*RmxgK&0Ac&u-rcCV|$#+X%(M157 z@T-l#s2^_%S-n)>NsC(eQOjl=5h$A3Re9$edn64avO@Jj>K=;h6;CXU8*!J14w7`T zR3FVdUN#j5sNpQ8P>X@0Zpuk;88}x_*QlFr%3W~b?8Z>LrIByT$8h27S5f<$Tili) z;KNc@!DheJ_2yOjpP=+P=hDiS=E!%84hJUo@4gJ}Axq!k$}M_>{BrVe`gnUKN8fs- zelRP7k_NJX=>gsh?{;tF=)l6f1hDb1t$_3kM-i-p7GG~e)%bE2^>cFIWf8^e~9Lv zslC19*Uj4`qzhwD0D-3$;27MB+$v;(a}F}6zE`EEoh30z3SsCkSik-C3BpI z-h14*yrb?^u)r3rQ*B>sl1ZQ8ZDp@@$eO%>>pr!w&aoK3Z-8>N)q{?hmgW0`dLQc0 zJ+=f1++&e5Fajdmsv<7buq|BqO(_V3Wzilpt2DKCysXU$*kVk&5LiIXos1q^N`2hD z^_X%vON6%%OOidsnxf5pks^?6$rFQ|HAMV4!)HngXlQ7yGi)8TtcL31>a-?c>+z-t zF4_MEIa@=*rT~%svrdplyy8l&rA0}nV9a1gS^W_u43x9;czG!b=ET2Wdh04=WnKk4 z*=vGd??^WS4ke^B0myv!zs7*A>HJsp=z}M&O z;sS8vpIigt!50fWlYcv>TJ6=Q{*lr-z`xXYIfzRItTOvZ=AKOs8{e8b|nq9JuEw@5f8)KsGw?Y z?twkx8Zm(y5``F;af~nK2(L<$bsU7$^wra(*}Y8*qG9&Rdq&xP@Q}`^5~N7+O0-Sk zmk&b-`LDSzkG@)#N~;)EJ*#9!iv||4b5@t@1dnS4!C}cIf@9gOax_TDA0WyH9#>qc zB^czBgdNkY(IMXD2X00FrZ}+G`GLZ|VlJEGNd^A>S!2!)zizR<`Wiy4@7fmiHoQ0z zbTl5@=X_7n0Fzz*o>~q4Ec$W(s)(m{152#rM!jO)4G zcbc+oc9gPhtMhfFLpLf<32vBQoO2ygt94l`OR>bWI1<__@`+&*AJKI>awoN9v@TW0 zNr?~P2~CvB&zlgR3x5InrUgEL`*8v~_^AlVRyBU0?;>8)`plhuC-#Eu8WSg=F?A6ne{KR+k%YXxS4?9(LHuywXKp=;!YVI&R7XI#4oZ zp-ZO8-3-MxbINaMy2mJlxMidXuF-%fx~N%rj{tg5Q=pfTPV8&>?H2nqh;t@2RwYO@ zJ7WY4D*`(gWJi{ml&LvCHH%vt(>j)ETRS3T$h%zy$r@lSA3WRZgO| ze{M=6%1d7i*04h$FCEq2ot9Eg%gm|@!jYuZB>oI+_mhy*zIsYI3OeGB60~=>(smry zrbsRtyci#_T?(oKZkGmCs<7afo)W^d*6w%mnzJ8-UKH70^6G4l@?aCytoUSV%ESyH z`-t+cQRYu9%`s63NM9zp`xEccL~o5p>2`RI6~2#nV3W^^8d*;wYtfUC2t}v!e`iLpYG~_^V74Try^oW)sQ|N z8fFNAG3H$!ApB=C+HL^F6&CB|8H}y?i~D3_XL}L|sPTi4z-l;I=BI2!&PPWbbx=;Qk)9GmjQ}2}nv#2hT0p%F@K{vqP1;q!9ymwhr)F7=KWC zr}VJHfBjIFZr)tlO`5y1o;~0fwx{#i3MasgDcJU35bNzV@ZWcAAW)u?elfpqE77o} z0SCBh?W-;Ie{aW^KF7*sZI#iNB=iGV?UR|f)Txio=CEM5CQlB%H3N_q4K!RXmbShk znC=rdJYiizbXcFfcSqTl(ctnOJ1iMhMmgKo?FP>qyQEyiWU*~dPn`;Go%@`er}oII z1A2vJam%_iEpX}WrjAQx02VAEvY@8+&e3~k zg5h{nafTg81l103G@hxk)tjpr@(A7+M?TP(5T+`V6pNCMQ6-!D&oI<$R{ew0(hCS# zgsL(#14OWzJYKBIFlmieoGUOjt*S7K2~*8OpCrfYR3h!<0Ut=<$Iea?ZzY3kHCrzN zJoBo|A9K2?(O~XaXe-9aTi{OF`pt=;WjIgHvob@rf5D+9A;| zWd8`teD**kUdTQdYYJobMM6p;S(&Kbm)6Tm)K1SSR?|t>-6uEnir#=}_j~=F^7$G{ ztF306nk{hHmCIM?y?rHq{ihhBlj)4sG)<}qk@N3neENm#FGxL96R@+<10`+D;6@St zk?K|Z6F@9ZY2~c7WKZv9kt`r{+@aZ4xDRw%e+^>>e~8B9oes-VWJ!f7pv(2DtU4ze zDumxgV|1m&F5Z(4xeBEFB70TUjfHQ;7d>oKRcB9-ARj-=2U=& zT}^$;2|n4|JEIxIZIys@PdK!};>S}m>5xFmM2(M5DHenEaHV#ccSqT&h?;$ox%(kO z+|k&asdrv834Tc2kkb2;tgJ+;N5d_cxhn@ z0TNGt(B9LPb3Hoq-bq?eRZn^YylgBy?Q5WFvk!yGbtG$1W6QOnPIWqUnYB8V=CLkm z!b<5*k91*2rFWUtzMt(Lx~ZvA8l6zq79r^FIbn(&sDtc0 z)_X9?KA+~+iJ|I6lWolCY~G~XWErn?a;Ew#GTzNB(=c<~r@5LLHQ=mnPrEVRexVK2 z(~+uJ!;5}81Px~eN^(YM*ij?omi8&Y&H`_5plu-1nVzC{irYeupFAzE%oxFzJUKD@ zxui10v0m=nKDBrEcJX%Q{6FDd;hN0D`NZDzz-8V`;5OL({rkxJvIewwcURJ9J1pPM z2%jyB=H8HLkT$UQMg*_TK^|+^zmdOcfBk$yj@&)j7ynlZ`WJf5wk__L<-`AH6z)3) z_XE4@i4B5!2los5YV+!ei|bmq41XRk(G|NSHNczf?qD0jFHEDC8@8w8_v-&z{=JXS z8eG5u0H)Ca0J#3&mVXyV17{06+y8d_PpfO$ZL*^HzSgl1glf{_RJep0gr^nAT!Bq& z6q|27GsKfjno=cUNsK%z{=Vi2DK>gf)Tl=t|4H?DpSk8j=ZNH=QY5(oAXSugL_?Lt z;-GCJr%|zPM=8*;5RuDk-J|IK>ppYDqO_VleGP=Xs7_3ccly_N)kJ~3lx6F0`--;o zPE(&$?HUpaRWT!0x}stLO23)N46cLc^Sg^_33Y zhJ|vn(Gn_tkt)hR=-K=2XW5+H|1fF*;pNr99`F>M2m?-5%ez z{!@@tDb?GyMPBT!IEn$M5OGCBU=!4!4@$6Wv2Dy8Gj86ZFMi4)d&nS)8hYG^@iD51 zVzu7Q9pYNCnEEiV#K!%hxM4G(PH&ZVrbGPLdzGZE$z>p_Pi1libo#-1)j?AacSbTn zKK##FKP=}+vZTL2%xC>outfH00Qn>8hm{|%IonJt7oca22rcwFI^o!{A#19~T&pf@ zPSnuk$bSE0_ckcdtt#mg)np$1z#W8?LAO8Cx2-p|(g}LW!&SHncTkp^ zy8A#g2bbz(k?5qs^T{0Q#f+v%QjjpSC?47LQuho}#Ee+-D!>>>L2Q}hj$e=oLhLLc ze~JT$(=f=a_AElFWIeWocXb|N^ye}7QEZQvPv-%Y-Z`KTKmA4=%cZovciDi!Fzyp4VfD9$uVwKQyT?~FbMgXK5 zeXZ8rs1FP$QP?=!wIXgZ>AT}n$m;=u_UFTp3qN=6M>gHc>_UOdvPm$QtD?RIk5vl% zu2&&`cWKy$v4RYAv&Wc1W833S%+;WO#XDF|l}WBGogG{poLRZ?5)ygAV^N1->Qz?4 zFfM5mQKTB7{YjFGz@W;g>TG&Y860M$jjC%S5$huMEJSvtr1Yj{Bbg%Sb5Np#4ZTHX z(Q0RiwZ^dHhn1&Qvy05SwN^2`yomqEB3Y8(OM3j%O3ok0c9;og(IbP zIFK4JA{{!_+CVLf&rA}UHIRa?2`oEQ52B;eCU#4&Q;>qQ=wQgBAW_Qx`CA5)qGa67CpxK`nXS{yHR$3;MTs(~1sji-0vJ_+^d3U^I<7 zz5_i@donyi0rVP5u1hBkBJ}$n7f)wUVuU_Futj~+BtK^eA986}HHDM-#Ve$thzGYd zN0)YkD?jMC3p1<{tznD34CfxN$ef)zW(vt7HERybjiyyBTBy#3n?DI0yYdmm+YMw< zZmqEO@HZYF4{`slq?*OWa z)bc8OlgB#inQUK72nc z28EdUH0);Jb6oykN;sJZW+6ig<~+m4kbasFAThbDE`hR15lN-Ksb~3OGRf2tOKkn< zuDs8esfC}mkdYaLFkNLGq?xI8jEH%MF_vCiQYG)cf*L6L0*(Fm0B|zu>}V-TpChDp=>W&9Sdk``JVN;&B-gDroZStHW{bN7Q%BbRQMWM*>G*kxZoX6sz3b&hpF_{sJP&2;3h1-dljDi#q#rm)-0mR5mrOuA>-fb z57m{Zyu~j=$Gx+er#ip!>$9>fjMjTy#6AR^rXAqd*TQy<;I6&D<>$T3?(6YCO>r#m1?adLl9eyYkMzgt=W#blZ(7c)hkaY9-%bg!A4 zz*xe$Mnwv)(2nHCK#`I}QBoDW1`~^w0-r14Iz6{Nmh7W${fF2Jt{GKV{|FIkMKm(d zrrAzPsgdT;Pm+HF*gMN-)j^~Ue}f`rQ2+%AsyLvx1{(J<*xycL`E&Of=Qjk9K8G*q zNG?*CG(o$)2e6r=MqxQ+U@YjkDW~v#TGecBpbCgMUJf%6u#Mqn&yi`M&Re?~yRLR7 z;98N@J>DY2f~d8%o}K2$@@k|f)c>ID92#@sq9q;Mwr$(CZQHhOCnvUT+s=t`V)Koi z+^+{cxU=q`ut&94?W$)5O5{<6JWj11f6UpjM6_IZfrJ9bRXDNd4Vn2d^9~yUFN0%R zBstqR?X;toK#G*ZW&~WgElR%C&VWgHLE#n`USVOaz`6N~_QXXvW7oZahRYp-NZ|8m z3mv3(5$4i&MwBc;b*E2i&4bT$11{BV+adS*Z|wA(lc!eu$(r zmft*_4WiSiA_^g2vCvehfg$(_hRZR_ot43gY1+>nzds7Mb+&K7vDy&A>C6nJ@i?!{ zNn#6aOK`f8BHg1#prd=YAw_Q2e-Lf^3Rs8K?6e@Ja2TE=kL4_85oJ!wkZZi4Umeq8 z)=S_g|L#~&5ocu_&cFzmDR$GTU0S&L-RXN8L6o8mOZ4LZqdk5adMC}`0r&j;1x2noR5T9M8i zvywzQ8AFLkfm#zMllczD;z{N{azzLC9AVFVC!?EV7;rDxXwKu84SI+HLx(0F0#^|sM<9neYPM5&r`arzx(GkTz;ih~5R0$yj z*PnE~;dyh4mJu;w%R85bfDu1PQ>%kKpBdUN9PEH!i%8E9UD ztE-16wWO-;YP_bHz#z-wCoc9QcebLX^+y~mm$xr2PLAy)ZoU5%P)AgE zn6EC!gkyw9vvajm+lU_@2W42#=;|7Y%ki+rV$D>Ul$xWsYskKg%dFWnuSJ`*Qi75e z$vIqfgxfYxVBBJmRjIbr&7X7L+QY>D<4;AZpmtC4x(c-gMmN`BFppXbAaxyh4W7#( z7K^SK?j$t^NtuSNx8Guux=k}5GLc7UJnEi-ve5heBA4-xjS;wl(gjxa%Cg!`OJMsx zm?5<5OQG4OCXG&mqV*#_Afd00(>D@P$_!_2N>UMqF>W(D&n+JLG(WbxnL3$^RK_HTO>!Eb-|X81r|dV7jm3k}$7Z=}v5rG5H^ zUDyzVJdd>GfQ+l?$@%o!=^HkokQT#;7h}@_dxz~c{)h5mZr4W5RTg3XryZC02KNh) zJyNS`h^OzbjS^p=@9@msrh|ZLtXf&B3D zAm11vHl$ZxkPTDH=YuO$77y&dkbfH(-oSu)sm~j6c-p^_G}%4T7lQnm@6l)bg28Xe zu`hP6Bpo;2A^*2_2{+i-ul~1*GGGDy|KFRlur{|dGqg8$wf(;WX0M9Njq-DAxU`~Q zH$m;&yHi2iYt|AZIz$l+v%8I`tDDAG(u6+#x3+ZXKKS?3T{asN7j;D4{w>Hpex*TC z@L%=;QXGx@aH3K93f5GK*k-6S3h$>gJ`hvv8v_{L$lHmW z$8CmuIbp2LHm`Xb3i{|BTV2w&=mu!=;#zqhX;r5ncZanJjhAqdmKa(>L~gG-a4I~sLiS~dJx_z z6%oLm7i_#;1_6BAZFy@sbK2CXGR$K9UW0`jDzc4M_fTtnT*lr6Q!I|91Bjm`4z`XR zn8qtSXw6xH-#`k1R5kzDm6cmtGreqx25pF)2>(-BKx9?$e#=I9iSyF+AM z`T5nU5o2W_y_n`O0D#yvJNI4)^^I*bV=zr{?5HLb6|LX`hxDa(fgyt=P1?y;9UxL^ z2-%sTEwm{2T}d#}kiA+4R2E|KTr!MADCo@V{6Au`!{bbUTp2N%;>t!;j$ z{iadFlOWhuK~6^xqub5cze7??I~&PFXkex5nUv>;V&OD<@86I@JsDNzI;ULK zp!}4z+}w0@YWeg?m0#&F4bFLgR$cy>q@y+;CHC*G-73?sr~?{XD804t(S3?(-k?ar zq;=i!nu&g9NQY$x6x0XwDYi%}2aK^` zgmzgA9M9D7AS*Np8yE%R7J5k$;ulXzUPC9fOj&joh=2CZfK^aJOrE3Zxg_iz z)eCL&s!;@>jtW)5hf=F6R1anvOynmnia1(3C;kDP_NR|1wHeCw2JBDX$+346__lr~ z4hpm`@G-mN*)*U0c)Pm$c)IWk6sW?6qE+Qh^d_LJ#HX|zHw>~&2{myEPOOmdooR$w zXb5>NH>lYvP|9@VYh-$z)6Zqu)d^EMK4DSjp1rv(sD zTqSg5X$M#tt_kDTE&_I>rnIsQj`fO8{JAresF&L$Vyl(u3;2sz!SID1#t!z*Ya4SEbB~fOIs^)L`Y2pyemxOWqR&W-IE1-)V~=W{iUJx{(ouz^ng9%Yw!cF zy8DPqDFFQ$S_vBe@Qu6(li9$bU7cT@hvQ$1(jDpe`}CR0rk+gUA84B$G6d;s5^0eA z$2Q?f8#h!YkKSs9B!J=OhPgFuMOBc>Zv7X!g&d(6n_ZN=33plbtoS%0in+1S8`;Oh z_*6Q3DF6{C`?p!Z8S^&q9BpAA|QP5|kng zCceqwtze5GJ4eux`JvSG6VBV)=JBey^IVtf0b`eCi7^eI*Mk#rXxnJP`>6S&>%ep-29Q zlIfur3!u*6;`p?=RVSBT#mR?Z&A8~&Fkj+>$U0Po#4*eb7T;NP!c#-m=4vX~@xyrj z@(3N#XOVO$l1v{AiwGSlA<8v7P%}gUCGrDg%f7xt=I-5p42%!1j4w^Z>q;C+9B_5x zwy^b4ki_QZekjjeGxe(RgbKM*#vsKmIcVzY9dB5dC~_cm(901683@a=iv@_Td~e5h zHsIs*4^hA;)7|Sd#YzQuUhV$+({!N8t8ra3iW-PEjL`>-bs&c>2~xMrUVTVlan|@5 zGD!ftJa~qw0C*P}AMpCP`6$(u2@%wedvH5uRJ(;zy z@7}KK6di$dhWTcO+57w7ot>}PpPQpX|7D!Hf{x*L%)B3f1JXQUdmk_^-@M~qyw3Mf zBNu0&$H&9SgYh<^?}(GPAe3*3M+-gz&f|8+tYi30x{0;J#rqB?N0#O69Q&QmPvyV6 z6Ftu-FWi@$N6Ou^MMCt@#?9OCiqIc>F08hZgN%!JQ&Il{%XZ7pHN^IP2SkYLCNJLe z#1AMJXXN~;#I{Ipj}!f#fP37lo$$2{gnv8wia>}iurXPm9*pOEj0`b`m{E3Zk-Bdc z#xw!T7rwPEof|z@ZWwyb9pnKxZ^A_&0t-Z5O|I2u)Z8++pcS{@Q}C4i{_gBE$jIX-^ZV2)!hLbgC4>0jH4beZipbw zd(U+Rpv29#qb>u1Mm)zMc(f)0K=g@!&Zgj3K*MH#7cNr+DhGzl%XcBa%6hIoyrW(G zgsZZ-Uu9h31gH$;!MVd?PNL5E2XTRyGJ1Oe*f+u8f3f#PjPT*-PGZ8$@grw2J|iGv zqk0I(>#(twiTd?Bq4d=+gEwiR`G|BIl zrNVzb&dcm*8{-JgBxGcgVZe#rHKxi|Z3j`$erWg>@j9VD-+qj}1%i-lJ)yv@0SYVj z1k*1cCEgr}PY9Bsyn!o(K|xZjZ}4hduiodcJsp9F@0@{WFy2Vfdh;~L`#4x|-=?`% zWE}Il3Oh;kL$+SypA|J;x^{`fz@WJZpb5D7l8R_!OF6mb+|G=NJ~G9rAxbXqvV8gS zILkMS5Dc*|?LtcQ72;!*7^xhYdbJ!+eT01gdr-$g!4CY-lR!(G9uD|6=OBWS6J|9KxEpy-?GKxGQI9g52LzYb+ne7+o$zwi2-R+5f+ayBcldi|=DZuJA#v&Qd=U)jM|;P zj8)pI6ew2U$_)sUa$iP?={G+yZCxn3p;3BEidg}H5nO($=gQ}R$RRSTrd(Kqt(MTD zuP@y-^!tDT2%+mxOHYq!VDb!%NlBG@S8ViF`j*xw-YOaU7fBk5hJs2Z|EQ4_?(9Sr zukF7EZD3fd4JvhF(t!#}YJ4#c?^-mzMH2G|`ULSQJQESp4s$MpYM%1*^qan-&7>Dh zu+L^<`0Vqctt76$KHinzx3n@Yiaz(ti?Kx&e}uEWm&=}Nssxt2;_5o(oVP0OY~n|x zzmX;tnZH5w7vFjw)7Tgt~!2WZblM&2m|?herTt`!yew)G(#f?9+R z0!?lWI-#XY@L2tqEoYEma=rCX;lHi(n)-uQprE^#P4%lSg=Zxl!zD#fD%_z(S2cui!)5q*cr_dl+t_d79c9`iO# zMtaa#XRZqO@APu&{K0ZW&7elv@FRq?yS!CAZkzTY0n`@}Ap;uV2v8QTLKjb4QKygI zEp3e)@^~zT_j)=N6z!IYoTbMpH*XNEuvXHAlu^ss;IT;kB`0l(p3p;Msx>De7hFc=G-sW1IO38u znJR6jg;$vke)WV!d(c%SY0$DUpoU#evOOpkWrchhEK9OMoPyC8R!HPXUla>f>IRTH zehx`U_WV1;1F#_;iwAo>IkN|Xt-pTZaRGtMmWBsT4lT9{1sv0R-=uEQ9uA1%g)Fj8 zWF#?&Zp&6N?z+vCK>Wbx?So>9Mnj|EYdJXljWVk_{2h?Zpcr6UrH%B&xbWxHO6KxN z{o~CQ01jlwnK}_mmVJBe#lVajp4~O}ykK8w2CIf2$4X~V?)&(mL%i~t!6aLvk}6K* zK%6;CcP5*5ARHpAPz0mIiCSC%{w9;e-{Fm~vh@MT3sB=f6PEvRJaHfjY(=$D(tc!u z@_6KJjTC|4!M{#ZBO_2W>Ut1c)mf={BMpPv29vL>w0vS~hW5oN&G_uL@bedk4U~Tzicfa}75!lFTjp9Pn3TY#zx}BR1dn zNtAgWoU){Lgp9cUc;t%Z5eSM zoGU%yz(k-u2zNsA?92UAC%_eu<0m#Zup(nMwumxcOOB;TATPpq%EJrGb3&W1x%JH} zrZk90E(PStMi+iNWdfAXna~!b8oasCl`4#6DYWNJ(Bqs*12)V07$ z6ike~BzaWnZwdWnd1_dsOP4YlQ+p%^&o;Fqn+C+CffoLPoutU*xq!*bb%#uLp}ib~ ziV!*$2PTP33FRrx2$cngJ&~%&Fc6}WMiEIYT~J=2*{pF^ZdwsRcftXhHdN>K!o+bf zArUo`MY)jSUmN0R@`RIBRuE4`#6mK;SgZ?vOFV>Rt`x48>2y&Niw-rJ06Bl$QOB9kr9!< z5|(5|9VASZNjsKdWy;1){~1V+f!}&w%gOi!W6$j!4wFJaD#c-~Q`M9@Rg}t#R=2&Q>T-!sKal@XsdU3m(!r58(c8h#sL9HE|G6o=*}SRQ9l7=deTO=z^Le7<8-%ezJ<0V3xtyzOfIy~9 zkMwojuq-$4bLJ?Mz<}f39F@`ct2r_gG=O_>26+JQ;6&6qa&N!kyI>~}=lA`pVJC2Z z%-SBw)#dGwV@aJ5RTX>#hzVjLqlRA;Ri&U5BB_=3tNM^#tps;1u zwZmEch-?-vx~olhm2#b_1p(&dybqo>oZicT%8*m$1!`CQ(36+w_^m$VE@9{NltN2w z$IrFN#@=ovzdI*~z2fS$C-wy0OUZj{bElSnEfy4T#$ff=f(l%Qtb$1z-@=G6@1*-eVK)IqSVvB%aqsN)3;wu@W4f6+_soG z1;U_nY64+(f27+RWr9e@ z&!jHIK|wZBsKjAn)$hxs&vuOb-`C5A14G1Gx4x%qZ)Y?F$#>EjlC@G;YnA&ArrPz> zk^DWx#lvxl$0*nvSxF=96erJdy^Sa7a8z%t$3g0TE!yDeP(mxKB$A)ll@(hC&A6FD zrhnxp@FFi`z>IVeXy%pI6(S_3YvztoPOH0XyLWdR_>D8NVLydnCU(J*St} z=F97p8#}wlEy@IEfy`7hn;5rXlpG5OB7#hGPfJ>NVtqfr*Dix3U%(V0#%9-4qVuq= zcF{}MRCoxR@|9glL(*t}yjCCu4KqrNthjeiKB#lcf0ugX&UqaU_w_goRS9iI&x$SB|Xu!0oy~(pR|)Jn=ax{d^q2) za4&<*v17-!(wKDdCiiy(3`WhB&loR5Aju`%l{GwwZa9uhBd3Ut1|X=z1=-}x__`o zmwuQ-s2b{5)@)bP|LT;fXzS{4>ev4l3@wJ4e2L7}2j`)&U3|ZKzTA>Ax3BB38Onkx zHb7OcLSF}VF5`IA%>9sQQqiWa1NqDTR+To*4y2Ne&tB25r>AdN*PuDGx~0>nLf=q( zI}dX7^*yzBMj5DSbgIg*x~8FV#TuwGw`r@s|Myy}+Pu=Pt6fL8X3e%GP#t(&W6k#J zd3D;y-=}1HK?Bf|uHtU)-)Ep*SD#^X#kyXH;7-e5%W*OAXQ1BLwRY9!c~z$_ta4pb zgQn-?x$3{sw4e9=^Z8}dmbr*d{zDK`_~=n9D)Iy2C@RE{)5bHR(}r)vxx#?=dJmPJ zvFF>gJC$<45B|QUTybEcK*IDsUx0IaXGe^+j{|$A#eBhsprdRomTws6kXOrw8rZLQ z6dTjUHL=Xu-u56hM~>KJ7b!FIZ@LlL5G5Ix7E~TYr=9EXgkbOz->)EHL#+sRMqfI7 z!2-4{hgQ(@L|mY5vyojOSgM$W`HQ%IyD4`9p9gR;1RW3JX-EhUKpb36twbjZ{`(<%sd%E zu3mb<5IQdIh5aW;X)3x5!m1fpH8nNqF8(9no78AZ7LBA*D*;7%+TmT!|9QzVi5lF@ zrm8bH?jLqxJP&PE2QQX3T!ZsD^(%`jtd(f0sbh2C#;dpY!Wq^nK zNNWeyEAW%ZqjHhbDa%RM@8cVIwShzE{U|PMPdAY){g?ml1YFppv(fruHl#9CtdcmXpXS?TYAQJiC9tw0SKj6zx~L|XNk(Ml<3!W^Cp=w)y? zoBb*r0L5-?8U`&n)=w*O4?;LenX-{`dSTWT#1zlFR)x`wp^UXuC7L(R{t>!CO)0{3 zEhfiIbdk0~6%qDz8T%?rG1H$8Qw-Gpik1IGub?yK`E>bR z{o$rtbZK&z)CzLLRqQ@^QNiGe6L?#4%hCNi1Ck%DhlaSbHJv^>kQ;?K%bC8^vuMXQ z!S#@~ra41C(_)+kAl6tLA+*ShzwF?&+Y^!-omq7B)Rt^Sw0N;@@_A<6>LjulVyZis6=Cnq zR2u0rk4p`$qp&WXmm}+wNqy3g`G`Gc)8j^MH*l7eF-`m>tKK{O>%FVE@0~+lTg=DF zMkzpoD{6I}6sM{7V1a95`_>Ot3$yjhju0=88nDuNKi=bqx#NV~jvv(5-UV6*yyl9j zr-w9c!Oa_Fa_o_rZ8XLhXZHm>L+`PR%+RzpPUfKhid%2*(KXTtodEP2_=YJ68mUFS zJ44z7$>mkV@BA0$K-doyT5KQSPun|?PA|(^V=yFC_v=Yd8Y9iunF%RM_NSw@)?BaN z2ogw!SgG1B({uv4vn_iH1K~-6=Z@C>iKisEYg$?=)IriOS@K0U0{n%SKj?3%(+t;C zLtXG-LmfuetpnGxpuQw~<512(`cO-YAM z2-nZV=to%>!$@t|-#aH~32)x;zZR~Xf|XXMvp_ga!XTu5=W+ePVS}FbHr(jtf@o~Q zh9vAGWuzb&Pnv1XNsOmUQLZEXa7TudVf+y}iIB5c-k|A`P%^yRc3T%CGDqG0f=0nx zg-s44L^;p?yNl*TF`kj|zyKhQnguPOr?oJNw@I|#dJ*xA4!>DBTg6%_gahtLenO43 zs93zNTe*&_?HnM#0(&Ts;(j^J1B?OLM0Kfas;Yb2MmSukGvy%n?yk1Lo&~7gXq$4G zELyV_glw~GkdOFaxo+)2>cy7B9CGQ?q#2nYwrbeY`aI}YdF#J=mv+L z)4|y+A;&aQ@ea|rz%jF7;2!n%v3h8x!1Aj;9&`OVx74il4g zBm1w3576I^fJtJ8N8?U}gQ|oE=RYD+)W6M&WQvl^4b~pE&1=ZuG}1+FU7!#H8$x~C zI@RsyXQg!-C=rOOHT`-weZm8t>JaR9NF@rJuAZZOg8kYrg98Ep8zM=ke;c79Q=0;kGjJDtPj)4$S z=`HH_S1BiB@KMmDkr$jH1b|_~oqe|#A8|vJJMmwFgXvo4D)#sLz7nUDWbUP~NJ<+D`9=HJVL#MWkRh{$ z5zZsgpFKV~2hOW8BQ7L0C+JO4_D1wAVg^IY{V`|VW`M{YWY`5r4BW!?bF{%TT6l6^ z;N$3p9M;fC1`B(sz+*bG6aaX#^S!va; z*D!2jsQ<3tQYZ(*bU{TI>dgTN$bcc8gYk&}GE;|+Q;hvmC_9wNjauzt_ik_;R&;KU zwTo1GRKH_1ae(d?MG7ow1=Z3a@UlD#8<#H{QMfEWiG{G`rQ8b*V>0A?7Hn5i<<#8< zHlg&JB%#Zpi)I`RPs9i#eY^0m;wOVj6M$>x3RmSDivp?d4oP+dTXe%t{qT3Dy0W19 z+=0>QqC?tFOGNY226uNujp;k8{f&t1{&I-}OI2Qca|Kn!!cwF^M@-&u2cD=jdvKfB zJVC2Fe^yBmw2*WwR0{x)9u15q*JU(D%PA(ksqlpN+;o6V{W{5Vb=zIOW>JOVQ))Ia zmxkMJ4dOKuL7n8tzqLP;YS3^~lv+aCMy>JJXd~yQ>=~yNwJC#2H zLXbz2@q?r(M?%K$pb-XEFp9=n7))|MdTAvwQnR9;p z{WQ;@OxxugQngi;w@S4_ZLCqJ)~@0%+HFdVvbV^6RuWo9#X9Us$Nz| zb0VkIW?g85Ohs@vkO6Hy1{NI&Dn=eQZ_=cZEOWM=KbtJs_a(9^gJ{{bN4hGXmMSx; z)+SO~tr!Ctac~p(dqnl&t%^7cfww5&mBNkztY_~pr+jUZ(4O^&;jx9yoJF26T)uzP z68m3O1N~N4jUgCVDf8te>rkwJ%$2%D+wCKxGv~@Nh8$IoCfeH-^x$i)^P z6D)uB&Mi*a7|h;PEo{{BJDub@ z(MW^=5%NL+8=4!4(_U$*vI6!(F;HnXEwd{^izKF2KBeSmYtDk0O$CLOX=dhh=utbn zG_TpR}NE7MUw5dwm`aH=C ze;xGQz$G(d97#Mz;wXvBIMt$b1S72+Nk~Fx+p+9R5O)PhS|@ekl%!Rg3)21}w(1)A z1WI@uy__jWVUd`UF%XYrrgbm>7Lf2cq8XdgqYV4X;BJ0$7j4ZURez@ee4uf677o1G zp%aBYzQg)VKsdrdOu>i|sN+K;8zu*~Gv?2c@qhav8 zpd2`{&2RztAU#@#vPc7Jkr<3~^WwcM5C2i^C)153B3?`e=?2AYH1lNEXdozoJW)Gb zI@JJf2OEkYE0x+=t3gLH+$lw9@^p^aIbDIj1ujQd`j2N=;x-hHR5qw0p(8j=?~tX1 z{b7*$l92iHv?(w!aePo7O*!$Og9cVtagN-y-I_m&#?e&P_4Mu{S41ZeY zZ_JBAz@h8_tT#n{mzeK18S7~^gJ%I#bRyRnV-pRg!jJLqn4u1vdL^H!M}Xa#+4XW; z!R?==_DGhB`78$YniDG!7*tx*`k^>@=nH91uVCa$xeT z)1oaOB@ydly+P_b!J!_vT?+T?gW=nN5Cs|9mCPx-=v1sCc~dFoK%-7zcdMC@5Z>HO zZjve8xw{LDf9}mol;6R5?!}AbfOF`x%pDg#u4H5Jx|7rDc^i70=bP#@-?!xNYS-+k zz-;43)aOcwm4xy5)RXB)60^io8Z=ZiL6W&^H+z~tz?m%KEBY1S3MoB7IHnM_Iitsw zA15tr1y7Jl%1?|2LH*~ZK-gJEm>p<`*j&J39KQ8*9Ag_#E^k98b)0OAryqe9KEI`>B>0!kN+ccKUNnN$&Ks?V)Yh+|_)t-uss9a_6n zZts77qU^y^1kY^tS2Uk$afsYh&SWY%?KutSJnXYhYs0lACEeyg64{2`VTTb0F$qo8 z+P(Az!V=PA5KU}uRX9_nyTA*&^Evk>10&N+Zs}dDC5g=EWEq*#_da=8cX3oR#b2=z zDfZ^WyqazR&xJx3#h>9)oS72&9TKyK*-0sWh4oQsas9bL#c$or6cKdZqS`Okv?;AU z>9jg?Wr_iJYU}SZU33>{h%VML)+*3Oyo&KREn1{+efY(rx`Kr8!cZ?3`4%}a28;GZFqfFvvxsN2(7@2ARvIf{27N~E>s_Igb*G@K zcuc0}7cG~#3Lb|&$`VJ#@{bfUBQX20=?iOJ)+}ca{?7km`nwfTf<9B-XC=^i({)8_ zAk+KQX{v?sJ>fzZG|1Lth>A0@#>^_FlG#-v>5p|}ds;-S>PuBD=!>w7?01(ZHO&89 zz!ODL$%%9BI_xO~*~_i!W&!Gl47axk!B=fzXLh<;lMYtCnBIXZRi}l-iyt_+h@dXa z*j&TmCMV~M?jPPBbw#`7n3GZMq;|IIHrmj9E!3`@kkS4Mg7_oD(wJuRqMN+d@$6C= zl5|rGScnTo2yR9+@lX|O%z>W<9KEZlEEVEjEAvpYjw9V>lme{&?BQB=rKSv%nrGXcPN{$txT)c1=^LX#+TS${#68wv zS2&?5qq)KJ(V}ikP@r@mhs&;ewAlQXXO?}#OBs;0%A2sKMj!9)2$1c620{1{nnC(0 zim~zkd>Tc(ImYDqJ=l#ie9^hGu`s=0Q*+C#aiUH|nY*sBJqSB_9hdQ}XoS!iK(J8X z9>Qq)3q(UZ9h>ev(e>>=Tnl&=6|75IIQI|91y&YR0n@@QZ}`B#ZZ9^@cNV2=WFrKw zk5a++TB}GUw^op1D~P3BH=})>UF^S>2XU?(u!=vp^P~h&AdHY4(Si$RA^ec_{9)C^ z28}W2!k?k#Xu^ojz*2cDxd0d{hB{V`MVVa4Pc#?Ak2MaTE5ChT01zog6rHLR5B3xy ze=S4`=JkwrRM>Xi*#6DxWUXNmgx@Jf298jWN&%)Sph7O7bX1YC-q4iT49iQn=N#;p zw+>~8g<0`~Ijj2QQNIzym+}&ZEmdiZkJU2|traFlDA=TJGWJUM3%pf_0;j_qvQ7j` zW+oMvhxRN~3$jlFix)TDcHt=4Tx~KIs2p$0Lme$q3%I}gO0ZD`;@j-Z=yi)-`$gkd8hC+N{7GJgHV+gR4YIY0B03I67>|MbXHSGn4eq2n=Ar6PmL(aF6 zh}b29<8{kQrP!GYfjxOa&IOcc*qyb6?XvJ7wlKzPWwrJe-169SE~f^!I=o!*E@3?B zGR+FUXDqibV&K!|#}y$-pi7u%483rEFe$+GSP;zlcVY6@*cjZPYnLSnytP$$rSlE} z4{7Ek-iS8Q(K}#@!FTW}Ih+L}oG(oY13I|%L`fu!4gDZ3bl{*fI`$tk>Y0j(0G_Ow z#;{tXJpU?jD~h5ERF}Siy|DywkZr8Yboa3Lpp$_nLBf*>UXjSWVUAEE^wv ziV8Bm4*l#YXx)N6P(E$qzhSHPy911_&__jjs{yTI?hmTK$C{A+v_i&2yCk;IOlE}w zEH7+G{Uc@;IY9}Cg+yh`GE)u$z188w_G_MmdDjz2C5U`f!Dye}d%jMpQ1nx>2ld+a zMBP-$OD$GDV-A55sRg2FLU-Y3<8jh2d4jJl(<;>vrg$?+v+G*4(b)Xkr(j36fj^ZI+8OFI(_LJnx`MVNu!N3@R zTI!zFMi_08A9sE)jGynnq*=bmo~tuOH2QJyMCuS8Y2(ll_H0daqw*f?`)kaJ zpXR_aIPbj;r;o#L)e__+3!uolQ~S|s!S93Zul2F|5dG1R`egf~%`qwc+q4SUfuZOySs=JW>)H|a&o~iu-oBS!!;hT_PWf|owa^aU)17Ng;pi;W<5i2o0 zE@!i&mi`Y)jV*ijk|{1rVajKH*wsB%f|h?SR0$l?Bs+d?%PhOTS&r~OWP8u?(yHAI zH7d<@_=1WQ+V;B2pv6EBwPyTuzJ%V<+vpL;-S7_LSduua&Y&$CQqSqp=vom)G^1N+ z>A$oR_)=0^R7}BF#~6Y}_yWEW5_~t3%vwd5tcBd@`@ zNQ}SCl$hb1@3oA)X0O<#?f&q&q+9OM?;A>{ttFsD8X1TnlszjVH8&r8|KVm6Bmj8!0K7fuQ8a@QSioVt-Hv4zj4T?~N3b;S-V)UL9m@Iio^vEz%effcz}Ori+jS|BYec$416rMjU0tETEY#ymCzEleu!(Hskz?+t^2 z#T>&BWFH(&Eu2|Bq^_QQLo7d`-}3oTn~d!&G_PO%lMO6@BMd3a%#0QZlA%qQ0vf3T zLLR4PK&zIz!wd8pby)5enl&+s9#j`ntpZ9U{L!(KXg07rq#{yDmPsfow|aPaHfQEq zL_IC5z;j>~sL2hSf#jQn0XuJPI#W}>q}9U{wZbg0eLMiKXma9kHSwr}Bs!3jzuZ)f z+>4^Q%V}*m&Gx3HO#s%|vg}0w|#Rfk{dv;Y4{^BhL&mKQw=cr&HGlpk!{WT zl92~*Yk9y_^|P`6=E&gO+(L?kfmEQvwkpLT1lMf~MDh^XNT3`If&zvOc?@`w3o&|1 z15`h`0ng?!rSgD1LL~jeuae3)nQi6qyH_DPpkwS03iZaSHNkxA>Q;W}2`V_+L7Un3 z4hVnEs#D#V+WcQ9^co%lw>{ctUx&~hy}AyrapkWT{3 zDa~U-=m%hF!-6pN>kB;;x(l((u-5J=I$QPz;=3mrhCvD1)IiXmC%fKkW(jN>D1NWZ_pJLRul-XFjtb&+k z1thACWPnhSqbJZW|C#1Y5NIHLHh#-D3mhaJfQM9NYap3{huUG!OgksIoFwD~G_;TK zru;ath0?;%Vi$B*bp`Vv$vZ*Hkkk9FrGbN0ioeb1CN{_oWldfQ5TN2#I?$zmrdTNr z>kr%TT)M(UH9d_Nk(W+b&{&}~XE4oWU&G&Pw6?STDY1%;t)whkyO^e%;U7Eb^K#6l zVu15nYTHhBdUw4>*(%lE9)i6|4Bj%8jYv7_P__WUpA&h~ap<&pl!Z41WALd1C661< zJc7oX_7T>-m3J8liw@1Vp&}0)lsP8N7U!GCHkhGTM_z1X$#cYQSXx9Oj(&V`o#W8( z_4AU}qmmB+gLdrVd76~u(WMfN(1!Cq674FC|MU{=HdN8Ez_5C;Rb0rj$EJ@RdB^yS zz(oL4YVyIsaAjU^+y)+bYpAziHY~la%CW&|D$8dpjt2KaWJ=}D-vr477;BeyL(~2S-}b52j{1p*I9x`ijBQ2m2Ye-HSoP` zv$PkFM5`>l0yGkP%%yl}J5`NdY}%bj7J33VbJvp_^0j~0NIbFEaXZdB@aHeOvJ9w6-*jJ7LK5}7Q2L|1COv4(LsWi+5b#T?8ttDhGjUkMIS>fTaH}j>GLGp1hNwh!YgEq6#qpf3g`?%jybYxX|^^3tjSG6lB=np;%^%CbhMW4qz{26T@k8iL^k*WwZT3JC+{T(gB-dG+z zOe-LWGOczYm6V~|0IB8j8POpVSTN_yx##kS3XiNess{?|##Gx76c_LHIToO9l2t{N zD!jQvzZ0G9Nk|Vk`+>Uc?ts^S>h#+nlOe8FH`|@w|GXO zl7{64w421>*<=i9?-};l2)kQT^q>CC~PGD zw~p!UCdmim-ThAJ$Y;!#>N)A`4IQZ;>BK^<#^OC-6xEQmbv~@a*s8|YIKOw0WK(jF zW0NFcVOEI2O+@I`3@29#apRQL(K<$bWR0=EB%%bTlscN|l%WJ>?u|Lj2Axm@no#}u z(AGYf8b7C7UvuhMoxCJD7qn{K zHHY{4F+|GwT77w_xEi?4tUVh85I;SVqPHe3hr=9kj*_CpgCw3w*c8qNKk{o@36F(2 zgY6`qGoKrP;yPjB>zCAb`Dpr$KWI&(d;v9-VnvWZ)?fwyBqY?*2*4gqsod(Hi~i(t zDA)@FEwRGRpKXRIZdFJ9G=Am4CE!Wobz%+PX+!AUr(`&`xO|AVpwQNV7ka z#E0%(3K6DzINa}cRj|>f_x7l9OA#ew8`tBD<}(D{_9b8@CdHiz^Ntj!$@`hVrca6` z*%!Qa6r~NXf0{!lJ4dbIo)Y{Pshm@rPm1=`OY=DHC-RU#zk#!A z=0Hh!McT29n|}@ABWK(i)7f5JksbY1hRE+f#}3I}x<3aBn7eTK5@0*%LzL#y7j0XA z=HD|eC(5x!M5mx0bx6i3&@$r0CVXV9ti%&B>A8h>kbGvxHkAb21aQcGnUdFZe13hz zX^B|7HWBkEmIP+sS*iD28&XwARkj{(PDeo_gY@hs<{V>Qe0<;$!rg3Z7`lf%s# z)jb-O$#;DB&SSRinmVH%)}jY<@TMU$hmHbdJ@gO#+f+oq01y+tc2mF>%cqmfVb7_hIdBkTYjG zhFsVs5XgOO*bdLSxuD%Kw9HXT_QphS85cGfGK2}asdmTFSq{ye#>DIb zQoQzq8DGe_O6X_y@i{no5pZG%n4Sr8cyb5|5&BF-XBbya3T3%qt9AeD_PYs=(xFd6ds#p2SA?w(i2SyWa=xu3Wx>|0teTXgRq)#b#NuvrSFWKyd7KAt5j4e zS2TNcceMPv(JF6Mim@_xtPD2FSDRch!%#Md!M&TZVSVDa9PFLFn|pMqRV^)_B$~S# zIy953ZGDCzc77n7iCzle4aCd8fX_2P$RGUDK5@oxT0k;2rGW3IEr9XKA8&Hc|B0Ry z1MW_U%*{7Po&OWj!A+mkHaR`XJYHZv3@0Gt0{l+n^QHHeNnzJyCo$eN>dU5pdT(I^ zj9a-OxGeG?>*309337W5pFWuL)OHGZJ@S_*Px0t%SN9D7{!Ee15uotx>5AY%RxAX7 z7UPQi0maC`_vek;a|1E;CKWKrgt&m*vpa_PVbubs)KsrtsmOE#!NFyN&TC16O<~uo zoYOivhQun)#zTR@^;3kcS~`p>BQm>?5Tm&>j zwKaAUE{%oW7}a(@S=};R+ofQ}TXaA$Jjm(dWx@vblZkos(g_$@4~WVK2y%M})`FU_ z3bdxeLk;CPfWmqPxl_l}J{`cnZ6tn`|ga~;(Foc&Z586jvajB9y@Pz~OP7^G& zBdC=rj%I#{Dpz%&99st!aU$nMVaG3pR1f*)R^GIKG55M>ZpK(dYA>7lmnA}))Xklv ze^MkM_3nGORoOj6Xe{ZwtI0h9O7_C*Tz0AuXqFlsUvt;;RP z@&u671DFz^;4Ga84A-#z)Wdkehf3ydVTb??$17OK)YvC%^+YLc5%0FMRzw`jXXw9r zDw`yPuM3Tk;c$**yc;a8A)!Dr;b5l-R5yjhlMqDM$yYc6M{lVI%lt{0mi@e1&OKBD z0SGK-R5!z97JI4dodLO7Lx16Ax;b>O13V$!JncEeVrc3CTOl#hML~{wd4E6YD8B&oR}UMYS}N;QNGk(i@qoQJ{F+LkGNcvg zN{CF2y#!Xj+V+35Z~Alz|Ne5&m<)NR9h+FavOE#30H!w1pd1YiXvwt0d`6|{@t8hQqihEI?OS& zA3jjO9}m=QKh^?jIYZ(?u(BdLb8tAtwkQXXz}#@@Ggg8xVAOxICDw{Q!5+HVn>}-g84`Sd*hyY z==cBR$*wjM61^rEu>khc^ux723$J;rQC&N!0?dV^=%d*oc;)x|7!$mvB={r%QS1*7 zFgQNJFDagWG)T*JLg02MLqv%D$W}5ut~Ta%5q}?mfC#@`>M2^2KXv>mjLt>HlC31( ziru|DuU-(J7BwhqRrj(%=K-tt%+*j9_Iy;P!U?{fI)5n*0lwf;`@<2?cDEEvtIE@A z5eqH6V;rmV>cat2it{$*RUu7fRRLdqWUtTIC;mJAp(fIBj}lISoIwchOfrW$R)Iob ztR&-t`&eIzr;KK~{GEE_Lla8+8@q!(mZ@upd|+<`Ade7P%%x6&pc%|YtNXB&Huvbqn<`bt4JqFGWx6wq@FNKl(e*#HYfw~}vC zHvljFja@st!RVCkOXT5E`#sv|j1$mCrYn()n=vmzD!5qdBKhco?yG_#R@*(%htT*` z&DW5PMfgxHK-6$B4zN~>V!&Rv?eVP5AH78Ahio5){R)IlM)Qt6Wk2 zwJk|@?S|W+^M&do6N6=+aZ!yN%j&i~P`dZpGrg_bP5)@WV(eQqjGkxbdqH+&|8NM`^k)B2y5qBz&M%NMbQr%cHl7gM{ITClTM&+#FplBV?y{$sd;lA>FJQpZGyNPWVVD>Gtv%7WVwWXBICL=ekpz!)9=F7pAxoJ#2MTrSo`XPqlAnMC8d#SJCF}#R) z<18xSc{$VDDZ4u!Aw8a4K;e@Pu{$3cMp$?eLo28Yt_^sy--*ip7=~euJWvr_OTofd zfD#i>nd=Z#`w*@rmmdLE6`v@M;nt@YXov~Rc&@Kd1zbhpgdh?0L}bz_IFK?}cH@A1 zdkHkY0}q^6HiK4&j$qzCYvk0c34KlFq#i2gkq;vx-eG%|E`XR4I~z}|B1^!m0k`Y1 zJRm(tVWq`NwJ<|ftkWQXT3BUSECsv2Oq$@HN}rMQ(HtmeijSm_NJw@%Y&bp)35;1t z`V7$We#fFP#gozej8TejC$3+7+&(WPN5`5D9N4m57}WL7 zEMPR2E&Ae_2@O_P>A;n)8ouHoEbe6~;be@r7{?_Y0>-!`mew@{GjvhP9-Utq&e!FHWHd{CN<+o7ESuQe?&KDVtNxV7?=H_E59|(U!rs5D#r5ujOKCR zh;(39ufrjS-t`R(X=PlD+Z%il^0fW|h3B?)A%H4qlwP;L!%XVR48|5gT=_$MFl`HG z&%y~mj*LuvaK{?<%CT7F8nAy6xA1VlYQ~-=%%wn5jhf{5v5U8-2MP#vQsXJnzilg5 z#|s2Pez~Gu5XGK@gL5X#&lfm^SMB+@cxW-d_dtAGaS|=)8bWTzZ5hbZv zqN6b2;!}9OGsV$^2KtFDF*3S~cjyMnz#y%HiijQ&qLZ^ zlwl(Fp5B{8fWh@_3LfSCp5|N=Wxi)xypo5@CDHgpYBr)4le=e8u>B>}=~}9W88u{_ zjx$%4-o!N}U{W$&BKfxdVH$q@$x$yNDITk^`NM2+%wrHQx3!2E5$KN$VIv-J&nn=j zF6P?@@9|c%ra=~6T_oU`Xu;GExK#QC`7K|bB1z@s;!qy5LBfY?+6S~@n(QPP3q^L_ z>RTY{lhZAT5hc+Yj`Q|38~17%L9+<(l>Fd)M;i7$;uBL3QI9Q$5b7&nMQf6@(H?Gu z`N~-|)cOtVd+Qs}3mT~$neh518~1vbNJJvE$4GoXnTrR%sA-R3s??%R{T;lp@y6Kn zjl>H53(6n3yP6dsMlkvJzM5hnN@aEDpeDr}Ssr3XwT7y1oM6FV(OnvI2HPuox*_GH zL&0%M0-vFG1}1Sf$}3T}_2YyO9_>Bf#)>2(%wNr%J}EN(B~}Rs`V%0PB^?h@s7x&d z)jYkY8yE4IP9`lToaoY+=^HplYb7E9O<*T@?)_|A7CdNffp0wMnGzP({>s`k(2v9b zoznAnBY(y>c1k^LD_6j10?kcEyzAdjpV^bm^E`zyWtSAwQ>kxZ`R){h96IQ-Y*Kn5 zFa1B<*_kP+!g(^g1EEg}69k9iLC|Xa*WsgNF_qh%#$|4tT;{ObrE-LM#QOeM>?~sF zO$=Cs*x??-7w>;;5(m?;?S4+?-Y{*#;Wi^u@rt62g6kNbaLEWaBYTO(s#Yg;2{ zD`Q7mQ(Ie8E93v+Wc=5e_P@Cqw1&3UcIH;b4z&N9#X*e=HU$6=0ASGo0Pu_C`2T#S zot3kx`G3KrueCKDH^vZtVbW<=?9Cz6P?6DxiYD3828l_I2!{+tk9Ez$5KMZ+c=ZW) z$8K-8rJg24PQCbiYe)Dk&gbVh=eL>im8MFS@Pe7=Kpqkz87vr!8%nEE)x4N1pc_UN zQRO1!xyN z5HIN+MHq(TMlTU1?IMfnxK6>kmY&TRZ)+VE+g+C}G-K``1;Q{eW5StQ&7DQg_Uqmt zGXzU$lojrHl%R>jww{(4U==KF#eukeoBTc66jiic(d6bHZi=Oc|weI-)9rf?qeLnq`6&gRB*lEh|dJ4)k z^_P_M(;BV|U{RYhhefr~k{?$a6utEp+iJ~mYTT?q{kHhF|JxI)uU$yFS9`4D~E7p zBaS>HNhOJpV@`w`!Qe|pcViDC)N*Ho;OSB{qEHnG0!WM+ox{sdEQbhrHYKs6#}brx zT~ON>*mWTKb4$3Y+-nD#)2P@YH*((GZR8D3*J@U5^z^6h?_FqPaK#zav2RllkwX1P zrGQU$Twn(>M3irarz5;_gomW_Fq6c1Qh6H&2s@oxa#~*G%1143JiKmpYp<5BkBzn+ zv3ei;DZtQoAG{ivZzHeeoo~Z)B^?(33$KniB4ms>Bv73z>f0vb8-Jm&4c*wB3-XB> zlHm!EOn;P)2@(jzE?N;FwRC80pT0L(i!~^w>rTiVi3vYtKqfNka9?JfGkIV5+YOBU zW-J@a6YYo=tyXVN9^NG6h?Ium11DPX7Z2tQ)aebE%G~3mr(vW& zd%kianii%Uhhh=HBZF!Wv3|q6Y`pz-aaAQ;(DMtYFwX~?WtxVY7nH6Pu)y=XI6m%RnJvT@Q0jJq)abXNHisdK|Sxd6@g}uQ35lxAk1t4 z4fOqbrSePO-ot zTf0HUc0!)>@ro|Rt1KP8ds7S+JC9{wFdh`GRuFiktaju8xZViZ6KKzk=X$WOWcRqJ zd8TCmktujG-?a?*af!?MeL_^R*z?9Q{lMAMx*&rH*(-4(K(~Tcj}Ps;!NI>NMLcJ% z038Wpn&Vb-Aoi*r$o~u*{~0WG4;M-&9ZZij%OI(oW;9q!&~8}-QJ%;5>k~@rCxl>( z8-#qo(E4d&jqkC1CnsVTCRimIgJ(uBP2QVyCP_X|rZ;@2sd5HFu~=vRIU! zLFQfI>t|j!7aR<^x;mJ0&KW0LJN(L|u$LWw41tEpEaLN7*4N97tP|6&)xf3SkKr=; z;yk*gfKbdXTZbN6A3mq?FP3pEOM6)0`WxkRo7ktUVt^`lu-8f-ZJ&CELPRo|_hQ4U zgH2itcjnuyf%oLfcO=sa(wQWqLH*c*FpS_YZ!A25MrNPau1+X;zfgV*nAh&=mr7NFxSCsT7zq<_17bs zh7#*weUBj;oQ8r)Pdy-r)phgKyrv)eCGE0?hpR8C?;nj& zbG{lNBL!yn$Cx|>3^P>s&&H3wyI&OJA~=UG-5WQT6Da)~t4mfVzW!v+b4Ny8KW?DA zpfOncBOy4i+e%=M8?5$7W|Un;rtYCuBw&zhJ189G(D0IsCF29rDsfa``&oOP3((_4 zoQ*v^EvlvKfN+CjRfj$sbkNii)x0x|ypffc`s^cafQm3t9d4EY@aqNg{bbx1 zm1bkZl?9fG{2`qyb4){-eUcP3sp3f4yjWR?p8C8Op&Tr!S+j5p!avfhbKs;ZR&6BG z&*bQB5reaTiy3gaC5Aox!!sbb@W9&qIX9~fp%GMWng?rp3FZ$(PE2cpUMemxo3o;v zrHEQ97%D%TBpQQ-ixZAxz-L)jZ$jn-Uwj)u5)`f;7Kkdaa_wh6vGnUL{f5ucbJH?n z`UY{F06u@%Z8C*Nhz##`Bt#$yMQ{iwP?R#`^9IdvnC#LR&w|#J{ch|q`A(a8vCm*y zQJmtxa10&v`BJyQVb5R^{bP=M^ZX0F_+6=oY5<)?H32wszs~two2v67Z(h(6BLO>* zMBMmXk-#{3mWjCxp`@9%>JSuPha>rj+k4J z-RZS)4ISn4Qb!UK>O%MxcgJ@1~5?^zWBOn)1 z7~ce})qKv6G5r4}pzmhV9)T-Vwr}k{Wl3-uh$I>;dseZr*IfwPsnQ#|{3;5uIsj(L}O9_qA%Z_)74>f;hhw zVQeG0yjC`yFak{R>65&tLjGl#_hrJa(W>teHH8LR^{Q;rCCDItOU| zataA=*d%bU=_r!!HqwnV{IZr)@}7n#$FTg-mK`&iDYDGZy2ec=108H%inE$jf@Si-L$hsFvBB-kj@U}0 z(a{Umi+&iXsq6vcA7avF+N@9@0j6`2R#E+|OgTzZV*`D1)&kjsaF)LI`%1kkS5wUw zvs@-G>NAhW&tR_xSqr5KWG0ZR1K1t4q~v?tTZ&g#*CJyiJ483dpCqnZ%jS`43? zIclnd7a@TX3ZG~COybtRNZKGl)4W~!RO)T8AIM~h>qW$kWoizv{o()OB-98c*67|W z0B;2Xt~5_WRL^fKjsyL&u*HKbQ%^mZQ?V#=?1#HzJO64kM-4HT~2t< z@?cU>p?Y+kO*nXBZN)4+oY71g3r-*r?>tEn?__SqD@2KCwi6peGGnMOnGsQT=^9Ua zKj)Jgud{j~y0VBj7pon;FsSnY^tfzztbTbcUT8PhYLwm;V~plsX1cU`FInok(r&8W z^5i-|shM)>*4A#aZp*;(%KSxjk{QnzW4?W8>FlzGe{6wr#uVG>c;M)Svf)A3I$6G! zKI||cP^3Y1*B@7+VT-z0yJ!ZjsyD(x)d3n9T|*G-uUN68U!fpD|8gmZVSdpe!tE{{ zwjr8F*6QhW*-S7R+)VhRJC{_MZKRfQoa_CtEZM0U=dUe&bmLGT`HAOqYTvz?qSYZ% zBZWSG6`lJSPgAqJy3PW3AFV8DgL{Dcps#HKF5ut!cZ8H4z=O8zo=7Yjn37`GF=OYt+{$X(04+fT%K zpcsWr{a#aRJl`5wmQocAgBjtaLva@BzjA}2LWP4aF#O>mM=+SD#nrBE-b+eEXJZ=f zp_pZL+CD5uwzd8EQN44mPu&Omvg%^>Q%3?RhNcp{3rmm$)qs}v94Q)rBGkjGcNvO& z*WbRqCSd|FIvWZB$d&AXV)xm_jz^Pp;H9Yduase$6O%1wuv?XTcM!OKcl#fx9r%vJ z0DWHQYPd?<`_Qojo{upjt@9IQhDEjI1S!*5`YO3<_q)+i#}zYH$y>adk*%=?@ZZ>p zOl^=(5*JCF)0GT0Uz&nGsMM`4u^P{8yO_Zh$!fgb{eqGdGowvMO!wgCNC>Z)^^zo! zZybHJSeP{Haicc7?17>t^(gepT;k`3;T^fE9kmP0aR|F>O_~N}U0EI6A;PaF>GMeB zer|UxYssB~$La~G6XOq=Mm4LgLND?~1zjr{zCSn2nwn4er%(I5TjC&J8;>0QxpV;$ zFXCswW3vXD8??fI*B(KlVAfbwAU#(-#*zDb>aXiyofY_bnY&fiNF(zOykj^Zt{B%) zUg9locgn3$x1=}~NyWsw#v@4`Hj6pp7t&osrnhvSw<{CT8!;>h7tPR5^ZMn@0}4PD`tHiWFi#buQ&&1n)l5ezg^H)|YSC|J`4? zTC6SO`emb+BLe{N|6ltnj<(JYhQ_*vwl+@2ZchKhM|a#1f%~4RRV&RVU{D*nPK_yN z42Qm7vp$fL%813m&)!3@FWt$ODj)Q{z1a~5$1EkvPJ!*(n(X2ExQgfpsH%~oh|w>3 zs)7q#Fe@!p=DE9MEmx8GJU7s}h05W%?)y#3oD?|I)VC>CdkBzw6e=c2M+xxVte}dn z`wc-@&?q;#9l}j!GJqCI+F<)TfM(4{85h|QSMIc@QtUTD!l-}vA#jJi`YVJZPl#7v zVbIj>OVLU+4KWEIA|Fj)A+$+H5k>N0CWzK*MGyd8B5-sk*}!bCuAZ`LccuF`{d(eZ z7{eQc4_PBaU$*)SX6xYq2!h)w~5zjSm8m`kPKm=7M2Nw+AO&|m@CL_isi zBXyzuMR&EVsp8i3G!jMr#+a!1B$}F{9!@0mjfp92r=bhk@reFo{N?l7f*PRYC-(S;auRD z%VmR#$g)ZKXz#E?j-h+z!mp{o?oF_Tx!NzZl{Q{fiL{|`s1nMyCfia|4n%$VOn~?y zqQ7mP=28tD>VY)d(jjsIl?hQpZsNq*L$W&3XKJ~5wBwD)MmcdQ_NaN$_xD-*uDbP+8<1K=i^U7i9 zXkl!dh%e7YlduMZ3E?F;#r{T*8-UR|Xx)z-l7QZ+m=Ij@5&+jLB9*}n6*!%pH94qx zw}}+kBno)s)6!iC7^|HJ-Mlu}wlmu@3$#aoWpg8hDJv9`RUrE53A8m0kK!Xf(89x; zyh}H{-&MheiZVl?*$mEjoZS;GYoVQ6HH8v?QAd*jhm#wS7v7(LI12pquPwP&!fj$iG5uieec((O9$` z#&w)}!!?>f6cZ`$cS)+{DS-qxX{kgL-5RTh4JG_JXuA=SVB59x#VX2AhUVG$(*mVG zm)>8MpThKYvsi1;yhO5DkY2t*rUFtBPeENFrKQ&|#~K1}j88leq0f$LmW6*hNYBvH za`fI3`fN zG2A$4Uy!A(dLV@TnjWzNJv`<<6i`mwiLX#fqX3%0CL(v0`7{?0H@ZoWDS4i3dexf) zxJLTgnqmtZm|Nc0Kas`K8XIL6reXEXS%&$7N2vzE1#LZrI@&c#Og3mWg`M@p+c{GND9cwZRIrTF)huI8xkl&%^jp`~ zkrioOEgL7G_cb82Lj$UHDuF34;C;Do5SN-J;k0nmA%-bz`?m*auw6HXb+H?s6C@nq zcd%GGlC{E|KEZqsGHO!;=E^G#k^7aiSHRGr1>eS;{V zL7bOhbsfI1-u~oeyJ_>DzE*%?NWYSUL$hPpD2wj}+mgklkite1hWQ5hecSUrw7noM zaLcW#l^@gddwXYsqiE#p^oHZOc-)>hUan%GS%1G z`k|KD1Nfbj#N-XY3VaD*5eCMdR!#H?u|$InLqA_mFIr3q&gh&(c4`lBfn1A=i`%-g zuCr!l&1y(dgEYHFfp}<8?v_ZP_H*bjn(|mNo%V@7x@KSh76CuPzcyY%xY|zkn@4TW z{=Cp?_fBrl#4X;=yD>q!Bh`0O(nfIk+D-j%J`3>Z1-r(%uz9czO=F1XRM?Ay^dD(4KpEpEOvq{);0-2@-?- zdE(}|swZ=)e~L!cZP@5cWJP5@TQ}4>S~WOB4Xwe2-cEa;$OjQEvul$-c2|C@E?65{8YF-O#=LwI~dSayUQtcvzf+3Qs+My~?KX5O_m;>!YIv zfU}-EUpCmHhd@-|Azh{?UY@sNrpV|8HC@;2MF+)k6K74mH+4bQlShl2qvHXuxwT=? zT)%thJ$u@X@o_JDxC#34K)ifE^61$wtwVZ0tpvvIwNIvqO~7+?$~<~1e)zq1TM(h% z-G9SJcF*=t9SW;;y=eZbV^Dt^7Z&lN*7(C?+Dy}OiQnQcSp&(>-{`}m=L$rqOXdU% z#jv(=p)Ibvg4aS$tjCPOxQ@b;xt3CU@UNbf5s3V2N^mb~przh7PBX93Xa{g{*t?Jq zE+@ks!)(oG7LVO^2oEhDC;_JH&BFwX;7K9rRB3Ue7DtE`>H{(FY`) z-A4gbc$ip!Z}I8P7%@7uWx{UnesL|PeHK%F0)}V$3NReLfX5`8vGGB;LsgtmeOBfH zAKq>RX`!_I0joN(FIj*Deg*q*phlk-NP|M0hiIR0T!IR#H*<(WzQ?}9TetqRf?2G) z5Vwr^wM_C4rq#g=h=8K`eoCqdU|fF;q&lR_0dH+}MAz6fBw;-F>xzwweFd~U^$DoQ zA8FmshUL4XKuDR9{(+WimELrEZsP9XJ)pYA>~!z)u5BV$#oN;_&M(lmEd=3dzBhyX zq(*N5oBoCD;dZKJ1JB1Yr7JXz^!bM(&2ydu-7)py&lT{U`DIvpwtQ$Qu!{*W4pqe$H{=cu{2F>yEL0xw z9=TW;S$N&*$yqv95;P?fCQ!g7r~y0%ZH1A5kZPdPz^gA#$KitS&#)_u7)`4^Mk^3Q z$H(LtU{(JWOj18c0*1Zba71s)^kXUF+f@cT%i(W!&07*Xb#<) z)-9m-I#HT52zpX#y79a`OlM(E3<7KU!qaF`GIoF)i(7?u6e+2zR1h3R4= z1XU>Zy7QtwfUVmm0bJu*jAw3qK!h2DtpMNbywHy>P9j^HAt)8+O36XU(GT5rmG~3( zhZ*|&kqZA*bH0|d`@@IB@10es7g;x&&_CO^bAam+f0F%$muo&d{Ip z8yXW3RI+ArFJUOw)6-dlRDpbld8ahW>;dE_+C}-jFcbWbZt6MqxBog>F@E-mq zB~KG6q*M#KX=Hjz$AN)1=peh<$%JtbTA&FrrRj?Gn}`w8rEODt%_q-!C% z9Ki%``*#L9df_e{Qvdk;k6BBfmdy|bWe3)x<9Y`*X5n!}it%A9HTO7DW-WeI;^BZ; z%n|jWjEQO#IBIbtj+@+tVq-`!0p2ZMjzn46oDGgyDVb4{)L?PB41`yKnxMrAYSWp> z?uHx!bQ*~vMIhAa2||j73TkWy&rorY<4_wPE}){%rLd<@Y(vhG(GscWGoQgqS!Ls zNIAyzHn*Idu2<3kPFE)CK`UBQ43&}hC~H1UxrrK^7a4dO>5T~Ii#l8%JBd9>U_MD} zZb`D}mQrStz?SJIyu#TXu?&7r-N}$qV%)3<1O&NJ(73(}Q@b7vR9paypZqI_620;v zYZ9DC(1&EM*thVU$nn&eRPyxn-9`HO<5v-ZNgZN#sh7y*_$SWE>%@bJ_+gtC@X0H}2e8vminV^} z!Q$~}Fdu1!&-ML(Ucl~&)s`rq1=^E*Qi|UjFxJyq7zT~XrDK4`ZOxAPA%hm-_!G?? zRd3FWiAGC$YL&j*hEq0&-BWO}+I)8-s#32(3p@yX2=6)x;m}=DLExiO`p3oi@~pq} zneRj~-X>^&BgrLA-YrN@^(t60rPC25iWTt4*3t&C$JEtMXjLVRfLGFNjAt>1!g9sIf;E%%^!FwX3jw5u! zFwTuv+~!MdJmfbn5D`@vc^=Gr;SCW*R)p5|h#CZ`TFI0exlsjR3iF*BjIPASOW{Bc z4@TT_JGOr{ z@FlL1O&vW+xCY;~I_Se=6$73XSTGXYoSH5wS<0r<>aHv^vzr={cHnwYI6_eL4H!5gvZ%E6 zZ7ebD`yiTPy@r+Bji{#^>s+cLxs-*l3h2{-r{9o;=LpM=+Dk1o5)Ph2#`8@&rxg+J z7gsS=5hg(F@Wb-n#^@Dj-tIS_pvIBn$^+naFH`4NjyYcX(|FF88JPw`2nrEF5 zTCt{+K3v?zb3s$KY8tVN3dLIdxT?$CHb9KZU6=logEIY{#c-d2aR!DOQdbEWk2p$0 zg#Ri9XbS(z(pa0HbZlpJN_T|(>Z3^Wx-8{(U7Bkwsi=pCJaz8qh}Gkqg$gV2&NiOiD$;)_6_ z6??kSd->w6HGU7;D5rWTbiVj9<%gEQf-(Hy_#`T6s^f}7iUC$V^av*8_W88 z6F_^fOVmIQI5->sxSuOGJ4sKdb%181xpGHn`UlOPpx&#CnqM&+tDjxV#LNMe zxjyNBR@{+r&M;J~iqGR-jy-E{HfF2Fuj85n|I}`~FdOn_bmr;TeP8f75;eA}jSKwQ z9*~9mjwy@uzWiL`mz&gzi}bt(4Mrq{kgKYKW z;e|`-@?YA855O~v92I8oIit>6^$PGW z4z~g5>Sux4)A-sgdOFQ|IoBL1=-MYys}0BKwe2&m=S#{(qYvI6=Z@MBAeZj9v|~V8 z5)vrMl$1CTm8udQE6P;r{Teh}peDny&es!sQQBV=yWz-VO&wv%1i8nb3rW-F#_3xm z3S!w!^X0a&KhCtar$^&LwU+BxN;-8#>|`jgacs~NAVzRh%%w(6101-^cnJ?U+MEKb zbu&)$!tvbGBwQ6Q>U~PcpHRK7E;-Gpo1KVLgI2EToCrE_;vH7XA_E5m8y#)0-Qd$s zm7ImWXmi1se$L_$3VJvrO3eXJHhH#xOGLcyS&=8cHG~jN^GVzw5dw+v$@B6-_j^{H zgr(lqDjb~iFId2b_G%_xj_nN))TzXG@^^Z{=e^-awR_4gfZy_Im!Hyuz|fUQMnZiZ z0e>`rk>7*P5|R3;(*zeyWC|4cn zf{#EN32+iB#US0qVopkzl4Td#CWvzL>|=ORwMZPpX$4;y@z92D_j47>S9$n6*bbba zTl`p&uPoW&6)`B)rNF!J^!*Ep%yjBCWcG#P2AMSBRoKoUVNBMq`5F3THln`rbOw71HOpN>M2P~Z{mA*LxnfE0c zgQUHXvp8x9sJb0e90Jk{1eKSuU_%!_D^x8gRTKs>kz@H>=S)Mz;*^n@i>`}uwl>+2 zhe}Dbref6s9Fl6iMbAIjR6fftd_e>s-(pJ~@Wk_gajy}46Rwm}YI=*xO=Kci$gQ#) z>kjm$(^;J=QdIUqW@eO~RZf#8R7)KVr?T<=YYi1)HM-3PM3>bZA*~J#Kj18MMb#heeB_-0wgdS0Y!0|DTSleIcBZw&NB(GQ%+SeOg#`;IH$Dy zt!AR%TA)m<*mM+Fskhjsp@5Xk3tOcMt1cUKQc##T27k7}r`vw`8 z&$O!K+?YwKVw|7aKCk3KUHF>O?0`(Lf9(=EnOhq>I_X>6 z{ik*_t!}MI*be_6*|wVE*`t_@pe)G}`*9mUjYvm+)OW_b3Ww;?)!X-GG7Ej|RX?5` zni;6Qbs^K+%CCN-80Y6IFN?GZU_f*gOZ994|JC8A<1T6YX>?8L0TM&Y-rt zE_>8nvc@D!fpf45Tn~O@rt&zEW(g4AHWLMN188m;l7>b+-N?O33X#NS@?_S~;MK{X z$$?p@=~2ZXon*jCBpHy1HGzA+SiH(_fn(K5?dk)^*jW|s{uyT&OcUHSQuqO(Vv-BL z;kI1wlEq^Pebn<;qUNS^-5s30w+9ozMdO_)tLoUtB#~6LPX*Z`!tDIo_rI+ysJ+jt z_p9g8`*m;`TM^T`RqL0srVri^Pa2Ig=Ewwt*bK!#k2uRS*s=q`4i5Sw&Tg5-9w zj=MqF>Q?U8cW2}M!YRb3HtTrL#`?ZUUzLdwK*9#6O(zUnl2T)w#7O+JengDn6b`UR z3|Rg3zlUU#0bNrV|6qN5=B%f$p8=aRLZwh5bi=V+Bj1FK`BSSgI1odV!w2n!|JmSz zck5p3tJ!~R9SMl45Yegk6~D2uD~I0&N_ct*abyfG#%hR09uk)g?2w8PwvmhRHziLM zC%=vt!b@KwUK3wi{3%y)3lw{foloDF-1qF6bG$w-^>Z)s-7dNMWu#3vWDUjlrdpS_0d;l)YO2gJ^chh{R z#7H7^n_J)Kii(Ng<~Q~tf?p_t#`d5c`w8=FTd2ie4*lY}!d621O$pQW{Ym(wa;13O{Bb}1H9da3exA1U&)%*AMC4DY#;u7|kC@WbBz!1! z;>0{q@nQmOmeXUHo2e;$i5hj-rqDxC!5y|@8oS25$X|^_KV>#exy+)|@&&HN_>ri!E;L(}rQkO~yqrUGCwDoRE9O~kfh{bR$ zFX6%`_9`R?fIvYMv{7S!>50qWa7+P{7n|I9qm!zc+Pgm70uFt&B{=f~NV}DzHQ09l zEbC5XJ)xMapoj+cBke-lt>9cXv!*Ie=a_1H!}xeO4j!rX5Ls=*A&1XVwAyedth!td zj7)9>>*(76T`;O@$GK8O?7}A+_MI^@=Yw!{i@FMcvj2HCcsFNS1N zNG5jeE=%j|vvOL6b}}i}Q2bM;3=~jDRkFb0Iem8dQ8`hH@=;Jb{l3m*xY|>wAfOm! z5V3WSWSGud5J~e9=kP1F7dV=5_ivRlo z0RQgStlztOdn8|@({&QJl4n$aCTEIaWy}~Sv!4sGD7aF@PVyA`tGnQkGFqlW+lnRy zY3Vc4#k&`w)~JCkh(ch%G!~k0OcQQu>eJqy80imV=YpoJoyirD-gc5Yc`k&hIeyZR zbdM!dzap0UPL@^)9ST(mLV(WFJDkkB4^X!s+w*#!ks^9;PhnF)>E@`iBz^gPWv0%= zxMI{0r>=62KLzvn%M$1KRdgHP%Vkk+mBnUMgnrEM>HblFWS7(e*{S<%ScsY1pl$nq zl$}$IXi?Lq+qQYyw(UM`+qP}nwr$(C`?PJ_?w6wKvG5Fbe`Fuzgz?9Qa=aAg2ny6eO+cysu4XoMcc@WTXRZY#eSMyc>!p6< zJ3+T0i|H=oWYS7~fOIH4DZ%FU8DZkSnX81NFKOA^9ScN8W1S!|1}K8h!8r^2Ok$cr zf>opv#Y1oZ+R&A6&DV|YgslhdaKxn5%X>GLv`uk;-D2FqwVCKD`F!mX-Vs+O zgtqpa~DOVf)qPgx%JP9kiGI6ZCst1-j*|h(rqn1DQXjR!Y7%oOYv=R zxHmQ{u*X;Itx?q(-tv`JPB$zj{#pROwV>6#D1!=q5y~qwcmO_xD*pIkf-118e#*ql z{m|55WhP}h82-iGGGD&FCg9-lxx?T%naS(i&DmhB#WdA$16y~Efc9Ms;%3g9wsrZV zvf;r|$I}&J^SGdCaA(YP<LLyo5^}g3=Ef9fvG2>e`o9bfE;#mwrqh@nb{l9Ai zrt(Q(izN)ZC4r{Lr&fq23(qraM#S)~v3lm>0&kXlnS|(tGMmIG{Ic8af&On^lbK%r z-DP+;zY$i6D(B&)X7wd+b;roHrfuZ;ifm%rW|IbM?x{AgA}YZCs~<}%k^0I>akdpGWO#{bK)xze^)+-O7lElNS~yrI@70}%sis=I38 zU(eq_q7AxoadE*&B;RFSZEpcSa(TOX!4dcIO}ttqj`JbkRXf2s>k=1@O?q-RI7cp& zbsbNx(TzTRU!9}*a$wYIy&i4q|JKm>IZ7qDhjwRD5>@Ig9;ueNSp}}prqalzOeLE5 zW+gkQ7m!Z;Y?GH(T&j^d^ChbgzzY)??a)sT!V0lL&?Y*cb}M>Tj>N}fWJsp6U=0x7 z^>y>fvussrRMKX`8w+J}$lHk$KpG^dFbk;RB!+5NhCwAE?C^j5?e-}y1Z1EA-RV)R zwrsdRyZjo4YQjNST2!GnGsTQBi9v~MMy+ZWJ{Tt%gX*oJtFBFa$v&7l+t4|Q!*PP= zGO~`waT&L773B;S?KZ}s7MzZ+P`3nLGHP;bWJ_r;Is39)|7mFgVN9q!eog2Kkf|f6 zsI~*GT7yzk-!iP-GjxO9L4y~0FJ>gHO{p_w!Qu5L|MOgDb*>-KU+qSlPb$=PhAZr2ovzZS0`q$rmw_0#qzg9CZ7J1}X zeWBh@i4T=PpVl(gvU>CjhA8fd2cNO*tAbifG`VqdxiWi`H9?fi{O zbuZXm_JcN?r)G(n$(#Z-i`LqvR^OqF8FN6)+xd9t);sKSQT`PaL`xlXQWvgv2|&Uf zhE01wAT!r2l5aYG{P0=Kn_9x3FG__W2J?XJU1WGuiIc$I{KG<92>tR5N!a%X)%xTw zwSU5RTWG-$gduORgrSrbX;6I{N}+mGM)PV5WOowt^LG$HDbnNA)a2Mk5zfqU_t^j? zS!fLZi6VmS5_>~ulwrN)ZBm--$c?H5KGsl>LC51DLE_>wxS4V*yeIvAm0VvT$U{b0 zB1DL%Hrp$v_6o&O7ZY5d`G24Uad_wp9VlSvHE`U&et2C?se*g68z- zU?8yVZz^_P%res%X|@26Bc%Y8-jR*2t=KP^8vL6b91V|;71 z-BI`AUhmo>Qr35hnQN}bOZ13wJnM;n1?+X#Y!~Ei2lCq=^#Ih*3bp=u3)A6^xF3n=h5y`7!MAHc(@{C;M|Y%6*h?<)dY2F zq`f(Sdo@Tlh6Qx1uR|p-!(7gZ<+0uWO5Sv;Fl}~ zm@`&fo2_przgls{u-cPT{@t5;m$C+?3y4c#6z|*vxa}+AQfCFS+u;u&sxf1OTPi+p z9&!v$G?YYF>H(s%6pG*V`d^)BjV_gCt(!=wyLVMbT*~@IznLmsj`-@GpOw~~v#)F$ z7%%y2g%$rQ(*3N8C(%Se9-t?~_)MGvwD+4!n8E z__sle&F{HNUj@QBv87fgj&GG1Ss-tUHW3(okY#Gt>kZ`J=QM?pbqz}%zdH1%kEhy_ zM}h8#-Q}4-A?bmm2*=YaUblP#5r}RS9@}ZI%Rn90m(u}&j}&aoaUpD>(bd+dK%eom zOep6|$jYtndJLg4twsQASXTXp*}?!HMSfO_DV3ZEk^)`$L?wXbS|9fSrR&EK(%W$* zH>;*>xM0CFtS@x4*K)2)2A}4)xWU3Zd=vv_(?6w!_!W`WUaUUVat@_Aq3KTx#bV$w zkhMNo9z5vAx|~&=TQTobh!d@YrE_v0FW9mCeJ1wP7;RL4!c0Jkm1xKH#yAfmBttn> zSA}n14rsUIus0& z2y#HDSMylf-#G;AACZE95@fv9uuB3+wwC@$YJQ$Jf^&pMm|V&9LnKznC{JFFs&YcK zfiQu0Cbh#h`6^}IJcF&Z-d1wFe(*FkLsC)OlV1N!cr7$QQCSEqbDaxQ`)usv*NW0yru~n z?vkOnMqOg{N%I;}`f}GF`0IJc{`7iZ0aqALeKHFPed$4mEOz15aU`pkxVIVJu%Ogp zrL}Kr4^l@gz7~dn&HITCDsSw}p$?Z;tj&YnzF7n`viivv3os; z;sM)x7Iut^rw<(dD-s=V&wD2}&CXiLKEefh5iLDQ2tV2C7rrLtT^y`jj&4lZ_m;}Vfg zO?D+1It5-Sqwjk_R|?UXoT3n|L2SpT^BbTR%}67zRxBNEOm=L{rM?{%5W=D30T z4Jg1fBt01UdzPTEOYbQhMzSYJ-Jgq~Es#=fF=&^`bEY$=kEXA%G}5IDZ8*Hv;~3%- zGS?uJ{`eztFRoD>gWGpP23wFAUnE`-!0km-g-oRT>q1Ey*ozS!6 zQjQn6nwzRqL)2w)I<6pSVaee9Hwne?NR(zlown(Gnzq`{Y15&|XsZ-8JGb@t9%4K5 z>?=r0W=tQz&%x3OggLN+O>ogv29_h_jAMfAzN%3nB1aE(HRN8)(WBW(ZdOkd^7t?| zt#hZ*Br;KDLI6ajXAd!9Ly6Zn0t0>)8^~~4R8~jTNxOflACUhJToo%%N<*=#pdfwX z5?`fi|qCqhedTP@%%uShHXslHC&oO}ToE&b}KLO#-2M!Oh2Y^;M z7$u#FPK92Bw-6_At7x3FXSBS-sQ0^sTP_j5 zbYRn$!!r9cGWcYA&kZ3S=m0l(R6V$Q0TAX5bw*y>>?Ox7XQ90;TWI>>B3nbr^|yu| zBKg1Od?w7$M_(>AU9%+VHv1>cjmF zhwQBXj^f7FuKrc&kv!py#ye8(sQrbzT+w__BobNVrIrfffcFH`b8gGNf{wj*lG=%4 za_?&}9393~#jR3mG+!9o%gk0N2MN*n4L|2Zi42iSv%a*b7tMYzxP}l{3c?U2(9o=F zRcH_dh5l{P2x=Cpt!XXXVo;kw_QTw5Q|u~Ou@6g@U))G9F3=HHeOP&N0qXD&p0hs` zXA#Ej)xwWK4?7qY!g1`R26ngB?4%^(E%jb zXc*VLr>EH{Bf&E6``Zdii}iJ~iDAsY++b1#=M_}B2Xm^CWMB2zJ0E7hc@l%L^$aSk zU~I(5|5Uh_+ndqhk_GDCLogyC?CU}=({Xp~2u4(DgEZWSUKv2dH6|IZbq@kC-oML> z9Cp~ndSApaK53o`As#?>x5TYytS-;(N+d3EXfBTsG^J=6b`@{ZoPk(pU6YQ3aj1~L zzp-$%deG<>V76vOwBcNWKw+g@RM&iAD$v7T4AID=j~s`RLYE6r=H+PE!Fn$+<@;|{ z=52E*-}L$M(SNBw*?N5JTsZ1{AwXp#F>P%?g^W^Nrt>>nK5mzIsrO>qv)yP_-a;-$?aJ#U%A)7cqF*c37gI+EiBO>Cd8rV#^MIMjY*0R~I0V*Py(~QuVBei`$pTSQoEt$x4a48y><4 z&ev#XCRwH?p|JF9NkW}p`+Q$df%36}P9};N^sCfDoYVeEMges-p@b4~Wiut-6raaL zONZR)0<)oeZXjeQT68v0lvGZx9iZuSocTDi${sEIy;#iN9{5UGN^h;S`9YHH50M$} z%DOL4Bj?MCHDsWxI^)>-{X%FI-p%WAdIOnGKCmGrdNrp@c0 zUhD4{_4W*I^YR1?(goPVgNK-fX@tSyChGF*Hx2GTsGFvT+Adw1sM#Kwra~Jh5!k@J z`53RWe)$kbqTAEiOwX>VuGVrjvE?&O<9v8#cJ*YA<8;_bjCha*DEI@>#pEwHYx*aW zt8uc6X+s=?qrW4EZYW{&f)kPk-oblDZuH93CHZN&O3S#ryKRCKeV?-~h!WFox{U|A zt9fj!5wukX>)XA3g0qgh;*`~r?e3^!-Xr36ds$11ek-RIx}=q|UC*Pl0lEZO-aCgj z?bu(j0R?YD2UUmu$|t%;ac|sXwn=E;nbr8+KyQ!7B^E_-)8xg=+;+yYwA%rdAs1#D znX4}LcT~{A*V%O|ml0E1mMh{*8@jw_RYimK3n5~-b;o3TW%t!}?ygbWZ3~OBnJoMD z6l6oGGHOGqS{()O%0dQ_mMr^N65NIPG+|Y#omctiz{ax^T^B5P21nOzi}qV^>u|yB zuVAg}v}dian;r{o3!ET(G_c{a3~k$5@+$^;@(l#HYNBenY1&z$DV{mzvlMIhYLqAp6j>O4a(j{c~PLfxrPt{XOW2=h1ZHH+t zv%{grdqQ$`RA##9rm?4_dU&olX1=jb&zUK4j{FsI@r?mM37tClp~uu^R0h>|!3GBJ ztNMY52j`RYKq9aFhoAe1tl`7L%`iQI{R_?v4EbFQ`GfA)wooC=S{i8lHNUmZwjXZy zV;;sFZzEh{{R&&xz4zl^Of@=XbLoecmMSXNJO2u#vp;~GKM{=Ph8uYM2&pHXl{%91 z@YWgg-du)`P`0HO}lI`nV!{X(}f&h_1ud>-d|q2j2h`?bLT5 zwqnjLSuLw1^|Gl0sw&P|2OORYjEuBj2#^RPv*~YT@2~>N|Rx`Qv*Mk zfKF9abFVA;m4xDi)t<726|Y+mi$8qMjAdZx3Ph8TV=ZpzqNe>o%p$Vb16I(vO?S)+0u8jRw{RmlF-W zjE$ZrGDABxV&`q_1$cs^9gj1MOXmmr%kvP#Nf79~5&tj<3=kUPA&lu!d(=S?Y5OfH zs%@>b>uCXl94=O@S0{_33-tBj}kcDlO&ET3yqIy$?vr+Mtb)8XFsSmUpAx{d#WkYtDA zMhp_mW%SQXz2suKJ3dp*w=}1ArODJXnp5}+Kxnyu4*^Hjb&%Haf(3CY1;(BV%KjpV z;YD@39llE}v(IHHAa_8|C?A^DE&_Fc zDn^tyt(CMDXcS)a(1X8NeU~Rs+oF)cZNybN5{mayArkNl(T;^!u9u)IU(;mERVGXa%u)64QTLH8olh6GP;9Q9j+r7#>aNys#$Mx3MF8D&5r}+Wk zQTe+3`71Z)H|lHs%=HMq$=JDV_N~)~kJ?ye4Ijia{d4nJd#===)vB#Vz2U%cUtpFQs1wi%KCOr>?+LwUfzjv4HTHt?`9@cHAC)mlE7iJ66 zf!qHo(Wkb{JRrk=hnpD`^}2=qsh$=|CBZzxk{G5YgM7I+duy}VhH9(JWT1iOaymT( zzGi@@-~5DO*>CpKzvp9%v>r?6kLq|B;vgZH6y~+ESCC{N>5$SRDIEhhw<3?eQ<%?P zaNA&AP6Tje+XY+gg4JiV{*@*}eSx?0BY87IT~}IG3=K`s!t<9l#>W!>N-eu-I8 z+Fw=u0vvIw7GWCAAM22zjH2Ban1Up}9ulVs0-TyflFx8add#r=oink5e4Tw;L$Mf# zR||tDjqE1!3AA_gy02~k+s<+|Bh~9mcxr6W_zWJ5YCHEl4LU3QY|mqWTW#*PCap-6 zgO=nR19~xmx;JOO1QNu$w}&HpsRG179n_2$fOdtX)1f*ryHK$<1{AhCg_A|! z_8gc25k%$+-7(_tYmf23E!ha5iG`5YUJbW3x2e>YVlH7E>*eZ+_)F$pfyD$_lc7P4 zoEM5!0|3GyMi~+gIQAiZTK3r)Dw;+@kj!9pxFB{^*+xrL=Qal}q|Nlyp@ixX6*9tf z4^?mgeH7=PC|^q^>Peim@YF+|XU0FQAL|MO|NhzT$NSARQn)A9HN7a>M zgJzDY*@cn%6FH+KO%64t4Hy5?=$uDAo1gBrfgkSH4DaE00zn?{%+TL88E~Hx8U2ow zMUM?49_v9|QUX)!HS(ex9xiDRWr`_ZnPyU)OZC-7@>P|Mt{LjJJf(WhQR^WSpsIJ5 zm9QtlWh=*Ji9eoFTupH@^lOLKSAn;(G)9BtrYi$WqYBiA3Y#)GoJZ?S#;c;$p@zAK zF@}+*A{FcHyQwc2dbO_iJSQuU76n)J>#G833srIkL6engitfL~jjyD>Ig1)hdJ-nw ziye@3>|SJfJkSScyG0#I081fj8$F}CY4SQ{X*3LzX;$MQp?zkZz)d(=Bc z{IaNwRXYiqh-m9FgF>r@Rq{GoRw%M4W$|OxkwVns`ud0URW(5z>}1ZGMSJ)4p?Tl8 z#u8S0qn>{wl1<^tw-H;UK52UrzeT9hIQfK{8ZB$2D!fh(w?jTn;ik~7{ z>@J|o!;Np=63SkLOdP5>FH)%s4N!+5f70(8S?qB-=f-8wJn^FzDq0a5+>|#jn~7B{ zN25qb+>%rfORW)?t3neAB=bX@8^}}8bkf3_$@iFHsU_%pfkq;7e)*pkv*BOd_pT9g z4cE2?@#JjmjzxA>oQ-J9dMWadrK#WdoNBrD#lciGTGTXEKjGcZW%^q_#i0-9`ZOM( zDnsTR+hV1!sk##tLC$(cQ&Y=}b;rVF@Y!zO zFVL1LBky^|2%`&K^3?8G1elhHV>GEAUv~UHvN!0J9aFU_#@VwAlM$c}s=|NztC*yp zeHg5Kj>`_mbWGy8L0h93-Txp`(QKK^#iJ--js!)N0J)xl@)H9^scSh^YCUN%E4EAL4K zVgxE|!`ZKg=xx#L8%;SphB>d3t0lVWQ|8Ek48GfZrf5S>c4!q?Q={#2aK6^jDj&@j zabmSk62+3s;My67f1VoE%0qINHO~bTMY_-Dc;KY1)H;MF;GAtLH^%)Q#8m|(?`L|MoI>VmV zKg!!K0Iy-u3!#a3zUKT6yazm6^1gbC^NIJL5z=<932h4?0Kh6R006~*7a`f{8(Qj{ z8q-=h+S)`bYRdkWWq9Az*4;Cg4BIQ{1-1%SnlFSPY;80VklAav##p~8KoY!Od9@@u zTp-A{^g71$eq4d%Oa03+E+yBbXhx!55#dn&p#4DUqhU&+!n~!Zk?=~JqG^oYTZf%9 zo@18CW7=`fed z%4eI(*6;JxrKe4e)^4Y#?r>4VB+EO%Z2hy43d};xtxlW9JyZHh*Sxe(%}a)HrYF>4ROK)_#{q^Sfz-YKz9OJivA5A&j5n9p%=AW!a*!% zXBSEb+rlQKmP)YXXr8G9oikvz3fsby_F!sLmVrMXfMSUmRaT*j2i;mvVu>**(oXB8 z;%V-J&`WnwYXo``t$DL!KGM+6x0XHuT%QOd`9_8GnHF%csbRBREr|f!wQwQeHXGhn zxUMRD;kk(%K>Y(tsPZ5G0Og`NvI}sAtsbs|`=4(4n`KIuXG<#>k7lu6jH?9?h;4dv zLwvlMln1YjT~MI3ic_6g^5hJG)SqPWq~2_j58ZsW|1s`X-0!Yg+Fgt^-To|XkjWJA z@_y<-@=YD2z7p{IdVgZmx%*J9eg61LHeyvBvC(zTN%njSfaBfX{JF=kvE7Ma zb8WR;b zvYRROB*Tmg^KDwylF_YMp>kfsekHqoKHZmmqiRdQ8+Dbi;A#I)CsHC}bI|-*H~+Bj zFjiebYTza%G3RXO;zwCMD$A)EOe7z*A+5dHIcnIOd*Jv=Y3&#N|DFi z(J3)}Yj$VT_`)|jRrtkrPWSFYl@?6y?dJC5IF8=UYb+d0dXRyO;R1u`FtB?liiGZy zX}_*QQFM8vX*GB0<=Hz)2ThkzpV^ZaQTiV`|B=Ky&`sEr5etEAPcOojD36hD`->A?5_rBx0pIrM9WsUb$D z6?O{5x^)O(tEK3wQm=N%AaAV;DQNN^0;SF4o%8`Q#Q|v=oc3=$N=rl$UqoGo!wt$(Hz56to!m~!_6dNt zl$CGzAWZR!@RQ2Eswa=<{#cKT6t5vfxqFdjO)1{>74MQ%7XGLY(%+_udAV$D9qG=}HEn4E}@VgG2c zh*y+^`UdL@QPsY%#*~Q*#Fp;L7Q~xXRkZ$4LB=r)JHvf(4AJ%V?5oGmW=Lc*(GJkv%=|vSe^_nbPYY`DKojG`~;6w9t!`Z;j%yboW!Y-k+C17X0B6vGG-BoL<_f)13zUQ2zRqzn9(MA?YSE^>sI`4a2$ z86hM&+})KQ^e}_VL)b(+e3XfNN~5%JHX>9v?7uY*I9wKN(*T8yDUh83X3k_Rk5@Z1 zwBR(1@d}-gY~aGRj{huTW|o1x4MYo_ro4@Jb=xGH`c32rh=9K3;)^PBVsY`p(C=|= z###_|(A_2XfFgO)36DFd>TSlq%h4V9DeupWlEiJhAsl{95?K3Oo9;r6sgL8XhuMIrz*TJS7OKO!YBxLp=<*Dh_-db&(@v}4#x#Peyp z0w|_#1Pu>Eic~!&Y&;TnG>##`(Qw6tDL9mTgU%!BnL+>ZuD(-Gk+xhRZ~dLz?R0XR z>XFfDg!;>UT>KTrFJ72)nH8Gbqh!J`QEm;wgo!`qD~uBBbEhb(%PP|w>IW!6xgFIY z*l6WE`Ci3_p@YFQcZbNu`-kvJIrY1QiQ8K8KEbVL$4{N5Ml4S`rKg;$h>r|CISVg_ zD;$qKS|lekpcHTOP$A=;{Cww2ZZrWp-@7l!CTy#aY882iB$YH{vBOp!b7Dc=p$2+J zMH-@xa(VhM^z0TOF=vlfKRd$|oV!Bz+MR5 z{C6S`nz5{vTlMaSMt_mgqC%I7tbd_I92)W%{%0@pQkce59Wz)OZpof1A$St>y6ozh zE0cgpbJio666qURfU;-Un%NfWBO0}74#C^IQ7P+%VtYV9xOr@75bMVz+V^o*Omkjv zuuQ0E5(SXnn{TvHw--v&!QJg{x7AKq_xYh?(^SgjlJ1A8;Y$H$z`2(QFxDhIQ?+#uo#IgmtgqbQupA3u^uLVSp@Iu%tOE z`KI^Ie%Y~KvsA~Do{^1|6WXAu)!bDtpndbZmGq#9kT2waOX9OiDxBqya>{of%x)x;x3cAl>H`B0T{9Beifr zB)pQ_8Opk1Sn6-+6(Wffc&40$X(HiWX3p{gF>F&*N8t!)Acew$K;nAhn^jbaS!*2Z zOc6kBd9T4wR6l03OjDEfFxg`6+#~_00FtxgEiAR`_M(9?h8~r}Lqe&ZGOJpcck176 ze06g(yUl_W+l5yL{Y}XXOTc^86?lWMIwU?FzAC%|2fk`N-=2?Ga$fD79o-$?8rwf0 z&$q)G-`BV*x4jX*Rkk|?oAW-vpXnuqv^&B~HO#|l%%TXh;>JT1FiYJPZ3GRa2++p7 zHF!G*t^lpBt#m7`H`gnzd|RImjvs2_x;EE4pD+})zFNJyeBTqak2YU!o)5deymq)| zUTr<0{k%QBJ?MD6+rDj{ZFg(Sw$NPb)coIwQY^N(ZcGQA1I3+v4$D!ieSO%k12kQc zI{H78VVYBtUrmUC8#$iI5G(5p4P1&Bi=>`%@SZ9;BPo%~3nt4S+dDP492H4$-75}( zXN(*>8H~03Z%k6up{4$!xbR#AM4VR!i%4whc6sGEm@pO_=1RKF9aaBYIyIsk77kBb zzrF_0p_mtL9z&ox(QG6i!kSvcsL9?z0}ifU-(GJ|+r{vhlfp?an3>>^0a>dr8Ax=e zZqJ-P#rePO{jgE5mLsp2fS`h_bxGs^4@^6n;-H0z1Q(`Y9TwWw-?KT4?~*FbExk5R z8(2?*7#e*8+Jo3k{40-!cK6q^-I5||wlHt%G!5w~FDKRrzz7mCMa2~bD_Nr14k zl1<1wI$f8b>T7}0G@tk7QxIRd1jcp|H;ec3x`~&}?k{yU-@!43ZnwN**Bnv!x#e!} z`~a}splbWJ^Um&D|LEKfz}Wyj@P=pL8#FTI;#s%pPix4|96Yp%?SDhes0S8wCYt(Y zZ)WY;?5I!A+1oAEm)RW;_Pka6d>QQn+i4H9#0C<}OQ+^uHMb*Jh5JMLbIJ)b;EjAc zN+`DTLB_T%a4|X~zkrhohRw_LY!Aq%n9+}?)7I28*l~a(Ctwyw1jyR@9j`!lE{Zpo z0ODw40x(B}p|2H#-KpEQJJe#ksYmvB`0fhFelZY)4p&ygMOfQAhYvP%=xg4mu6?p` zpf`%JMM7`o5qs<@B}32LFH>`NqFQ+1VsH`+fbwJx80=jC`_C|`6$sr?oq6uKs?oo% z6rA=#&r2No1UxQ8HRkRu|7*^Jf_J&RUHYewpV|)&P3C#Kjj*uS-Vcb(2b2Cqz&*b| z>k9G^Xnht}H#_ex?Id_`mUjGBG+4AB#>iOhSd&+wtZV^D z9%-yl%8{xWPh-a^17c4E1?(~S@EJ=P{vnH%3M77SOskd^M9EYxY#}ydnN;97MkmX! zN|=liW(zcI*DO1err}jx50OZHpxGm0ZTq7Sc}-lMyozM{D6k@FT~r?J=d5mG#JnK5 zHKue*7njP}p==q?KLQ&YhzL|Yz&tXkF=>`T9VJ&$6Ga_KrPL#&i6Y6-Vluzg5T-Dn zuW$vK#2t1_R<{A+rD|m66p;vug)2Gz7jw|vEU$eIB3SLjfhIx;Au~bFMIn-EGkH-7 zU)InS$8=eq@d`q{9z*Ask;4Y*0LtANFZ`hu)wy!-MK<5y1WeY6e#!Np`%4Ur{WJ#( z0AS>|Bb)TU+g}QQ1%##l{%)^kb!*!VcBJna-Tn*QIpVsbmdHP7>W4x*Ezv`JE3Ren~!OoVbLu;)+$os)pEi=@r0=({zr=w0`+_sg1VX;(!3sDQo zbv9H~8(_v7bb}I;t>-G*Dl<+3IymR&@2qLfDbeX%M=jLmZV7SfPcvt%%*2jc<$A~L zj)nv0N@1hHUivGR8$I1w?{&lAjn`GblU2@)2_`j_4sdTl!ANWx&&8+iAMQZh< zYmQVx1t(5sY`EX@6{^!L&k8ZTa&3)oq&uf1rwq8_v3W`l$kYuPdzqqLkjsUX$Mk|1 zwpm*{3zhXo_2Y>}69Z|8FFNz_OJfXJ;x#uWRfJOO>K&r(8i+Rr0PwnH>kV{gK8Ca9 zB;ITx{PY3x;491lz>s4R6($<4mIqTw49zo)laV3$5>kS4L6bt^H`;sw)CCj{-vk@- zPWReLQ}qpb;wL0KecP-01mIE)aj6mE44ss8qaoVJ@o+V$kZ-}@T8O$hAluggL# z$f317C{|NxT8{QQcYbuMXn*Q@%F^fW$W(r|!-LHZw#5#V!_H5mCSk!X#- zp=Mcl11EP=Lw~D?6pAStwGUrd=5F7SW(VFhgu1*1iDin&b8cb%McN$=&wB9(mbI_E zDzUB#XhbmP$$lw;99L}mo@lvmhO~WuD=k!p$mYwQPPtX#qq94&l6;;b>M`MHV=Be2 z?s^_2J}j$$4p|5hOrp-oLGewa#U%Vd+fF1Sqf6xcK0L%=M+5rPDN=Q4ESLWhARd9c zR1EL==}%78UVj~eA0P%M5t5jg)h<6a9*)p^Hea*eu%QCQ5iA`EdPVBIvW&>RSJG@g zt--?Kp2)zy;nIP+{ao`;y;$&D^8A&_5iu^70Buw zhA^;Eynl=7>Vay5jpZ){Ga$_q`hht~0-y#qd84}d>ALz&I8VRpJg583PQpK7|^6e4Du^5~x}HNl^t(QQRZd7ZJ2L?JtKkcM#!nzbN@6T zvYl%E2y5!RL1WP4@T{b;r0)U%0_naXUOPpM!`Ge){_Myeb2-5oO%$MsZ~E83SX9VI zV4*7l5|yS)rTybmV~r#rVy*!4i;&g=2AV#e*4B*nw%`zaO$9W~5eIT{O`|2GM*#_Y z?Rwbq)e4poX1*j2s2EAy!RA)$LuRx>;$-J(Uo1VJWO7HGZHMG@OiMz=|C)7w!0WI_o29vvB-GxH=Ui(%6FD3y z!OB7hA2H@5L)NiOG=2<5XQiVsjdG>$iR!~&a=EE~YT{UdR^Cvn6o{@R$xS?$4H-== zvE+ScTk5_c(;#8ku;p1R{)n@|w+2x=)+|ky;CYB2`C>>6sn{ZxaVx2(d?w@I++Gv! z^CCwA1uf9sbmuh&Rp?ofk^#v$**sE^!Czx5rN1x31Z*)0D-7MSFgMF`&*M2tV4y}^no zEt2TbtCS6mBf1PsjQ%luF!5wLQFr|%J7=+los(7sZjkY+iyvnhpmN@y-2|(#E2^{H znRFOjgPja~!tcTCK<_`?^C5-7>kD~NJb3jXsfW(&UDN_A2C}oERYHj3bz@B`U5h!T zgctXVFFYF&s=5I-<>v4F3$HH{ZwOr7@0mee79UTp8UmU`3=30II+2F&GjCehhMF60 zp7bnusSG#_r=+S3Y*7)&{oOYbw#Xc#(Jz-0u5g%3o$?O}q>I>CdARlwP>AxO7)gzt z9Sq`(RKU^Bq#EnA&#+mW@?PAwTexy$jOirTs@T@CE7Jyx<~$&tEA=5oB4mkR*I)|i zlI1v8ku!Vo@3QkeT6#kOH`<1uNSHx{RHq}v3{W*vJw31Fg8(S2ty+ixeE*mXBawL^b#O>nc1HvxTGGxKqtDt=wiODkU1xyWZENp=0Em9H}zY>bb zaf1VGwGOfv~YyxRVjElbPl35FYg$R(RD(!-?Sm_&j~@ zAH03Irk3U!W_gbJ@F8VIhGK(mD_c z*h#t%qwJBsNS{aa3l-BXa1S2oJp;eIu4(uO)=yakS!~i}6cYXgmifUcxZP2N)(pX{ zW9Z+eaU9FE%FB&XX^T}`x*|ujyg6U=#WL^vv$B8}Hb={_arjt-Amtj*aS6uLY%&~X zG_jDz=gD@vM%Cubv#C4ul}@H6;T4AX>^>o_YVh$=3)8}Ik$gtokzoEvc(~O`5oZpp zJ2`ZSVLS5tMG-8L{4+7+`S-Kgzq3~-=`3r%^55;L6ll`2il<=lcfdt6dr1i#XZ$ge zd}*h^RI1a?>*YT8j-o|)s25G=yfhQ80Q0x|F6o0olJq&vM;$Zadtf$_q{}EVGra$}JxY99W#Jkb7~VQ+qiaRKGZdduDkw&!5i$y5H1M)f z-2T!wWvCc-Z?g&EDnzyZD;H9EMZ_&6s6gOCCP1L$^C7+BKy8OS8*uGGZ5O33PF zbUW%Y?KNfj7#Jo1j|@k(gO8h3I|?$f+i{Tbeqwh#14Eb>8-p#(6F_=NW4$DacBG^h zCl_TFl;js-x2oppi;bng%Sq0PL)L-AY+KTpT1l!^rA3J)nfcH=V9~>^_(3;+8XE(H zk`M!f1o&*D0$h~hT(U!E#)SQ&~+{EH+>>=mn z;#K?_cweqA2P1fi0L)KI8s#StWflCQUf`+`SZIL^!FJCt%<#94;g^Xr9P6#Wkc1btr!!jc8jBwB*HO9b5_^yO6u ziwhybrFXDvmnlJ;3yQjq3B~-2t(s+2p9?< a*$VJx1s1Ig3}QfN13anF!5z48jR62=8Pq-i literal 0 HcmV?d00001 diff --git a/.yarn/cache/ts-protoc-gen-npm-0.15.0-4bb1076a19-de1d526b47.zip b/.yarn/cache/ts-protoc-gen-npm-0.15.0-4bb1076a19-de1d526b47.zip new file mode 100644 index 0000000000000000000000000000000000000000..f7a25dfe1b8ca21f4fe0657ca6d6212e61a82a8f GIT binary patch literal 64726 zcmb5V19WB0wm%v>9otSi>ezP2wzFf~wr$%LBUXg{<`?97oh&@;eUSMzwXvHMgUza8zU!6fCIhU|N1JDt*U)8L@{2(Es}cQcLRnQA5e3EB`T6~yG?bL1^pdpS zn|2iB{wQBb)BL2PeHfXNoT8ne0f8+dM_1((^Gj=a4x>`m;s}!{!FuMP(gOP5r$F$pM`B?1-_wBkdhNeiHMSXqL-+OkSEKoAe`D3(=l>V+|6vEM zqXP*My_17I{a^nBtepw}B87pm2iQ58*#jsk=;-Jz%?#+xtc?J!6jWR=|2^n`urIPA zxWMqMzv!<<`mg&{l~s^bloJpVp`M~2rK1y_eTy*e@dM5pKDK? zYu_86Ga$>waz!&WwFmw$&i)_fe2we>zR16x{snUcl_jKv=?wJU0hWKCsJVX<`pFlq zAqWr<$-n-F5y04i&cNNWW2WaPC<7wUACFFh(iB=#2i+^VH5GUDB2?~ROU4ON*0k7h z^pi!L3JMW0fkSp9{Dib))|l6c^_Nf$Wh`0p2~(H{AMu z@;T-p41&2R8hRk`;0crIND9R!%xmFs97!ra58X0DlJW?-F0DD!v)cK?r=TN7cz=~E zO9@(1sZzVDQpqB%ozd#3;`;24=;sSSXk*ql7=^#Z7AOu$3D~*$m?rl9hEaJNm65}hqoQrp72G#@IfW`osvn^vW3m$Sl2zi$3V*f~x={nej*bWR zjK~L!4vGp@jsR%XRHf#2X5W9mfVKJGbAYu4KRt1Az3?x?07nzyS0fQMmCFSuHD;^U zHdZx$mjfPle1mZKZyovn_BH>;`u;M9zbgl-smrT4Fc6RrC=d|szn<}5#6n*dCTDME z?Fg`^Gj}LboU-Wsf!g_4Mca=cO^Y98Ow`5W!pM>jIf0!_FH zcjmzB@->b*$|_2ZX~ch*#@$=ju*WP6w(KgXL10I8D`mRhiD2g$_cV>pD#(t&xpsm& z;<4KZ=|KYB$^m4oEo!P?1|)czoYB1C9#1RRlw1HOm}Cu`l)PwoY&pE-!k1k3jM zsCO~aj%u)9y^24xj#9Q8E5T-uSKK}odBlNG(}uMbfqf+2Q+ig}x!zsuAh1tchU52S z`Y$JtE|W|N=Y%1YTb{B`p{5a25Ko0`F7U0*SW=&&Ga@UHmyg19DhWOX@%*tNIN=bL za0wpGnZtN;yq_$kP&?f4WB@8?4lrcid}q2bK=K$v*fj|}>sFi%iGK`;q9IgLZ@@XI zPY*GBFsdsd79V8XFD2iaX}M4XmB>z@XK3Jcm==y^y|O?hbQshGT%$=vX(k*iF_F|a z4PB*@k6IcH61`YbW^D_X-#D?&G}tHsw%A5Z+RP$h;oqj=@?ze6k_)QEI(_K5kH<78 z@uS&I)|_O@5Gi}EP{Imj@|p!%f*tjy%C`ly0{#He;Tkxyfx2)&MX^~Kg~Tg+wMC`P z7DPlm4vAHa`6P?&uDI)xGJ`#4g^YFo(q?HYOgsAU{k=j9deiiMN14CuiH2}y6t;m)m6SW`!TY!P@UNW z);Jp^x6hgF1C~}>WhFt%CPRp+fcYj@RiEZo^DKG{r^-LLx$6>$(%Z3pUVVCV@# zNgF2>30Nm_%}P(`2{7XCEEY4%uA}=4CJ}YclcS(f9*JVSiW6wHSPeh&Z^UXiC zaf_Qu5xv}T)BirFd`%k>2@?At^eDL(Bvz!6$M3XI*iZOeBqf@8Ok`|&h&aex1r;!)*|vhBDRVQQ zp*Vq%;PGhJ|DzORx#ipg_rQ2N=OgtmsUp1(b&tr z(I;Ot(7^L&N|_EZh>Mo?C)TE(K1yyInIeeeLla?#_jtY_&akp_>PuLJ)dsuwiiCg` ztzB$r*0`}QeaorUh98tc{+C49f}A16_5eW_#PBnL>W;LHD*-q!(r7bA8>M|4|KoZp^Keogb5e zW3on{YW-;c!#*oDJ&F)?4RtnFP-1R327zpTw|WE4jnuXxQ>i-}Wuc0)WvgyNg~sWrcdpgqij@hNZOTU)E5|qI!3yUxZ$;5Kq2$sGV6Uy=-BfQU zmnk*B`|LwMt10DCS`-Dzp0$$|>t*g#*ZG_2lnm`DHGH>lp@$x84+J~aBGTz2vl&!`&mmV!Nb`OAEH2x9rZUZYk$A2yLVP8u< z)qg|}uy(TgS_`rirEOM!z;%4B1t%hdAmD9-M`mM0{B@|HD;8UvQWeC>O5s0M3!g8O zESIg~4j2i*{0T1OVj7_Edzg-cMU*Qqb87V)2eMdQg%*geEBbvVkzb)hCm`NOt^4o? zz@lwSAAwD}Q4wVT%DCqKI5WX?@l0fA{$^^Lpmb;x z(xzmChiosw4Th2^e~cr7Q(U{fpoc(qQuMT^knP|*J@4}C$oNu}4V6K33!kd3uVmK^ zDnEag;>RlTCtrc~mpkXzOnR2vG&sGrGS|J4qp#;LBHeX^5&LI3h=H(&!`W{PY9o7< z@r)^)(@dNm$VdB+Sd#@1_jzV9P0cUo47z!YwaLASKLBKmB*K#(bTUGCaipa1usJg< zrzOHy*|&!amNr^lxx37B$TFPzykkQic&iIwl|k>0<83MAKf2bUShB^+=&Kx^-LMRO z0&#~Ug@1YDErHA6Wrn|{lwKPbEEQ;@%^st<;*|Ed^3a)kyt=q$J&DD$zF}AnJcFSP@rS8+%7-ecLb5cBo3)6#PK!JXBSyRPw`~o%yZzbzqDPAp|@X|reu%0Z*Ru!W$@ zQz)Np4T3Ay{{6sOU@Lt5>|7f3ARIFJBbzCiaaBWZ(yWg5Uc!Hs%?vO}w)ulMMr|(> zHIK8y`jT-xI3xQ$e|-blXc2X%bR&Tx98$hYoUPw_nU~ALO^5(_-;H=_!#Q0psXANl zQ#;=#Sed;mPitxN<`za(?$9gLsHlj?e;+qk+*{Kquh6_jJ`)17in6WfG)4)n!J?R* zr0;lAMzXo~2?4p97*EEHHPt>v)cE~6ZrI&uS$%=Hfg2AQeOsGlQwx6<@hnY}!2H?74>JP;BQ)=|HAW<;vP7P^yO08VqbqEo6R#dm7 znIef~El!5`H;<9fWJ@s~B?^1?Hl|lZSp1%Z1o&U&Pu^Vv!Tk`LW!DQ>5z*gpb1Loj zB=t^NUXqr9ASzRC_KKxnnbda0jTZzn=mq=?}iRXBA+^EsKD_qnBO?;GB9ml80#5)9}CD#hl zyB&b9CF`2%vCvp}zYTVnNl$pXfPq#6-1 zS`S}yy6I}dO98`oIvM8ff$JE_r5!@Lw)vbkWZ!>N@|b${lXchxCABM!PTxp-&=_ZE zloNzm)B^;)S+DR9wVuP0y9d4d+r+i%dU_0%aVf2WO}XJLo`P-bBD0@mZn-P%4IkxX zGdJr2ZfW@O9jGgcR=_VXoT1m-p_^74Ukpup=f-!~zso)4$}Y+HE6;KS{h!KR)C^#0 zq~vA`_)G5V%F;F~{D>X5)WsJArNeV(D>G`mg-NrL67qgRs6>^w=Y&lhb07uLC~k=^ z-e^3m%(bV@wbzPw(}{PJhj;S}k5QZuR#4Ef03}rxI+zTBg79D!EaBTQv{XV{ib>KS z1;;GmPJrgm^>r!{49x4HvxbsX40b~uy(l+7wu9X0YmvQcU*TH8?5%b96K~&b#<7du zPS6{nI9P0!s82&Q-g?fEo%gc2#KNQ!&P(m=8i(h3(Cj?-E$E*N@xDK}#HkV1*;CM- zVvYQ)R{t==bNqnp8m(}N*d4u}Q8u_@A=3;|5Ok7;4yYQ_3yo}{rZOOM%|il#K-%t( zt*}Z+QOxKqG1?^!@1_f?C^kyE6AV{QQ8QMV&q;X>3bjoWFLOrq6x2_cS@(so$@ROH zOXz@q;tWL%9H=gd99xP8aS0Nq*l{dLsaD=wq*sA}R!-|;38rlMsK-(Q zCFUx7&!|_6&r#nf2QX1fR#jch@U$5h&mTYPf|B^VQ_>TReV--RJR_crq%gKzdt&>$ z%Hj~Mmiv9F>=Yyr5bJ;Bs*KGn0m1+WLwhq@M;m)3Md2^atx}eDSo!gn<~pxMP|g)D z{%8r(!Nw++ClL&X(cf)<}2}Z8COd>9+EcM(5klKHPh*(QH5N9n3&AGI8y7t z$7q$mdq=TXbP1krL#F&+jecM9CaNI}TZdBAG`DcdEvbAqglDx-MLx!&p zP5jNUR5-ryxEl)vAys^od6Xow8FC4MW~?TXei`Y9J4l0IQ5`rw5ab4~+_01rlwH%2 zu9;D|eQ~th^lojCeoGS{A?`MPL%)SmOQE2{v#pPGIFBTO`TbzpQLhE$nQag5k)}!> zd_#;ZMrl@nn1))h*v+4qsENKxv9Cigvu2I-n9j!nr?l9} zXPzWj=+)WP76`UeNj-1DzdBQ|SR8Gcf_QBqc^IR$EIl41pR8@bGs{u?%^(w=#f~gu zrY^O1y_S)mTQOVy7Bgq?eD5s062H`dKJxoQTqb_uyB+NaaC%7nPgTKUF1)E@ zCU4;Si|sTYA*!`1*Sh?X$GH(`ewI~z)ypX+4Q*n;s{IFSG;fd3pL%ahZZ<9o_~~!Z znWxmqjd3)BG%cD1LDQ36$4_F+Jn!a9<-F&Z&JY0zxxeE$G~5EM-Zw9;tHXt~UMgq4 zqg?)7sh6ew?MuLbfUXgNfc{P1{Hw46u-A74d@1q1nw8BeE8@q6t{vHpfQ*%yrtS}R zM3@G67|RHZ$|HY$OiT4*t77D`IHfb1E1yfe`->FP^H$(KUUlM2w+lxbZcddMwcnr# zA{NgdDucB_h|Hf%80j7~I$=h2dQxOs$pd z``U=rjZ|qhl#G<$s24>k!|cz}KF?u4!7IR@$bZ%cX1HkC$ho48q;RCRS9ETA5sn?nLu`-AK6OD;z z^DrW4iBKKOgHMvUO^!3@NB{52AkPQ90M+g{_ADk$V8bLoVduO94s#FMJtPlt(|B|R zql6evP4w}34f5|R@HQ#f`boH_eY1mSS)MNmlJMvt%xTTg+#RBd1HREe>|IWbGEDk)?{FRCfV}IOUAqx z+lSUgieXp!V?ky~Qtct5lJRTO@5?PC>K-mxcaWDQhov{Jl6BN4zZFT6)HL3xm|p57 z2&cUYYfM}s#Vgml1)9#L4UZD~nq$0zVOHCKy&z3Y6)mC?hEqbJm4$Gy|UJVctrV?*Ne_nP4#* z%Mzj-=n`|i4{jv?#-L$v1I4XhK)UVFkoZkGmt@6|QF5fzele?wtHDMoJuJ|*3X7G) zHWni1*hG|k=E8aN?F7-~Ap+cWV!k|S_}!a>)Xds}c53ZS5hPn_SD4IDRijGah9%RY zs^JhFcqv2t=e*Qnk*=5CRRiiVobWs`sJ^eST{TMzYl~GdP3pSydN*#VF3ytk`khr- zTBa)hR&ja#%}O))9K!D<{H`WNu_gRQz9TTf5{uS4R;B_%HJG+h6W#@xS|#(etM0JL z8jL#&Sax!!!jxhvI!Vh&FE5SyQ510&iYby5WFKkj({Y4;yWE>FYpmFY+NDjqZb`}O-DnSK*{ zTf@JqiC=DSLS55ghZWU(s)pT;i>r{#`1L2NL^Hq35e+-&$OFKHF`PD3k)$m?)0%w0 z>oSgPvv#5?)(l^bBl2*LZ??-YXEqtIidS!q#lWUT zBiKYdKV4BKCCsiar-Y1V@tlmDJusf+J*+2#wJ$s)%WXfDCsvZk_ey6q@#f2@WEvnA zH1;kY+z!uKgA}$>%fFp5cYxKN4fIil8L#j(`gA!6AsU}v&k+UU|G@P)-4}zWoXg=p zNZ({KM#>BbQN(gcF0yRPG>9D4p@x5YAj_X6t@S>rZB(v2Pe+`-yy@;&Zc7qM23BGgd9`h1Ov1j_iR8ZgMm4pdaQ%*3X1WtN{aL zpy?a(9Nr*RZ*{0VnEd%pSyEQUx4S9x@jkAhQUaO-vD>q^d!CEHQ^Mza>u|*N!`WK@ z55Y!u@l&ZO0(zXM0E)3sn&%7W>bQ^gcpX?0nU;U zdz=;?WkK5%gv8J;%F8NoFjDC|#o$c%bXyh5)L2Mhs4!lQbTcwEuz%)m*jhcICTA#n z_V)o{=;!iwTxr>SU|Iq96~y_`FYG?Z=z4MU_49wi4cvcLr}JaWaa%&J`_ zT^PSoW#DW(pLmZkd|wt2e5x^a_mfStWUQx2@Hr2pbtu-((Mu8-dLd=pM1CsUz=VzR zQqeVmDeCL}6BWfcQ(ibei&)EqE(853ROdNFiVu;!j7OL6OIHggETPqN3d3mGzi)U{ z6%>F@l`pVgraIpfU`D>xC^n)%M3@wiTtC0>3zIf%_}z-~JdD!Fzc@oxMW`fXyich# zq=>P+yqZ7+fiD0bDa^T9kF!xNZSUuP$^tsK%Jj@3Zg#YkcH=TWK3Cq+akzDN5Cwa?NL_#OXrpNj0fpB>=7Qx zTalQ*o=vF^s_ZG?AyEqg`X@La!D<8AX+b5C1J_YkaYvN8xZ%o!D1j>WPWX-OKqYg4hi5@; zB4l7`&D3fZER_rTI+XN8G@chnDQc$5$8SJD?^yQ-Ty(RpW?_~Yg@tWJ(c!lykc5(6J|^=?a*7W;#BLi2j=#znzp!7 z?Xd&K2^HI-J;Ms5r?P^^alOR&@Z~H0v-X5FJKY#1O-8Uawshw8EE`}yR4;h zeENPNf9OSlfEfP&Ibs)p!IvLC*V1*!WJUCOERj2s>TLw0@M=Hhu)=FL!x_R~P2*k4 zhJ$Qnf`$|ZDZxMN^dKYh0|NHv`W)GN+ro_XZd=hluU1irL>2Ny*=q}9SmsFg3bZ!x zu)PSm!W3Z;9*Khfj37zWgT*A^<@dyNGXsK$UGuk~5Z}fmr3}(zNk7x#g2E-?lOlY% zLY4yd&ru{0n5{k6{@B=>cpyPwWl8D`JFsrh64fu-w|+=&N-N3Bc#?-g{}DJWv6VyV z{25fQ7+URxk?z3fn^zx*ensdUM?is8$In_TM1k+OipTGbD5Zev@9@Cy1E#$y5}W%W zpeH4;b4DIq0(Z)ilp=eaVeSeG1<@@l9L~Nw`fvt@Ln0ZmvN_q9Ab%>hYbdjAUsu1f z-P$m3PrRe-BPXuL;W^rS$_lLruwMvw4zz^oLhUPQkgbT)sHNa=%uIua9ETr2@JXY0 z)!djg{_KR8Hgv)0kA59dNAzF~1+w5?dAsEz-a<);q0 zWm79{i#~#uDpRr*6kBQ31>y#rP{15EE8JbZbjexUWuy|fyL1bM$9|ENX0bk=j_PGp z$0A@9)f3a*BZWRc$%+O_jtppmIiS?=hZS08bROoHD1bFI2$g3Z$w}zYMtoI=p3@!C zM4iY~2g*VDbv_d_)zzH_wp1KuOxqw4(Hs{L_ZIZJP|CoQvNO9f6n>_hcP;AT($dq1d~Jm#7$e>ktPAF-CyOMorD zL?z+4*sbwH6z~=^F@I(z`QC&*jbTbBoam4pszGUB3SZR_k5kF_Yw?^#Tz;mY9S=5W zm=1Frg*1ViMCD#UH12P|fL~{vR!HNc#;fcGne1Vg{^ipw=@+8LMmV6Nub#LF=>tVX z4Wmx;l#SuR*m&O;;cnTv2?+@MK^|zgkqpu4nWi;Jy*>7*q*9nd$sM7h4@oC6qQT4ao%)D z>{4b7>HguR;z-t=d|(yUwU(`G40H=LQPP^CvE2{U0h&%t=UF^khLU>3#P_RWm=A$~ z>p+Gczk4+wN9$nV#H#k6!%ni z^ZEzH;yL2Rpa@VlDy2MN#sqX*b70Cmt-Vi~>{$d@-wKaxjn&7&sT>>@NtHUH9mWVQ zg3rV-PN!y6iHQ$zCMrfSjd_u@(W%cDcjzeM%Yo_N=?hR&sa@Yvd68xZzcwGVlN}L_ zvLrv={ccA!MM(i#v`(Sv&Zw)F3#Jkc6%;WgvUXzf2%57La+PmGoT)|_Zssul=IVFI zB*`eIkoGt_)HIh+OUEOa*?-Lf&6t@MMirCh7r=VNTIe8VtgHWmJUf+vOAs*z*F7 zE+d6YTZVNpCLE@VC5rG$3Ha5 zG~KA;6DhL@Q{zMg4{1skc5+2pNiQW^-lnp&!Qp$YqHRBJ>K?f9rZ8g zCJL?y?*@Z-(t4>((?sdHkcXXBMu23CwNjY7m&HCE{pF~A#la+0j%)nwX`|+@LmZpT zoU&=#e!i!#!jg@6g~ANKV6xmH3o=N!M4W+w^^}eswbajL$dLy%z*r}jGq{XbM)_FB zvQMRQiit}4*P%UJ(<$M?rw_lC;|OD>pN^W$ylZEHoA*!I5L=o~Omz=IS2b>1zAr6s zJNO@_m=A%KX_~)Kn&%y+cqJ|%%e-B2zJN7f8-ZT$$)WC_GKjby(y8x@8qID~D!A#O zU4reLo5M-DyD^D?PUxjCQ>kB%^o)QId*2}n2iF42uid1zaU1kMYA&2QKuoYR6j`SjU0m zqZH~pASetCITJNOJTthetCA_s~FC~ftrK8kNs)mQ&eV-%|-)5s!;BKwL%*fDfC zTjCK$$z136oWMRUu*lT35_=bkUypruIhUGy9EfgK-7W8`Qf@rV(FLd{MoYJ$#+nUN zkX3Q*1S$j$j7eQX!VA9>?z+OGX%$g((47@=Q4*>ZQ<@D&LK)tSS2rk4R&&jhinqyg zhv(@De8kdoRYh?<+4uWcLaFM+w!6$45E?##+wjdNj<9tS?_zSk_Pe8R6-tu^n|)zP zPL$ucn`RP>9+%O0x=(=7_DL_e&GU^$t7OQP-Wc&j9mW;6LpEu9MWR7q5iNHsQ0Gw-FDxMBkT~@w}bzC?%}tuVf;& z@7Xix{D6rwWFQsn?U2WgM4<#Z2}L5Ql6Y1Nf_i-5-oF2q-GLnM^DcZ# z0EE(aJJX-N+1xP48`?O12=L&N0@iEjRw^gj%Emp7a59`K#Bk?K!;o;Gl|s~2_O&rV zh;TzYDTtOFQc+uFPu~av_YU~m4rH)uH6%0(m&NP{W&8?h^GT2iW{GDz=LxfcUDALh zKNAv?-+Tl5ZZOR_2xd@(unI&X1V>6Oi3$@31!!Jhu^%$ zWcg+`pq%?idrn$g_DECk6MLZpRzdf;pDqJG5Q=&7!~1Je+Y@;PNYfWBV`I6C!lT>-Y;}G5L`(MFtN;;L6Yc=R4Np2jj zahGoog%R>RT+er~Q%rUy{Sst$52Dua!ccw6UdoQD3-h#;V1ZXFpFQ*_*u7~P=F=~N z>^cud>^gKI#jrx~0~*F#F*}SrNO0wRLU!GyBUh&LJeoZoLoBm)E23%OMAk$!1$!Km z%>r>@Ai=_wQ)@DhkZ+xjX-4=uDS#(2c=IK>Uk*&p>9OEk;ih`0n;WGG=3L)!etl;` z(mPo`_68R9P<-r!@|Dr%=>i*55}0Jf_5g(?M(UrFrE8Y0O-6YP0p-3&qrS|^{Kx8tzm zaG-2ghb7rH`#YhDYdxXiYm&cuB$3ANvA>GXl3*9Jx;ZXYd( zEPN)`Jc(s9b4MxAt>Y#;u2UnpIqbVp7fQP06{h@V5`)zXvD$YM5twx#FDR#zAlsgz zM@e-dWpE|qEsK&^Ee-TgS9rxQ=U>2F2CeKBSDQm8APNIe8#K9*Wd!Nw4Z+5*j4%SX z$y^0u@->V8@l$13vrlnTU5vq9SK)+17V_J>h(!qK6I*Cx09klPwu9qbcSCQE|G8K2 zR!oen=65g$Ra zu|U`#@v^T>1QuK_o`y#Gy(seOq=qrQMC=qB1k_;19mqAazAZ%`FXN(3C+V7ppt(i3S`q8=)# zz@u`{^?+`+0nbT_svtDlyonALMfTYH7Gmz9f~gS9iOT4~h%!y89i8@XSQ@{cwCD@Q z8*z@+PAZV99Uw#UxDzA95anjXS@Fr_PAD2}K@D<>(-=1mqpht|w|j6t@;&r8QNBBR5oUpji}_bL$NWgHZD4de*h)zY&%Biw-Il|of1dB2za z8d!G3BVdO7uB@A24=KiYQHeH7Oh%3M$*Skn;FyM_E)_}~EseETAF~@8?OkJO3Pka< z_DCF{Yl0`&~iH6Nls zvQsg#Gjos9i8T}A48mhY-sX+ntN!G*0Y}?d=v8LrP9_1V)|K~UIlN6}=)XVnT-vFs zH->ACLR_pSUMbau?o{qWuB(|juO{n{B+g8GqwpCQO<9<$>7N&IsyV{FTeZuEjG1I> zGMBMlqoXy7AT7A9sLukM&N<$4k`D0w1{bT)Xmcw=Y+k=Xg@Ymw#YEr)9Xn6WLndi; zaSvfI16#KUR5(Zne!L3q&g4kDVUDVYRzPTjvTcDKq!NE$rjb|C%qwHH_2aO!vzmH0 ziE3>@_$Ykreyj*s+rGuRW*OOvVLIsKO?tDKRd=94S#i0w0F1Opy;dQ;`FdL@dm~CM z`C70n`OLe@*71n2b6lsm!Lv5Vdw#skF<8xGycnA!kES*UV$b=jO7Ok6p>UIim8pXs zv@Sq=UU$Di#<>FY6Qh1O^dff|0?BpL45kd~jLE+nV&mg>h{+^U36N3#DcPcs;C3in z#-M`l>ZkIeb)KTx?!Yrm=CFA;~f%j>@4u`W$*e;n^ElrCkYYZSFT;B?G$D zxZNG&#MyZ->Eesgz?&IK2chyO{aWiJhHQNfpVgn<0o4=BS>KbRjh*LnDamfvXwHve z;v_dzLQLi0N^L<5?aE>WEjRDxn`kq;tA1fEQi|q{h2&-lCRX552qCbEdA_4|xWQ|2^KfSCeW$b(-*^8EQ{H^4p#2-&s>b~ z(EV<&%@wsgHi?u--vqY}lC8CkuA?vajqG(}_EK+tyq0TQfao13Gw z@$Gg&YT0)*F3a5gB!d4j5s$Y)3^yq9E0~VQ66}IajWmwlWzTU!r4| z{*-Kae9rD^2zX}%QC1a$oJ2Vb9NoXx!tG0H22sDrCO^GvbVoX!Fh)>WGs(iV|8uEt zAv{X+&J(k>lK!J`zTE3D8yGr{oqUL?;t%UX!z)f+t0yT`#kTq5*$3`cY!1+|$h^@dz3Y&=>6aWmSbLtxoHP{A*E3b_8tWo9xC zFO8aJU7LO82Cgbw>iQZ8F?vqoQT8M8Tp5hl{0w#hVjp*P01 zD@NQ#iT3mbo`T~}e%1Ht!J&Jkl*{Leu)QLCX$zw~N!=+Nw2KoY_49OXVxn}f{EsQ1 zZPe#M{Cu)r6(5$8*58XXz=^nlr&~LpE>GsIn9EGu?A&kDJ!Ww=h)LfI>L=z0p2Sl} zJ|nk#|K$9A$0l=!)0F%LI`sS6wSE8h3Fxo5&c+&GWBe7>MJe{ktbL((4yi>i1}duC zZOL|on-z+*(b3q&h|+xJ4cZc}6&lXE7Fi_-{Z%H&60S2IjW_yxAE9^>s@3+EFD{U% zRajV8w$Ri=`lpb6xFpx;1yI2sG&Nt*2avzj(rd<;bnhWWM{uvEfTNFN2RL4uptoDdvR$YkKW3hxxI&{36|Xm zF}rkIhb$npr=)ps4sqlW;8u?PUCF(MYiJ6GB&2?royAt&8Pm1I#wiBZ!5)~NzRxzC zV3@Zmj7H+uyjRVHS0$w-D7oH%Nxpz=`wP6OCnQK`w4($~*!YSXSm1BUK5T>>#t(5t z1H4odr||PCSlH+y#yHfJwu~KjFBAwa^=PfjNR_A}Cv~1-Grqbo$b!QHNh%KZ#GOxP ziae8pH;YV(!(B=N4$jBoi?+7fYe&{7=eJdmXcGTeo-}AZ9cRtQQih#@D*WcsK4*HaaQmsz zzFT~~$@UTbW-%ZLI48jOc44N3E;o&>%4WyFKq%Zr>a8$<0ib2AR*rQl6qPvgxZCb` zSu?xNI{$n&a-W~ig+8CqA$@oTtaTPlaHd6eQ;LBcYMp9%cXo^`tVgA}n(Tls{u3iX z7nCcRzRX1Q%S`@*a}fWJjsYTJcPj&i2tjB?Ma}+}E@C+9366|$ z;b&cJr=nFe;WM>?KWuI`##WIwh}g-M>xSb;UXdk}(I%4ysjwpYGf=xKiJU#nJ7+G~ig7!?S09Eed58i4To5^fjzk7`W z?L@VAdR*Y`HlhOX8>7!%AD?eT9y5GFvXmsaY4wE?9{ONAkz>2A_}IaHT(*OXVY|H= zMHn?x&!#q($9v_qn}1hfXBzJF1qdJ@2#kM=%KB^L>Evi;`Bz5ie^6Q3x;T$;ZmALqKKMPcrlNIZ3Xcf!15Z& zEiwmU7!k6ur7Bb$F(H0Q{aeGvA7W z1n(l@qbQ;7dUmWL@wS8ZPjQrx*oJnrBu7x}!W;GaV?P7e6qYF`Dt7f`IJz<&-3Uo= zC!)zX31#MxhJwi9MG}7k=+@*A@U*~itWMI3!apGMvhngUGJH}c)P^}B03^#9{XNXC za&XS`k5VVOhg^5lwE*TJ?v7-GM}?n6ilpCL{TZm-CQm@D*{%J5`R!m;9yg&jdal+w zqNhm4f5fI`W@pD&QNrGUEym?+HBV;}O!9IE zANp~~DneTv2lo4oBZ|D+?Z=*B@qlzt&_0K9taEEL_jzVkl>1~sfv#2H9_5}gRgyP< zpQrSGnoLgQn>NE|<)d2IN{(yBtAU*dfho-5ep|$8#HMx42S*q0D~=m-hCvruw0lu& zLu!>2J^%iZA#E_{x6);pyZLO37cwjV9MZj2wi{4#S)8d<8EB{>L^ct1Hl za7{pNIzW$ZrSmkS&$S;n}fd(H?(NcqDy$hNR-%53O8Tf}a@-P}D zDj8hKeO<*{XiRC~XBN4lCFO)fR@-R98RitqO`;xx)bMz69uw6uZ{wc)gG3^32Bv~o z=SDQEiq+xrw0nqC-JaK_8`;1?5!!XUMN6|E9tt~QK%p(Ax>K_tkJWc(WhfjEPCe7} z{#|EA#)TzjcGGt+t3Q#KOzh5$NyEiY${o!23-}|d0l&KYMbukIDxA?%9}8KOqoEAK zYe)*KFnAX<*v$sqi`x9!DPOtn%3hdTQCI^<_$|4d?qE$m1Q*-#WX~d5z#y0H2A7iP zbZ(hTB56&%4_m)Q@nc=`;d2MOFZa1#Rnz_4aHSiXOj|w-w)8Qu6Gwq=_<_f1hZF4K z7**)0ecR@SaK2zlBB2PoS`8?~k|=&-w`iMuksQ;oFQS?fpc!Fmz}z$4yB?t(Cz_R_XCbI2*PluabElUu{|o)PFM9)~=Hrf8i(yR7p@8KG!uW6&?zf z2vZJ!2eYjWaoqxn|CSFVpOq*aj}Ht>`MWTqX$ zow_vzyuP5O=9*alGNl9HwYwakL28wLuOlkFxV)3^@gLIaP}@9HzshfJHbdi=7L>vI ziE+h9#^ddk1xfAMu>zMm!@<3A%Fqs+ae=YiN$rKLRAJ;R>%JY`eHLo0kuMEo$Wx|@ zhoH;|Ibl&h3z7PWc7U`D$nVs751u_)ue8Sh+5D2`H)q3)&&GvtuJlWi*K@Q*l!X5 zge2?vuqG0ko09QNIVwDW$O(2kHUJIrQQ+IQwt+l7<(uE- zIhTChv}PTI_p*eK=3H8BzpjRar!S#>Lp}sgdT9H-`$j9mumQUY_k6{>>9E^2X~&5D ze=+ur!I`exwo%76JGMKvZQHihNjkP|+qP}nw(aC*ox4xnvv%D&dsXGf_cQg(`A&{8 z<`aZns739g-ukgkj#O56#TqPvMT^N+h45bHhG;5RG**O)uX~k+smqg7&C87AE2Z4C zGJQ}}ed+XUnv0FJ1=aBiX@ZsM1Mc55qd-s`5B_&e68?=@{s$C36(dVa32PgdznJ<7 z^E2XobjYCFZ`?w`ERb~_Ix9%UWVx-z$JRPeOcG%2V|-} zfGw`DtvAL*OR`1F`#a%JhJV&H$;OBbGFMVY0AoOP5@5OSjqU|#X8JZAZSxESs<%A9 zK^zR8SVA6={bC^dg;DC4-GML6qt)(cZ+jmKGl_ms62qs5mq|Zhz}Z&;vs{Ux=$d7% z&5psUdyk135veycceB;84t!#;Q=eGe19|nufXnS)sb5d+3R>c z|I8mIpryEw@SJJ43TmkN_3F{ZUKL`GG|D$a`uCU4Lf!DI{w|^Gzw40yu-3BGGqCu- zQ*RsV2*ok$bv)#aBg$#2Jl~-^arQFB(ZVueaTh5{z47IGbj_bB!g8+{9o|-)ae}3W zBaa?h>cRKkvL3HGvvDXozV#A zaGYS}O!HygKa?CjA^2Le#m!H9Hw2erZaCbLp@R+aks(}!Fxjc=dIajwiRNe`qISX? zlX=EuDO288+pf3n-qtn~O>$Fp>%xqE#W?D&;Pz*A=fA>YRLSo4U4sk(7{<;eM$Mfi``ry zD6Vq?MDa6v4je(DKsS6&@sLECn&rd4&MaR>bdm4n$(8D%a)G4ylT%F6mq?HtYv@ox zMs4hd;!1T&X#xewqY*J;5oVudB(NLztU6^)vpAJ!vlGL^K{+QHi2IhJmTE*qJ`!WS ze;g_qgdV$wxQyYbO+L|tx5UDH?RYC1G4tRO8o`7HNZC+``RI}^ea2lV(U9ZBTd+C& zaYIwv=pF&nS3}yOxifH|nXM(RoTR4Vd*Esoks`Sn8_!l4(~3zHZ|Lv~7@}=8dSg92 zV{tZ8kfCtST$6848e35Hb4`H_z!=CO25*Idp^?jkRWnT0eaStjuMvG{B7QU<_VHIG zOTr$$5o%0jXATNSZ6=APdmrniDcll2Zn1+_)t&uziPDl`22-Jh`!qKegGmQciTLz$ z*?Ql~uFusK`zajopBwv|f1@MPizlVPeuo#SZ^tM5-zNi(4hA;X#%3npk;TT^DVk5> zTL&oM>Wwln{o!~ZtsDh%HFO{jS*&i)+BOyjA6SMx`~Vf&e3AH&pt&hMg}+Tsge;YV z43{jJg*2vT290qa+cjj5QN{sn8k3S1FXThW}~Mle8Z$WA43V$r%<7{^W{1T6CdCYp1 zvX%=uum6U-rX>aD)%|``>2K`yf1sE+I#_;7=KR--E_^dPd%u~TcPL~gk6^DD9m?2= zhJa{1y{jZb!CqJFA#+6e{H62a9fG%$~e6@s}{l0b1tC&4t#c$c4H zYvK8MU~0*>B=;MIlIDpQWn@UhmYYlQ9tE#PwaQs6c}J)Va5y!SYFd<<@+7RdI4P6N z<>R!ot3t;h@y)xeOBE88acA0^8;8jYy)z zW#m57(%>{^Qu&5eW|m-*12-|*@-cySuZW5g+r6CnLA!YFWYuHEDiS$SJ7soYk`jBJ z`tglOt1Z+QV0s(shw4Q&xg(u7{rd@f$}WDjF0iBw_q~4 zNVjNk&9DeRCkMY*5GMeryhSD;!_56hq|plJM>EoWP9VMx+kqc6S7?S=^9%60PoW~m zW5v2>d~n}sR=f55O%F|62smNNT4|saiV%`pBcx1|&GkvWeF<>|8P_cmliY@v#Uxj^ zf4d!B?1>vk`0Ju55owKPSzXKejW7fVd?G4u&OUCncxyoD`!2@E=f8a@8=)*)ao=^# z3mgCdb&l=w%_?-aaP=N|;a$XL>@2=;*Ey z6Q&nUs0nBF*)O&)YBo;G9j_M5hS8f-Cw9yxik_oiFIq%nJH9W`n$WnoMPm?^v5Y_7 zk>X&xHNZ|T-9_qAx_x>B%*pM%!{Aa;N!2Bh?~4%8RwP5QyhD(606QV~<#OTZ5HT`C zEZD`|vo{-#0q;-dkhJEli^*?!#0UqlsDvaC*~^$;o*}nKWsC1uWtsNM?$LpHUh(&Y zF}V`cdSB`aY1k^$hbg7xHiGH9mnq)JBD`GXZ52gQLPFKpC`{}D=;+Q#}U zGZa&oUqt8Nb(=Nj;v7%Pm>Pb;(szJ5ky`^=M;xaSc_q(9I}x!MGk^_e5ZvjZKGlrp zV+pt*Tq<8e-c-dOWlJ+?q=%)BmWDRBhXbG{{G^(b&`f`;EO`iStwBX^frZilwOlk_ z9c~G9__0hZ0$TulPHiMUY_@|4Ix!c;FliT~$%KhCVz5t?vMV#OT5I6lfU}(LkdTL8 zQXg<4W0!Q!CMR-%K%6GBn)pRPK}!7oYt!#HTuICPaGc3*02YW=rb>eV8sxze?1D|J zb|M?+q#sPYtNQ13TmD*bbK;+d_4rm}fhL&(;s!F}8r*4?fm*M^auWpOez7&S)coRm z5u^^b%2jqLIoN4kWh~9T&uHk$d=vUpW))+Rg)D6bovzDl*XLw=Rcv)auPJt^&q)** zsMj~B)-VQdA09FD8Ih9II-?^pp+61sl`S1&xu_qvTygP10Zu6-Fh#8xGHGFr0Y&+z z9lpjaaj?s|QGRQ|wCy1Tz(X3K*l(J%$F55R5S-?FY*5$!@}TmqD!+KrnPV?l)APR8 zF!w0z2~ib?^=-g5xnqQxN`N+7?kZ6+2b>BPoBX93+8Oy-cD?qI~y7;xy&_hPE) zmLKYTdVKDzC<*&0RbTzRSW|`iG(NHWHU((Jg-lv%M&^spA`lZ-0JXsRr!mL^I`VgP zm>9$a(fDfD@qD{Z~kvhO396mhb}s^>KLMe63yk*%$NiSzuo2FrP5 z+nmIArXl;BHdV}z8_s5hQ9Em2hrRN zN)?r>{-n#rxUm|YE2Z1m_-Ml22K3K7xs9cqiuo_l2X&7_^W580)yT~XAfq#bxPvIC zjajEG4JJ4FJUQMdtJ>@aAIExSF27Re0st~9mmmST3vva$U{m;VJ}Q`@1RpM9opiwn zAEsUHyB5$^=4g6&0*c;1ew;zmO=EaM8hk!U-AL9W#z1}t8ZD5G=3uzX7v{)r$x zTKAX`w2UdAr$107W%HB4XrzVAn5O%|Y$7+hJGJaN5^B9?;DV46%(l$(@GtFM9?h*8 zFG3D^HY>M5i983ZEXaFbXI=N|6WH?#FC!oVR~eVRN#yGm>lhDjV1IA+0Kt#;SpKre zH-_;a$Z-5mWt_k4QlY$N|4nNDKpTbES zn3qQ6*30nJfn20B@cGB81%0u-cTV0TPMdNc2mmp{K35*6 z9{^s{FLEC|n=%ccN?jNrW*#6_8a3w}xc=mr-V3=;j}eB!4aba4xJBlvbC{-^x6xED z0Ka?uv~k9lUsE+}B*d3XN*GgJ;(lNL5#YD~biTV4C=24*ZbEYWv>Z2E&x0Qc$D{e- zU8~vl^kp^l67d7SzmrvTj1R)bPm2}4()AZR(@Bg8iSR*K225%`+3|K z;T0O%HIUWVr%=<2nOy_nwuWab$gQR`gW>8^X3#=C5&Cce@3^jhx-L3rFvr_0dPe@F zX=6I&?5lX6eL>=8yUAlJZ47S;*~H~Dinc#FuE60cxTef#n8YR*NOdE-CP=yv-B%xB z4S}$r*;A6sIMAUKi-l2r2Lov3v|iG(IS81IP&FJ?W4~=kAK{m>u82`%a4qc~FZ^AV z<8#q281!%b(^v*MuVVVS3c^WO=rHVk6U69jVg+|(GA548H6@hlZ!?cd98*n5OHb?- z>P>H+X)Hrst%CwdGl*GK!y)ftFj%Ks>`8q|DO9clAV6;sweV4uu?5o4VyG%%mayH0 z3Hywn>0m&nvg-fYOugOZD=7q&mC8h^t3J@i;qf;3uZ0~<-JUBgFN}@b3t+7 zBzS2n=tkRf{`ie{NF#r?(WO9#jCRuqzD1F&+`=Eq@csYvjzZpOTdLrzQC<|XICyI$ zX)2w#+O*eFCiRvROu0TWs|c=*k7989BE9{(hD)L~^DWD|s(+V`zbMi0-R|=Jc~UUi ztjd%fs)$4|sRrj(d~P#=a-zvGd;*$(3d3Bk%Wj$nc-bzrP~m|CGY{ukTQPP5Q4YJju-oC^tmdHFd)t z3LgfMsQ-DIF+tH(Vi~gdW-BA(BH(c)(E4yZ#lg663@$x4FQg9n^$GmyhTE50Fo{## zYSxh(8ZY_$4qbRkEs;S@5BNjn*s=AUVW>JS?J|LerENTkRTLLWP>Wq!P0d+u`uK zJT>Am@5{N?sK(rVq_!amyP)=n?I1FtjX#B~tzOiqUbsvFAKsn(%RE7%NI&*)$;!|~ zu#S0)-B7+}uT4kssl!rwK238&1!pDV`AeRvac|wv-n{D_AYou`7{cOV{gRBa3r#7I z_Gczj1>^g_*XWr=Y9OHBO_J<4f9;2JCcxd`VTlxlx z+4!qvbpOf3z#WBhhEAwmVQA2U(|a8mb8`6~%Ed0jjL0CJhR#Rk(Xs_AUDPm+(`UfSK1Ph;GvGL=j%twCH2}(J~_e9uBSP zY3bcX|0OlQdPZ#*TOO@#>R;b~sx@n=tkT3NBeQxa)2pMaGn$pc*fBh%{{?DlN5i<> zK8@>#y$zoX@RwWBd=FdM|IxSq|I?QF z%ex{J^}fC9yAl6X4^z}yfqZdv1`^uvR}G|Kd;kuuWkf~{B+vWyE*BRFRxvFy{t>Oq zmkt*+j<0^2mE`hap|3dPu3964R}UyeB%9iY#^v{5-^pHZa><+akhm+M8liD|mfSA| zNA{ne6C{s~gYIn}7X5z6FCGJ?&?|+Y!{#U@ChNXbY0^xlgWH7|3NYu3SE!l$Z z>Ys!ADRCKzXpne&hB*f)%9URNBS}Q0DmU(?G*psf_&YQ@95}iL&y31O ztYsNQDvU<$7Z#<<#^P>Cv)d9j{Dpg*%@{h0SDz~&cis8eY$|Qbxi-nTa= zd@`GNa~;I(N~?3Yub;zVLTlLQ*fXrkke0?CzP81qE?lAYlQGUO+GU0Rq;<0@Gpp=$tI3G9aF-C$V#>Qp+=be@G@UWoxOT~^9Eb3Q0?0&lcyjw~NyuIu#Q0y>*XwPC3< z{a|a>?nkZLefYxx#a*$)u{^qIaCRee_Sy>A<;r2v-}4AR=_^dq8dlMjj3TAd53z6F zy5c{By;-guc-62|w(yrOkJY%Z{rhREQ6Y?G; z-Jp3H;WdCzlpT}OHZg}+sq+aF8<$8x(OH4vmN5(S)3Bz75Au!4aXumHV?qFauzyHE z^>&@R>T{;SI3&CIArtlwUgpEyV3u(HNtQor(BIwQQ#osWq4uSb^YNtE(xA$*>!0u< zlXI3Kt&bnOWhUP5j7z{D*iR!5cJYqF*+>-HitY{m z)B|;lz7Ob1;Gb_LC)Du%q=94;1&g2;x?`%fk9KD0H$$DHl~V4)QQvdo)@4-D-Q&?FZCRyQR1(7~&;jSOy79**loR(fx%5_PQSJa| zxS?G#A>*rtB2@*oO9F-Syk%0&9WMzjv*Cum=rt)3uLzT)Ni=dkUycqxvWH)#{7jMc z$LV0e&GN?-*Uhau`Q*_{L7vX3>mP+hncvuYR~EW7=dUa7B!%LAs=DveP#@7ue1*v8IcC5$$)5M4?3H+kJP3 zDHl`4Rr0}vPhK!~KdIYOYzAuJIf7obWn$lwt8mv>O~qOeq|%2x);}}2JB#VWD;G({ z9Um$a%eu;7O#omdvQkLrUdstq7U0$~3TB)qsxg{G$t^D&+H`&bzHeJ)ynF!3on+Rw z3$M~j>!l~0!;M-j@_YgPJ%BL`yJ4Gt%dS;10RVje{cqPD{^w-$FX@*5s~GTKb(hAO z&8{f)m(FwR%q)M_noLT&?po1YjU{6R;lvIRZq3XtMchYQHIjf(9%$~VM6bS{^rRia|&l)+&wpy z=Ud(Vg9XNCz@0SP`DBuxbW^%5cThoB`D`;6qQ5PhxlECt9o(h3ib~_fvhwlr|Q7U=lBjo{G~N8%#OJ zT4$U2@?-=PQ0H6l1qh9dKEjg+qn?GZfvx+55lcB~_IW5DP5!9?`TL3jJ~>ymrHFi` zRne75()ANpORHT)f`!hV#8iagwvcnPXR!TIr&7~}h6R8lu<;LFI&Rq}8eQE)dgtTT z{&>9Xb0MJS5O|Ixmn1oHPQDbOeSNw%OmKz)N(4f6Q#&6-27{5;KmeW%%m1 z#^wE=d$mb!!lA>{@mxD{uBxVU+dQ>^z)k|)RZa?AEqD_N12yzP$tc7`nn9;e~tH}bk~z*0%- zs1pM6ZARGuvwAp1ik zb`-gIkDh`!TdL46>7-wHehP(z;wSiee96LPk2vjd7$bOQA-2TZ`e;v;5~J*0&o6q^ zb9S%d9b1&_i!U_iCQQLAN#J3pZ83*p+C{Jm_YlYqG)u_O?pcsCx&0Jws?W|W-R0aZZc z%2+}U-0O&{MH13!9qbtL;l?EAvlH@xq)YzvK1IG0$|&_D6p2#vHQ>xK5)Hp64DD(f z!C*cnUR6(VdsuEib-GMw-wec>fB=$WgrBvEtppetp(p4Ke!9!hR6bMs1g!^gSy`=+UmZO&lsH;M1 zdYMvL0V@@+Z#Jv=vXEq$Db>1rpQM=30kiuW?;{Ejc~uka*MdOkmz(sTlfw!h0kFWT z^!<~FEef~Y9}NRx9fm?VqM1{#>qGQ=fE9dn4Q3Q~yQne1hP(OkA^b{I#?0+-DR>G` zkyszS2BxIq0C1u!P9s17Qv$m@z3taJ9S|!!<7f-r(_{gPC;5?_NMOF6p|vrca|V=j zWInK@ZXaS2<|0T$7YvJ(NFf)Z{q?)VLMP2(j6F6s0`fX7SU#}*t)%;aqHf}_ZN(uZ zE(wR>1R?q-eZAC=J-{Nx10fCRRZF4+bTpu<;V>FWAWrKBr$@`+tei&yGJgR_psu)a z|JgBUWaIah_32O>2E@`2))NER6U;wLrUDMx!C5Vu+0C{g4^y=mS@c_?*z5UI(l-o_ z(nxvvcCT;waq#}*lA?zis6tD`?h?Hg=YF1zQMpibo4)wjzv6NqyRPf36>ScR zy*Gm)*B_uty|;d(cV9vDUg~h-gcKGym}Y;pz)e2xmv&vDl59k~7~o)|w!MwOddpC` zy+S;EtHKwIbp7;;=H<=QJhfwPfG?-Y`ttXWhP>uuK`1~CnmIEM&IB*R1}<3nstb;J zixK&FAj@MLw&c@6A!Y9=m~aXCPWb4k=K=g+jq49$TC_zP>TBT@g!=BcRc_lmXUL!O zA&zNfqs&MwN{w!NHZP4rs(dAN0rp5L^ZJ4T96x@sYb!^ksJBEOo@0YW#`kCUhfjO-Mi(IOf~YQ>`+VBS$XJHZQu#TZ@v-5#l`%DGW2coF z>)M5s5s1vQHzDsR*#1`IQNm5iA8P3Jz2DS%d}F9Q`a{6JCD@=YAsvqfXFu9%Y zIs~FWxuz!2-yrOh>`^F|4$8}qMk&+ikR3itOjP)p6?5F9 z7EMnE_B8%Zj!Cdup^lmQra8_2;caEpMJep;D~rvwjh4nsQ2U&RvP#^p)QlRRE4qyq zv=+>?Yx&mi?c*R-W)7h&3Wt=#uf83VAMK|iRi>%ldmKUvZNU%uV||&w+3yMuDd142 zI9To`WVLPUeEG00jO_Wbi!{dgF}YjXww-?4Q$=lQ1{v*|?Z~$woxHM4#%#u8SXp8? zYm%FJJ0LS7^MHnY0nqJ<^n3cZXO!$xP34Kp-{~u@kFQ=ctT(Msjz1DGUpy(%wT-q^XjP+jb7a|*#u(|*~ zgYQ24)vL?V@YOlTB{CH*Jj_a)CQgRqxivRhwQAiyVp3*wAwgLBkv=ihK-)0DIwcZ! z6ZF=Ox^TNNcWC|Y*{~YQa>!lf`BL_{BcSy@!Q%2@rM)D&*8b?Cs1b<@PDq26_wc(h zJ-AK1ZlpiUw0A`pHSFx3J5pNS`5`_St5YJEVR9K4V_nl&j>h^Y+q$KzBO*g%cN%e& z@$LlFX1jfeA4?MFDV;Nt6ULb^^`QC;sSndPSThN32lBbnZQi6DSE~O_j0&(4og*wy zu|484S|#Y_2%5}!dn3XBlzuUe_t}#8XJxQA5aP27`20CNw@e;Z^k}=aHvOl7tPE1t zb8t-zQDuydK}fha=1Ugduih9#+Tzy2x6|?_Y3}UkR`SAQ(HTLjk@sX zr#V*g7;a2VG)N!;pT*SGQM_AfgQnZWOo)g<#Hc^l)WV>j{+s4(Gzu#42qZ*ea946; z+@Jv3zPE6*mUuJ0QH8H6x-$v0xd5Y@nP1UW-dQAwKe^<+ap{toyxW&=E`+Ml1rFR-4J zAcFUdv9Wt-Tb&AIARj)^G-}T+_?1J8!j=w8oQu~=Y3Xp=C0PoHwT9AjtG5!M1HoLn z&Y_GW8UmmorH7;pQ;HA5OMu!7tK>xRPm>M8SNI_#r0Ro)gq(u6!0C$@iL^A2TSY!-#wtWq?qE7@Lo0lxFjcDzMEUl6+HA- z;qi?}s4kKq8vbDpT;~5X?l}>qo_Vc##e@@>v~-v^hVx4nGqqV6aama))i*kl@18>5 zw^*r{@P)V6{RG^x@CQ58Y1;ENU=}I&vw>x)1cFCJcgbU?!qz=^gT7MS*U6G+*^Sx? zZ!eUbw{imAj>5;xC# zVrLEragTF1M~w|<2#kHlhL1*mK$NVSVa;;1o2B`8Pk-cK@)rttI>xCGExk?x>ez%Fae;C=VGQ#E?{mALBL5bg5wEH3y{~ z8e;?I$`y6_N@RFoSAvT@q*fkQ z#io9yVtJM3O4nP)7?E(0_r7+MqWc4IwE9Gm zGRwgYv2CIhhmcy?9xEAFgV=>^N_@sy%eV#xVjikKlvHE%GnIO0LHRc>xyP3k&@)kk z?h_-i^ZG4&pB<28X2Np~whPGWDLzX>RBq*go+;7 z@V4R-4CE6xwr@UbBB7-B#5Adswzh%a8p)hN>F*D8*H)C_G$_As-V#?_?ygglYvRds z;-9BW>)^IF-;?p2UTf(^?|>h_XtJ1fD_X9Pdfb$maCUy(t5V6@9U5pAqNp;I=}miU z1T?^RIK@62s2utyGFha8l(Zeahmylk$_**{x`eBA>v6LrqPjW9g6_3r zAQgq&9L+<>DvHb^$;dpD2ndJM-ku}1Z&&OA2WX`_51k(YK*D;Xv~lT~BjjOP<7jxr z&-QqGk9=Afh0eqZU}&2c7<3j|1?=vYTAptz9sj8NTVE)bC6WRS%@4i70z;wE_u|DC zjZd@6VcWXaNmCRCF!@wYG?()94Uic zt$cj<_EQj^dA-0z4{qMfIg>65?p7fxu8FfkBcJNW8Nv7dUUJLUt65#MPT2E{8gZj{ zGOPpr3>r4~E+vcEF|+LbkK(k^Z=HalS;8H%rbG$3I9iMfA51&T@<(RzbL4TZ)BRG@ z!{MD=ILesmu^kiSK};t0Vgx+g0r9MaF9P@{C&;I{>g8|cxx%=oKJ04{gHl4zUf^go zogN>F-N{e45lf#R{{~SuQPck=_Pr*C`Cb$Mbqe->xpew3QJViIFaK3u1jzjhLDS(( z0A^#Ks#St%5Y7~sTnHu(UlX8|Xg*i?wVARp`c0<;yxr_{voYS*NUvK(Zbi()57xTL z42hXmDs=JlL%<9FES@_TI}4oDu7uVpcmW&+A9SsSg z6Kw4!>z7A}r1-%W<1uc?6Ud|oL9P$!Sf0!y8Z7FMev_-NI%*qe&KiKR3zQfvk&Gvu zJeeImZUvAq2zwL8r8Ana)X&Hio%E@aJ^pSUTv2%Z!l(C+gr_R2zMAclEaPktb^2{C> z)+-p2rStcykA$clV&&Tyrr(D75BE_2GRogL$A6hcE0WLV8`u=^HKxhY!$`u_eL{_{ zDVgU=W|jI=SpY!+P_14n_48>GHFS`x!^UK@{YmPmy}?K;hR5hPEsy0xF^d1I9sGtE`~3@IT!_thz}Pk*9$(*pDIXCO$Sa_bT{|xr@wJgsP#{=l z2e&^lMx}MR`x*j!h{V+;{FUw^*xLGKiAcI9u@9zz?$o}=f zqVdSAN(yE)&orF1bF^FjY3)$3aLlazZ zF>hXPAhXrW`!1E(DC#xzKuH!j42?uXE$$Ka_asj@&C0<$HD~RKx}uwVdX6dWrM;7$ zJBqQog?A@5*LT*eDxdSmvh(+>^X=?fby$l2Hf^J?!UH$aMU zo?Ia+6@JZ>IrjB8uiEKQSX-#b;=EQZ?c;aFFkg>`%?)t7y?bsa ze4wumwboMYecM&W^w4~BtDHm%8;F@-CX2y^HLl?Od!TP}ZHTAWwV=xNviI!xCvT^M zM6dJJG#>?bIf&GnsEY-a@LFi$m39#EaMRZX%qq6s$Lf)l2i5~chR9YGO$)P~@s;zV zt#nGJA(8$w74B`Vfz6b5D9sB-LciCjdSOMrT4@Z4fSRyJ zg2AXe8p|XyrV>m+6Jha|a|(1^{LRY%`^OQ(L!}&0SNmAI>&Y(6cBwt_UxlN}l;cZ= zXEiJJ=-z*L(!e&LkvIqWjTVU5u8#7%fE8wHE*l9Pd5O!x@h{bk@ON-QZ{$Mb==AVr zw;`E9*>K+bIZ$ZX?i%_;qM2Lg)ryjdAU6ZR6PkJKt+9z&0?iQAX(|Yk(Yzb)IO_R? z=d0ksk{yqV{toy;MdJordpIF9qT>Y(Lcaz;aZ>F>Wb~ljlUZM*J02j&tq~*|L)-yu zRa^KetW41ho-dm&E*t`3vGizs{A6f54SKc)V?10ygbu8s0eS?xqxGDiDyW<0eG)h8 zIJ{^wI@4ON&>4D$>>k881fs}($mwvLJZSFRWVf_M)7%Z8#L%@>P#Nl-**ehCTWp)J z09j)7V*B$Hd{XnsDpj_7G47XQBhzEw=sQiv6160zb3CN`?oJj#MvQ>2pE_3pJ^pkye z4mu`yX)HUNfN18}82c(<;nN-qHjSEC%zI0ARP!ZZ&V+j)86xXcRE|HQ=Mul>1`(5f z)L%}bpAGT?S*CnN^00AYq)O1uUhdiJ2avCJ){=K3Q=8pyX4=N(5CzC!&RRF!U7Xp8 zJ8u;in!b&`DwTY0A|%Jpl;AaM``4Fv=zzrak|S*Z``4SibT$_v7aA(iOU}d&=F>>e z$cw$8aI+N5>=SLNaF7RxUCHCWC&-fGH&sCCGJNiLmTDYU)DrHXc@aT_MuN4KnkE-D zJWGK^9f$RoEPCRkl-Q~}^UoS{>?q8}AAhLwMQJbep&72wl@G;QL-X{5--h=206n?i z8bn5a>BaF3k4n;q#(Y`F@sN#$3Rd20ysGW6z*8Li>r_k$UGIGGc0oh?s>1a_y&V8_ zKFoxie`p0Vq%%E92A(%zo%^_$aJ2hHnbRvmQy*z5fo*yMk&(|pv}5&o8k`s((i4=Z z5gQ#A#i(EcZbP}#P*+x4M-{k_OiAWWT;afa&3dhVt@4HZ>!m8dTnOR_k8+w{uucIks{c--E6py#L4#6%gf<77>z^5uvd%tWaCCUt>k|oKndig)_K_ zBs-;%Sx_YIJf9yQj8kNETPdl4C&GthhNuRRt$jRe!onk$D|FF!#kEHOlm*);lF@Or z{Z3=2mdw=@z5p-zS!KFGQn8{$$5 z4cVl%Kn3&524#8zWx|byGZS}IN|eW_jR{?rQXr7%E68T^F#bN^qNeMJOQ{ZFg?-2-L)9EtE~m^=*->1{HL4Q-l(qDY)DSuh+LTDD1hZq4Vqt>^ey zzO+(TKzqJ2yPDucomTP-A0VZD=quv7YR8WxwFF1OAF6LYkc8=ne(I?o-F*p~K-+Ko z5VI@d!p+7knpc{cp34x&*)Ya?W*6|o&cZJ}HaVq9M)G}FSu8O!1WOrY%7Z9fW8uzq z&*qM5ti2~e-`CohRwMC9m52vsUt1YQF zBy?_W0RhUt9iyPu{ShoNYd^MXM?t@89c9LQG;M-0ajUp6grg;3%Oh8>K_pjTj~NB@ zjOND<9C~{C^YK!+7;0cc8OdR~hvawJBw3@dpG%?dE&2G^!boK7ity-93X;Va00utB^#pTVy-(cu9 zgylgK#PxH8i(i)(jQi#f@a)$k){k`a3<2S^nDUx9q2E4Ks~SKbV5@(CEORME5sl`L z>*j4Bs9@PLigNa+0t~6WLnHGM1#MH?Z||O#vXr+&rP8A^>M#+`ywy!GqnxW@#~`YT zGk`ilBYQDgt>+Tws|GhVpXD5`(~Ynp6>@2B;m8873AQGPr=eADuFP$EEUdr#TPoNb zNexw+%iyi-7?elqdFAY+_VeMIli^y94|*iq=SJw)dwZ3tc3m)=Y^ME&nnVTxW3&n4 zor0Q0B8}EUr}DeLvY8Y{x=QCxc_5S`bHsDIoFyrRKh0&hilkJS;k@`5HLfWb`laf8 zVi%7{L<@OS^bAPqw43(k-t8g>Tc%Z(!s3GstwGqrjU~b|qCD z;`_WfZ>M@&xfUO&)_}6ItPAflQ-;P?LC$7mdN?V zCW;uHJ9sLsG!DF-_HJZ!vH*ExVTnINYjEahk-95bo=G;%88LX~0`4fdeovd$4L982 zNyrwiFpG4h)aAG+1~Js1RS7|ih8(up+1E9ShZG7uv>!IT@6VKQH&_W%mvS5HyhvLs z$K^RwwXWJ#5HT_|I@qL&^)bSOek#a{`i0hu9Bt%T7vH=cxyvitO+GN2x=c=bdQd(Q zBr!U1OI6dL6&z={OO3b6CoI*(YWKQVI4bG9Nx8{4LiG?Q{u}Z6A=jtq6z6@*ATGhO2Kzo zjU{#ykQ@pJqerE3TQp)Q{`*QdDb?bY*<<-?AqISom@q7^64_%QuA)qVLxh960l9$; zi#*#zPO~e2>>?1f-bS+0Qk38je7T!>A78GWkq;hQCUQ!Ke|QH0T{Op)#ct^mE-NVG zxhMM!T$&dpGXCd^9-LI#;HalUNPl3vMfA?7bLUbFMUkQ-7A3j%EO`@qwa1mh@fBrz z`bbAx{DAAVWLEaztTX!r6JuAmem<4z*BmGT^+*Z(7-d!LUVsf{tt#`6fUw%;UAI#V zE(sk6>LHjZ)G4>QNZeZ5EF1L(4Gr#Wh#0v>Vyn5eFCXHqtRa(5JvJz;F&o#(=!fWi zkRYa{S6(6WmbJB6h8)+qM!nLi$i2J@|Lf?CQaBwonUvh_KMwx36{8Auo>HBuZ>VK= zLVF8tbSH9fSO;`;yCoaHtcm<0zJb&6=uH7yQT#=%H z=9$xeGp0bVdML`YxG36j`b*~xHp;33=~{_ZkUH zPd^#lqZ(p}SA-8X5y3i>}zD+FW&g>+_e)b`aftF54m+Yhz$#~VGF>LPggKZ$9cAlAJ0gObFB)>;1=7ywveC&(rr z@cKWjy=8EmOU?#rCuWYBnK6c#nVFfHnVFe6F*7r>V`hvYW@cuFJK3{)zdhMod(Kwf zsp^{d&%7h4C3Ux2dNhp6UGac3F~HVbYnlh1AUSbz(J3RC+9?eY>rVob#*{%pLq8== z#-1|kHj;uwPezSn&{TZ#)@!g|%f=3$#vx?{5$cC0gUl|z;=DLc1I*wLhsQf;aIc#=ho)7&8)~&eW1|~7fP`0li zl(l$i*hmC13$NTuYW^QcXwx%BMbRQpNVL=;{l>FGTt87p<4;%PKSKu;5HVYqF#d2A zw8{)%4Z)0Nb65YwMN37;mPMnkv*A7QL$o0y(c#K51#`Pbyn_LfI>y|Hl+6#;SNmqrm77uyK(m9ye@{+3K|aC&tPvlsRYuY zzpgsa*+u3hRQe6ZRG<=al9vSNVVSJ^Nd+sg`J;;<3TG1`(tn)hr68=v{K{+_64cp9 zs6VWTUf3Y_2FAGPd(-f-{73;Qz5*mgut#t4u*_W5CjJ;rIPfs33WsdOjwrPCQ(#1w z^S7NweD!)wdYSwt`s>0q)(H-hQfijQ!*p|kP}J0F+r^03&7%61cUC-4;xP7>8T{0i zLb4BoxQJ1gFp#+uD9L!uXaZDK55sdI(ao7M(#{5;M(HJT=SxabRjSN0qk#qCw4}9}ARVD7pWeE@rSJcv(9#XomZaj!K^_2dG z~B{LG`C z;6=M>hD&sNu0oF1wcIZ)y31X!k)ueP0V7_VyYkpLLLm#B&0L|c2FR;0p%P=T`gY?jUEA`{S`c(Fg4EN*tU%Tg@U&iYxDBb zTK~t=;F?G$u5N_)>8!jB;kmGt?qznF-d7DCxp<}8a=JhpR~QXg(JGhGmCe= zZC$cHASwBHADHyfj{O;^t`;aJbqz+uc1)>0-ny=5>yB zzR5Njd|zq3b$<8_Y3kC_9*YhTr@jN+v-|}}EWqn4%8T(UDT+z|I^rfNbOV~w!*o1T zLF`nci0WImp)BM$ELDWi@=f6e>xUYp)C3L1Q^{Xm-RGUu4>iuf)qE27^z=M;0}FD& zmPA=>CLKMK#BVHOvn^Zk(>?!K%|4W(h_K*zb&;);W}9LZV_$tg|3sk02m%Fx@q(Lb zYFEOFYl;@DFqpVSZ9O8OpP!`&!%X4|Z8jQ7=+WBhwIcm-j0^VdaE`u+NZI}w=D zPrKY5x8#eD?VoPKO_4PM6P9U?dLiDkcfP-TMt~8hcj)+imcTCDKpUoti)yC8Ml~5GraQE6wxg> zznJyxLK3Xk(0J2Ak}o!PPR^l`O@fDBxsn?kJl=THXZ6iTgz~to9f=bCO24CZwc^~l z|LOUt#8uPzcCODXB1};HIVJ*MDqcBkd8j7&L(}#VoDY4dNg^@_o{TJ2x&BOg4zENq za@`LFjp<QAUyS7 zuJ`1H_ynbd019`i=3}!w^yxJ#OW#1}(`f9Fg!@-2#0U4VQwJR4=uaH4z_<7`!zc}K z!$vI<6y~w#Ybu&S&AM2b;dCJ7;4jV*p5&-phbXi^{0t#R%cOLpl!!1IkyX zaLI8r`4~iuQuYW#xjD)`J6hq~F~U5N2P;K}5kZV2p}WE6JtGDl^78U-uRa)mz$+FX zk8icr40AFi;)>W4DJjbc)iqUC6&f{Z1TgWL`pA3v(ezgREtUxp<x+B!sP8krtMo;&x!&mq+rHQWbG5zjBADg`YZa^-isiiRW;{xhu2C2O zKpho!6Rv7`rJ_Qwxb;PzZk;;HJh*`ilPf-&W-kkh{HX*2Mgs?!;WWQ}?Sc`dukuz_ z>Z#aeV$`-eb%r2BKAs_8f`b*tal6n%Y?PZ3vsiXm8lZ?J331`nK32q{yO;xCXwyWM z4}8Ro0>~5s){?*+_9tTd;jXas^EWP7|3gPi2QK)H=ZU7Y!x$84P`Bx_(6C8JK}})%=k5l`w>F3m84c z7Xkw5Xfx+>Gp%m4sfcb(D>E~LxBsi`fUqSqTn)*XkwVUp841B6`Nk@!Dy$M8E}rEJxbD#kmQnmz7b| zj8Q=1+K7&>W#T!5Wb7aova4NiY4ix(6KibwaU_uGy{mAVUFZva31Ml$crsx{w6D?{ zi>B)YXDnnr)NE$>Yz2)wA~3-?0_sX=KgvM5v^2p+-q_&67N zRu-W%!h{`h7Ozy%e*T@(Tu+BR_0iW_@~;Q1$4Lp+m^ZZX`vK#n-&s~g(++)~fTohJ z@fd2ZpNs%tGs#^l+l?w1Q*fdhzV7IkP-&y6sXzUzMm&WYTqFkj8{wpaVo+co+(|8@ z_^3@7@^aS;@MjTko+-rVmJxUzNf6mIKB-p+8Pd3!Sx#~1!T;FsYrN4|477}4nh{WM zrbTKyNhi9jZZ#3IeY+dqP3s*VzFge7(zkzmxsn0r$bo(BdnSOIjutvb;C+MpQYCU= zjJRyWPrOLzeyG10v+rTrcOq8t?p21~?&iYA#_lDN+Da39m~eOw-0PDUS9hUnB5>Qy zf>)LHVpg=g^E{HVj5%7g?st{ON-0t(Fy|{B^XV~ItAVD zKss2om{UkvBl%+d@zm?co_1Oo2^r$F@hFrb$9^NV9G7gk>=_z9CZ5P7f(C z1^I(V8dD$8AexNuJmXkPm^ubgsC5?zp+iT165Pvj%*pLh!<3@i0@=^NlfaHCp?QG> z$~8As5LP_->?sTm-2&ChrZd<~P*-eoy$M%>J7?`ux8dl-h3<$-u|--!w9Zh?Z=`FK z1XfBA{`54bam3nFL6*86aOf~j5=!4)RFLK5wWd3%)z%k2s4;3QwMe>tH2_H~%qQz& zI2*v^`qa2nrnfK0^b|YP;ArJ{{By$}k$?~L0Og=L+ju+kb^_OzFwB}dxrK?UKA4JJ z0hjMCeserT>P(i1#_E;;A%{v);xru++C(ZvRWgy4Y{*UM8-HO$t6&pS%ob*umd`9% zXXi>|r3D(If57pJ7s(a+nW&vqNLmMd`KO@vM?#U^772r)&gP-2yzH z=0U}VZCgDM!Fl!E%u7x|3IT2=guvu_&!8!LfX1jaw6D|S<;of5)R#vOj*5hECDU{0 zXucAJmRE?1^>#DxxzFu+n8Gp=8iSRxgYqf^>1&@Is_v^DhwcQm;H?m07P@2Z`lh*| zdt*;Ux{InxyaYMY>Iiz=2LW5FARN-WDkBYtx~eppTk#+P7aPR|%!@2KWW%+0?E=*_ z0?TwV=b2mYSCHv;q3Tt0WSH9zE24eFk94$R-U>GG8HNrjE;lmYl zC+@9cmcU^~(BtRziXC)#aQS^UCXZ(%IxE(MCL8WBLus0(X zCG*XN+C{0t3W8%Jt)`v^N_dM+kHlo@*KvRKZ|(y~EQ~dgueN23Wmxtzk18*Vkhfb}vzFnZPW@h+ zNTh$#hG(8PomnT%V!{gC9E7ZEXtWAUEMPCLMFmvSSI(t}L(veuW|f;li!jJH?MB*h zbILvZ7;y5zKHOPkTP`lE@gCayR;aY}%&NjHBq_#6^sy&1r{+50Tz_^MgBz>`ngQ6i zvyqyW+wjPw5*KlQClPZY_);WlS3=x{HNTn$^i#q^EQ0tv6yBD^V#*vwr}x{>;jFCh z?ki>r%hSLA(Bs; zwvL()V!~L6=0lj+7sD>p$%`dcSa^Em7D3zRU(phvCHE(#z1>hfYY(sf8D9hJYJqqw z=K*(z|MwvPJpPxhuC2X|qs@Q$`Cs&T_~YkBu6kCsmPQ6PR=)-MqCu)q5&;B|08912ikx9jnh~MRL^JhpiM57%V~}&<*)JGLM7P&(_(`B&r0&+ z@TC0yuIG)ZM~SDluC7+H(H_~cDQ(W|lj8g^RaGzkzL^pZz3QWHiKeHvw242WIV_nk zT@iglWUaM*VZcd(`Sd;vPPFP)uUj}9KWjB;WsIyv*URg$NsTDGT5>yMpapT3oT3{O zCP&o27O6d3Od`Gst^N6kkz~&_90*|Z2ryXxM|KVL9L%gu{@_$AdK9XM7e?s*F?@=r zcy4ZEq~gp`tA{D^hZ}v0kkE?@5zPX9*-qvt!4*spDyKn$J#~!*3@;(a%+L_@EtsbxEXIX3*uD!p(SP>BUq;mi``ZbB-|08EcR<@3AI{z%j(6P4B`6mp*AG}gk zj!}t~%T_DNQj(9*4YMfM?rl+uN=`^i$|#9eh>p<=iAhaNf*D{08!WXwO%0j&t-CZ7%#$Q`BH2*OS7ooaPGWi>{ zv2J(1Y-`*jKb~AlU`&H>zgdfbw{fP#5-LpDNMk5$2Fp7KFp@Gjq>s{~W3QFuHXtF9 zWk8DOrAw`Lf*P})Jc4Yrf3{k0lzEW<6c&mfXdQf?IK)COITqTRZ(9pG^oJu$m~zVg z5pEwB4h!hJdNEF!Ea6=9J3*y(aQcfE9$n@4oeTrEs?{|g#YKX=Sk9r}@->{!mjki^ z0^GC+sk6zXd8r zUpN|Tm(vk*a>~ocuvFWm`aUjhoe=|{(Dm)JM399j5TVTdF4Pg$1g6ucJcp`$7@ZM^ zJkU8Y1st3AOpgA>JqlKyYtTRJ6~N?w_{qPBME&9DY#dFE?EllvjquSwDgoZ94;aFL z@t6Mg|9#IdpecK6Jxd(}Q!`7$Kdaz>vy36SVNn|KUv{zk%Ps;nsl??Yt`Hs485tbp zJ#+l$G@*E9HRTgizE;L+V89zKQlhIn%-I9~mb@4skuIwRAlMQx{+56KPs0BJBKQyK z{5^UL_LBA}o0SvuQFlr9n*CXSQ3|+--$Oq_`Y>yw3yW%d4Fd&c2TQP`t778AgNDC8 zlv;-R8{IEPXK}6on$!L+-T$_(m>4+%WUgZk=uPy8sVha%!vRcP=svyY2=)Mz-bvS= zO3?bV5`I|TfCHsT*_Ag$o`y+vh^M9w?Ueq$+{ zR$l-dV1fF8$mrjiihpAXkTvyZ)d`Z5umJQ4>fBcWnar(N(r!c%R6#I@Zs4NR<1jtg zA}=Zoh(s74k!b{n`hwKzFAoWCpOa*F z+E8N;{<)Jrc*!rWoRZf&Om5}|@m-4+zWs?DMD^H<9n}oD)yRF>+6Uq`Xv>l4BgddX zSB>(c`{mosUY2cw(}w$XtH8k>%<0wc==F#}f^UXso#13~XP;>aN3v7!C>P3L?yA$0 zT*x6f?dq%LH;@bkS`VaJ6I?|9`3sqA+&YY}z!*I`OZ}J8SFPu1FECHiRbExaX1+Jy z`F+*scoWV1$4ZzrZ3n;STmZ4J8SMyq|F%G!kt_)P%1es|NSFuk{g+nyZxa4TrvIO1 zoFqG13g~FHa!&>4?Q5@Lr_f4CtEs8^08G+n0Lx59FdP*Aa87DvjuK#7H*BycX*{4k zT!TxgYMPf$iE5N=Hb7S{)Rif=N}%$C2dX5=F0N7jEOOu@)d>!!zEh!#}ml=qIK66qm7>+r>2R;0X-Zf)W?z8s5=c|MNr-&z#S&Ng%ER)>UnQBqpI>s zvLmPEtV8{#qgGk6qWiZ{Zja-3^m%K&#yDM{zcCD zKfR~Dt*M^%A4X>t%VPn^^6Pi;3MWeVh_h7aXg5=m!f$Dc#Q^ptR1x9zFaz7%|GUR- zf+&t9O{>QbkNbq;2EMl*L}Oen+Xyv^z}hH3%-5#+vd(shPz#NUGIy2!vU?I}r-txx z0dsB_Crez3X-GZfIL=b+nmF%-Anqa2cYfdot~I~L&BsF&PV>9~?lLcAa-(PiFbp6H zV9B=0Ih38z7KN(o3Bj?a_f*#bc+DPt{jqs*%k?>#Jn|hiNyr_T>wbI83!?1!z4_G5 z1rCrXV|my4kTN!x;ovtQn`pUk`gv^GATSy}yX!9w&5j>EwkSw9z<~SMBOTZ;f7?_E zaw9kX1PD+AAV7itQ6T|^4o23F|I$buXNEt-n)L76md|$Hn&D)Z8+;c#PKg-x&E5+_ z-$LJ4$CUcygdEHhSY)mWJ5}Xj$MiM=h`z$-BWNdyG>CKwW9aA|!jLsw0(!l};^2I? z)eD8yt1hJ+38-`q>TcbeKglsyqX))9nflTCY~|1lwc)C)(wIlBE{ZH9h6OBRf@2H* z1=Q+ZmsZGgT#p#9HVwgMgjjBQww5mRIv?lf%&I00H=;epzK7z?vL`nLOSF;A^}bo1 zQ37xN`FOh6WRHvF@h*}1()HD(a7IZAGK<}Ba$7zVW10fUi|_vjxiS7zZnD4576@zC z$lc)8rgH_XVF3XfJDB_)rqz!G9R!44E>%*3=h(QtCh?~^!<%zvf#4YBW3gZ#(r`Uyl;g$Uc6ij*9nm&` zwWS}js8Q|tl~$l13n`N-FpTl(LsE4qtI6<0hD?cIBW3P z5|#Neyi!dXb>0MIn8~Rvx6rI=(jX$_dmy_C7gCxqqrI z;{VpWA9gEQYvXPYZo})%kjsPgDsP!fW7cBMF8;LKmPHwl_fCX|Q&m8)BKwIK2+I%{ z%vL}s+(TExofjU3;!}1mKjmt34+O|?%KiQS}Lmy(nu7xe0(yf zJUHv9tVY`~wd8%ViSIvqLISK=&tVL{-@db&5RfrzmiJ4gcFVD_hy^7r@FB6AMO?@+ zaL;NT1ip2U2f=gjSHc8CHVnBy*nQQFwZ(aS!<{a3@1ybJ4Nd)0gIz7ntBrgN%;w)` z84e08%DaY4^#N3SZM{H?XN@Y6Vr+EJA_&%dN51I*(lSBUwJqoqnGLi){yK73%d@wt zOfl5Pj+Q|AfJ=eucI8+ccSj~Jt`1DT!~6K#Z`%a{Wy|7UQ_B1Q&y@bt46Niw%>iDy z{h3Oz6F(l2jNn23fW82E$-)ltOXyN^*iO9c(|J;TnTXQ!*6qaDNUE9_o}>p6tXias z5wgxnnYpD*)~OJJoyBtv64~6cF^iXf!4cQ(B;}F!(s? zyUJQ0hG3565N$6ns<_7_N%UAEyBNi|9w(zh9*#%>JNRRhRUX!o=C17}W-)bOF!s5y zDcxldXgQuwg_k=QCub*Xv9M^AaB0pdzwRA}1k89Cap;&GzpJB$>;|<6B;3rZPk0BV z{7b=NZMLMDq~zCW3`DqaK1StO$5doS6VKql1pOCFC2d@yo1ZauwYS)?AqA>xTDU1O zqw@h)#$lW1wP6L<-U`@|D#1ye+XD}5dTy?VoYXOR0Tz8`EmXrlnq^m>-V|Pc!{Qp$ zTr6J)xHUb1Ta)}BmjinvTO&P3BSW44FxP)Ax2JIa_{hQU9w;VSe1%>Wc92#TSb$=L z0Yp1o%hvghkPpVhKCXjqcR+Mb1$#7~RTUe4P^w|)7H|Co|4yVkYNWKGB<4UKSIom{ z5Tt3~T^fX}&UtwNRs}Iux{7W;X9((lLQ6{@3jckmLRRxW5Ee!m7+*Z2+`S77Y?!B7 z3`h4v025HE+_dYUJ6KKpfCe)#e_l|#5l4V3wA(1{OQk7&6DA(U$v!^S)iiPwj7?he z0ae7d+C+!N7Ief!BBi_W!Z_ziiX+X)vu@4sXT3SKqByW&1Q@+kZ;{^;3<|(f7?%LH zkq$^PN9P4)A^mxQ^l+YBWQa7a+)T4^TDKag zZ8^;TUe=Xxm$9Q-y$a!?vkV*l>ulZQe0zB}*?LF27H!#&bMxHbOmj6Fv+st@vXL-8 zvia3B7c(IQ-tWHO>7?HJkt5SEohE$}rQH-R?|e4TI!JaIY~sM>&r_U5V+1kofEvPK zj6f4I8DIS!l?i&QAS%M?XQ<@Q33cw3XAD;c_QO<)VI0>B!(2TGN=J&BHD;GfbxR%~ z>ajWNI`x-VlN%1i8i$~7h?{66tYDvd@17iTH5^UB&hcY$k zE|VK~9+FZN%i$8HJbKdJ7<*y^HxGl5Mp69YsJWxljc#bEbzQo9NbNfqBifWBtg%W# zN2`X?MVMo*9~orS&lN*qMiIbf6FOZsrpJ!E_e73)FnPjCmc`lvBNo?cyEnaeDbJQO zJu|>%@28szvP|9P{jv@fCa1~Ib#4P~S~*sqL`^0|?LuUwFb%a_TlV=n9AeD#-8Mow zm;2yz8Y3Yq>7ub(!S%rxKRKqN-pwKEr~RwniYW2#qEh_;dh->KnDW2sjf2^*2pn-WRHGCH_dIcsmC}K^;*z*rGi*2M>hxxQx+v_X$hA`U0xAWxGBAPI zX|s@UO*gm?un@1FzOr%}Xu4r~QM@#5$=wT~{)ja0{i;Cc*kGqo1`r24kpmrm@x|rz z=g9M?8qrbPtJ)XH;ZdpvZj)6FJ1E)}S8XPqHlRQ{7yv|)4nJPl%BO8?S`+zmwxW;w zH%1#LH6D=NI^(tHK2Y6pNm&o<2h@vs=5YNVJq$)O=`%RoFvNL)C6El%3N^5=qX&s$dvz+9x>eE$cZYZw4 zB)Tjur9jFwW(rr|FaHz%0KQc=q1B;9Zhpc@L|eZ3IVRtH^#UOTU} zoD3F%=xpW_n|lIadglO3=KmU<{bPFn@TEcVlYjyx zn2>X?a8vkd;O#0|c+-!4dT6|wW#zZx_4#mOP-O_`(FyhV`Pu21i|dp2yOVGk+u+Kz zDa;49Y;CkqpTq+qF&RqIxI@C>iW;Y+N-ndSwiz%j)8+ zlFGng)=q_Nst45Y=<;n>py@JlLfQa5Q={kY3jSpHqPSgVIQUa>olsl{c0E4hAr%;Q zg^w|anzER9-M>DNnhUY%n~cU7S<)D$WZuZev~jcY&hi**~gMYMsedN&-8<5 z;EDI@Wzz6y=m|}4IIps1UEFoE7VnT0x%6H(2RY-h>;df06WtXa&9nsY`@w*P^nVSd z{4vqadX`TAj1CpVYWzAG2)(z3cY~6v>&UJIhJTM=Dgm2GiO-jhJ6k2LPy*p&!h=jt zONacaIUJ6u{F(fVMl`q>a?+M9st8EAkQ^na?WV5ZfNOJH=4oIlg7v3nqw-|OKuvRl z`xe0@wpCWh6k?7ne>=F5C(A8IZtN&s4}MmO5nqR9*Zt4Lk_WsHOBk!SUy?Cl}7qSsN?55$BpTo=SZ%|do2CHXDL(s zZlU*MGV@KTcZTt~Bc#aik~}x;?9W1@s|7s=#0P=G0oEb-KQ5XsfM$?77Qebd{_BwN zFH0HyRmvxH{|Z%x7yDtigc5r81Aj3VZ*}B~A!hkgkim>_sE^4Bv5;tVy3`A+i@u5U zjJO0mF`0r@qSE!DKErhV$}}bfcJUOpIEif>WJM&&jCx6GFZC2wQ-<84v7Z01oNyg+ zfDWLPJjLwHoHW4`RLbmI<4C@oNwE>77E)m2;j$&hMJYo4BFVHcn*zTarP-$LO!JpY zNrPlaQ|dcXsYcBBsWsV(ZYB|1|6MG)E)Lr_;yLZg=b&@Q?jovKSkT%Wr$u?C3n0V` z7piNREVvjk!HD~+?BbB9o*ND}HZ4KuwUPNjSsKHMtuf4#kWx<4RypZCyIP)4if^tl z_GO;MxTtNt^xxAHu3dwa=wjjF7zn&R6`i*W;li>LxM1v`b%upftxQXh_vM%97{|T{{YS!nR^lR>53d{9gTZdYff29nwdzj?s_Rs^{ zfrp9%|3Wq50mlXnN|7}3AHgf1m(sc$u{Zf=Lfg-qb}OkHv)ZmI9$eMdq)HB%giccm zf1d4Y^9S?-3)j48bX_*{T`2;qKI2B@mfp=VYIn>fe~J!s$`xqNWu5KV{umt1Y+tDP zI;hocKH)a6L#lEuhV-tiySHOn<9vV`DrM4J{ehB4(B1}(gv1Ze<@x&NLxk2&E@_LT zFXV@TyM>cDI1`g7<##Sry8v5(pN~V7-qHVs2_=>kqC>rZ2lO-z7D{b}Uq7O}B7WcV zfKw!?MuBclUf);5MTD&!)cDmT1F0pq6xLLRo-x9sv9C%Kw$Buwi~nT>8V&6xq5R#5 zw)4-1{7^9Z+6rLu2(bHQ`0s}N?~uNLTMa84YuaDDMkPuC_ALNjU!Wq8L=DWDLVL*J#Bs-3N7YH>>k+_qV~^hI{U>w|1mWh3FqaW0#2MS=Q>m>Y4EF}>+{Y+X2nDzl)Z(BX3mf;Z8SRF=&I_%lZD|rEI;qj5 zbfetqTv@zp4K%BczpZ*D=)D5Efc zFldN91VKbgq#hL5VSu_$O)m3*yOSW`RDQO2L#^C=hFRIxG>BhBO9T_9*KQkIz7Ai! zH$Eb>tW&4Woa$6~$(o5$W9U_D)HNQO8bv+hDrCR#;NF^vL-&~_f{JSU5T&JoHcYgD zXf$LfvqSM-R4Q+U9mU6(RYY_6(aFX@JXeI8ORS)OZB9IaVJ`d~<%YAo7|KP{KZNl1 zdgXNb$e&|5BJbrBCsTm2NkXy!)0B3?0I@myE`#mG#}jAJQ9;7bxNKwXl)Okm;g`E} zac}`}Z}SKj`A!AtVD&zvm3hqdgtcBXFFwNRD@u`M*bXy?@?61ia=kzkd8sI? z26-c&Z=P_;dxvk0mb^;Eh;N_W`C6Mx7ZpXKA62M1x+*pM%S@vFSqL7HJu#z6=|VJ<&F;iWoHCPp{Mz*f>Y$7%yzdbH^fH}z_7 zI@(4Ng9qa&LADuK6rX0ZhY7177o2*a{W3o0XGd*00prQC7`05kyrv|>zI~fEhV1lU z`?NSR=uc9%R6N>6CIbp-#O}Yc)K-8pFilx|9k=p;6~C3Ksf_(7l$|#HeRh*oQ{u^J zRw2;&XKPd7NEb3@Juo7n_2~;Mdns4SMa{O22!;>E=NpW?*HzR8KIWey1Rvs>u zLpOL3G_lK*)%dok=}y#%HRzo00;k3NejOhjsH%Q8f3qLt9xF8dc8w`gee8|M7R}?| z@CsCAmF)Rj^dckL2r&Bdpi^v^A~V1 zRgq-NWBG6b`ZpIoBklIvj=K);c@cd-@YxxL^fU;W&yu*zLUN=I3P#Qyoa&BGz5;{H zP?t$!%umGO9{>1Q6#pvs2ELe^b3M@@ZMgfsdz6YdC3DUw zw=Jq2mJ|4TWTmUPFKR(0Bp*A+XXIS!bKL%%rE`##9&@K)m+w`A7;49i)HPm|gojUP zCD6*X_NeYVM~mBy%*`i-ESa*O*%ov$^GC74NR6I-XqfBlhqvS2A2kkVWqf&NjCEXb z66&RS!3gW~lHR6efcCw1be^A>=Q;jt$O@%-6cGT4T>-{loWOn=GC)WEvl+`jR7FM# z8UX49r~?6$0o`5uS>^;C*cwt}vp&-Uo3q?L*iN_3C>BVTjyq~+hOVs} zo)R_)mIkKqwZIK-f)y6!h+#dY>HjMpbg+8JLcqqg*J5#2*}HO_Uu4eyNx}Z^RbD@ zYsvVH562Gg3+BO8O#HZeMru(To@@@idrcOX(0QK*!wkt1O?OlYm$%67H)Mlr9_wZ!_ZOtc<~I>whcXqF84QwFwyBi(Rp)N+Jp z%=t`Pb>s7Z?#`fCdiKbuaAc(?HbfHOgC$iE5}I|PT>}-W#3_M7*yr-jKD^9kx;wYY zR;5vRyzZXB|5*)IK3;Wn1ElopxWNec{x27?|Gq={C8b|Y2YzWlRIH2z0J5t8+&i40 z2sv_XyvB+XRNP^RATjsYL2W8SPg4MYr%i=w8kh5i*~jO4uSHq)d4Oe#;oIWq+spi>rXiteEbrEF9;#s`0WenL*9TvE#@LTh(5N4aGaZP76OAJWcQ# z8N1&4=DKQc1xzVZQeo4CjO9ys5mX@svf}+3$YAD)BUno~TOKE0)r77FJdjTPP%-pT zdz9IyvnFWpo+i&bpU_A4m5vbcK;0(?-!R6734#7lyahg z#5yr*aV61ls&OR?p=(O@fOI5H^kc?gNXZR#VaXt*(lZ6p!)q}bLo_g~d+oY$qi?bzaQ4z}1V zQCmyUmZm4wsG317+(6~#IovpGl2%{{lzFk#cn89MZulCXp!T1!erCxSbN2{UmE_G~ z1*XE%Vhu}8;vSD|yZva4is|RF)`zLh`@>A$7)MM?s%_lta}%kjIsyU7Epx$yF#!5X zk!U0BZa|h5;b-+8Rv2u@HSF&DCqKn(zvcie z8s0X+f!zon@7=PgS+*%Pd~1o^s!-ZOM;6*}vcq}#`yCB;D3^{c4}H2KjDQSMu6e%i zaL*@^KoF5v(c2zXsSF5Ijo0>`Xn~sClnwS7Y#BZxd0;uk2L(Z`7$?_JE~ zK_W1tW}H!q4kRBZdBdJxLTs|$%j!9>al=-jD#1R7xV%9%vv|ZWWd+N6EcsLyb)c9V zCwM!;c4?E2`B-%g_P`t=bmMvJ(2;%rElmcG%5;Vwpe&+*@i$H{{|ATl|7EHZlT@^F z5HjP#mlLCOlh7s9qBM6>)Ybp6R{w*OS#heMt@zxr_8V37-qf=BROsJKvaWt@>Hrpy zI7bFZ%Kq;fEGZ@+B&{Gs>*(sZs&19A-1zD3Ovi6p(&Wt8bWtp!QH)#b#36US^E&

9<9>;Y#G|x zOHr+KnzUz|r$*@aQR~Dz{7$duyEYT})8L%<%ki{V@9&9Y#%3pXQrl`REL8^^ueYm3 z4b81?bZe_??K-Pf);9B7y{8$jsyb*}XHVCwr_UiSH_aPOYgP02E@fGG2are)#uRI1 z>MriSw$_i7qID1~>JUu{Qbg(`9P}H70ksuKwPk{4ts_~84K@0Xr3lKm^NI#yF%i7s zYZdia#25aNIVb<+W`X}h1eQ%u!Hs(9)zl9PXTkw ztN3G`3d+31*t5NSLv{-oW$(Q&A2J-sf-|r|zFAlFbz6_pfpQssD6X*V`;x%@N&n=) zpSlCF){s&Tsu=hji~)=e1h~OI^9wS&>I^c!ItmXHwIg3sPLYAirmES}yrZAItK7+L z^|d2sZ^45u_2_g=Bg@6N{JG(cY2gT7qiz+uEq3gAXI(cMGsHa+}BBx96!gj6IlF4f`W-HaRFuca-~kN zR(B)Gf=^q|O+KxL&R|EE;W2>&-LO;ai$KP;BSPW$Epj-CvgY6Hg}D+KQ(jmYY5PDY zetbXKVrXK2izos+dMvx5>wU1h_RLuAuyLQz|^rUjN{}$lIvu+1Di2Wj?$E8 z02c|$@C8~Z@?G6VWOTikI%;HN+xeQVxu@iymgB+X^WcK5=j?p_L+#2iocth=pNeDj zTH(wE~&~G9l9yD7LwkK1r&L2PmqQ6CS>zR+)3K>JT&X4gX!f@ICyOp zrS-_YIg?ed{R>rO+0}yr$=~ckIcb7?&$hl40$r3s|1>uO#dk|=jY}gP@ClXx&7ok^ zr3j!JhD0N5<%4x~^Jl4|XfIc*utz6vst(Sr_v-_t&CWDj4q$})u(Y}NhTq{fcMp}G zg&-NVs6$HAuA^#V6WsV{t7 zsHS@T(Eh6*aO4+!aD0Xe_gPEvEB#{9zA)LT>Y7U=Q4q%cY2;A3WCgg)o~5t4#+YzW z*fScdexoJhGMB7did@j|{Mco5rZMeo3(>Pw?2u(x&KWVaf*~xbC#MsKQ;SH8bA1Z0&y&n3e~ZrM+|}VI1!|-`CwxERD^K!izkcfs;ndZmx4#j#JnV-lOkX=b z<%OZ(S5uoq&B6Y`1<+IIaDUB1N{a9sre~=TD{^vB6l@~FEbWgJ*N^5#r49B4Xc&=W?f5gyhp=C)+1Mwp#%cf{ z3r0I=!tIHvvkHnmFO}1Hb|^oO?V0ifE*gJ$lN{8Qu!oa;wRc~pzw1RA7A{|?%M{gZ zX!h$Y7&35bVTIkydvb4ej_g{eakB?SglKH84VZ6ETcd%UW5Hv!m|7_jrU0&Yjelz^#;b_sA6uJ&O38glF2$>jyAD>ah zP7^JgwU0(_^VZzgIQJR&Db>8HWd1<%K9%O&*5K&@Qz>Rue%lbP76Y!zqU=H}uiAr& z1o%;m8U7WIK5`XR8h^n3Y8xqE!gehND?JExv)GGh^I!&P=tQAnn=Ge#=((|dEUWrk z#4b{_$qzPz2$rcV#*bdfAg%A<^DSNUbMVNzrfk8ggnYdFSh*6@&uzx@0xfX*9FN7# zBM~3$@T`tfk2r~e=YYIlef-D=;MP7P!3FZODF(qXUK;0ru*n-zy7Lk^Ej-k9oM+He zL8!3I5AJ+zTineSB8|6j=^2NcK2EPs&8utV&03fy$8pfXeLy=yw5mQ-918X2Q6yGS znjDOV?}L7XV=>eSJ&$N_vGwcEl>FqdDqn71`#uZJhL7@iW0=Ti(*a>mb!Y+BGS#e; zANXZ%exCI{6-D?b`MBt9!aK~U#Pm=H)-A7!)$BYOuqicEmbpKXq9^1AE17GzmT3c0 z1_Q=ELKVvCw0!{-Lnm^Yhdy`J;i-7FrmNW4Q}iI;0QPxiMo#w}KoYd z^E2`rOgu^nihdCBPxGWJ^CJyi{~v zMQ5>Z>6(i6-mGWD-djwVZq^K$-nI$iW=DC&KKBAR610JLz(}pM&M~wGG6*a!Ow;-E zUZW$jnG@*_DRzzKyuK(k))VTkXK?t_m%9uDNWD7n5LLK2(xL47=@TYi*Ee5e?H%ef>{ zptH6syOq*IbCn&=xE#)-3h-r&v(Hs)R1v0Z%tT~PM7jSqd^oGtfJiQu zdTk6?CFvO+A}&zEd2)Y7j=8~R%~5e+nKFs9n-^VCc(KOkq6B^V_J-b{C^wLQoS&1T z(t>96KmmT8lCW#Xg~to2MV6kE*dm2-k=R+&Uy^K8ep^eSasWGFTEZQUOp8AS>~7$f zU7To;sPMSJ=ZZ#*}ldE?eH=16WEy^a~3EwN88VpHw~H(Z<~( z&B`}e`+PAcz>s9hrWhaDyP^l*_ED}^*0##SXTo=8S|}2~;nqwPx{3KFSj|yG%P6so z$G0Ss@}x!?6-w_GBYnfz@vwI8A?>;^ErD{BxSS%0U}sKs|BxrI@H~oK2Rh8O&Wf!P zxA>U~%YcicRXmB?juUt1LEa`oChPs{JawWPwejBP%s@J=vSzQFefqp?ftkDD>(POS z!pI)y(Nmm)PpIQjqTt_PslG$xejbuUM1MT$ffvo`FCkgVEixG7353{;hM`Jh2yg{k z6C*&qhC99&AM6lW0DS?KMTbTZss1)Mous#e?Kzxb6)1YCm$NtewRxZ`f8GcMR;nY> zD?i2i0V2g8{`qU(jBr>4lM|FxX}_dU$mu5%NANURagzkX%e3VA8DiM_$kx0*&;W|U z!T{0+vg<{3scCBhtaLG8Z3UnHH;e#A^9)lnjR?A8o}90}$RG*DX#qC6E!TypRNX)t zacN(fK&j%p`A^!2k3fgT1p>BFzxbpLd}Z9${-FsS`@LB`V$PkwyHmYtUamz)`TqMF z_eunvii?X1%YE5#Ll-W$#^}0t7t|ZeWz5{TMNh>-g=`yXOYw)PtuJcmS7Ydn-~F-Wm6LWtCUs?Q%~rGk`*Hg>en=wXugv+cVng z^euA--~OM{&IT%q>x$#6P_$@Jkw9^^EkVIn0hMUt$5A$@ zSQb_V)M7M|T@g@RqHIDWJ(LN4kkms`5sgMfWQ3Z8NYh}g<^%;5(xqBsV=G9I=sB9U zOWjCLB*sJU1K+-xJLA0F3F zV>4Uge9GHqjQMQE(a7IrwXben_?OE!Z{P_`u&(8hp7s>Be*E%;<+1tj3?+ku0x^==wQ=?b?E!-z4 zs{3qJip_EU+v>zPmc^@cotySeB@jAcx<-PfMj7VwX*Tl(U{LtF3N*dFtF-02a0L zc(eQ#ZA z`!Hfysb$TylbhEse=xHC>2iIJU**3R1U0hRClW+Kbl$$vlGkbk0LiZlk$^;DEqs#MV9Sl@ zPc*SaWTac87OyZZNJ^$S@=bh5Ka=1bhHh<~Im-&k&BQ+}Y-{h_rYGSy@*<%|+8n|p zh!SboMqtd;2wN^SlOFZXcehtr09OL9@e~X1qEMwxUa>gIn7qUgy8zoNf%HH5)6b%? z2$o4{*!s+}UnR_sG^gdKXJaeZzgiafZva_<5K4jYE($xfnQPP6u@BSh<7rzC=NLYt9 zQ8>znNmrMwcb$BKz;;5M#?>5#IX+xF=^pdg-rooJrfh?_r6m} z|8RN$TUVe2uR79k;$5)!8-SD5MYM^6&YugG&xI@Dd+%&2TL%}^?H^ExNvpGU#yLY6tvCJGyVs>#GtibP(8uRr@$Um;Y|h=PPEvYvr9QRoTv zh(EATI_}_cYp!i5gU(`rDL?5oE(%RBl4!Vum{0d|#n8E*H+@?Iog@JqA{~!Jp&ag} z94MSiIt8fowy8&dd=K<*0Zb;D^0bM$=(1+!}F`UU<1v08YSrhP#8hX&sTQ{B}Sh)Yl2do|NOIyR=#5dBB zPz`=%@=Qw#*c}4`WPt~5qA)C#3&*3On!v%DRBspGAARrqN)dW)G zZr2lmNVsy~2Ip#`Fl4nm0?Ktu>ZhUe!i18I(1-THLzjH?O@E%NL;H|x`H;Fsbjy~p znJ~H^o37KrUsn@_u(cj(42)!I?!tSKQw>q*+Q5~dqnR2QZ~Dx@m#kc{Y(!I|;~gIv zeOjJ7bULc3vGK-*jNJ^COE_IrHnOSF@qT}d-o1rOXQP`M8gFRF(3!7r(UR@$@a+5I}G2IYZ3=)erVyOKlmJm*$3UgNeUBCGJUqgwdSa; zqOi?FvZC@Fmcn)3n3S!;vxAUUeZwla<{DEm9r8>?y8;{5#f8+EuG3RpMOihhg=>#7 ztzRDY@EB1gGO9#~xF1WOU>t_=Rc7L)35M@n+ND(Y@wDO>@IN!1;3GAMCit)d2^PeqYnb5h_dFDQ zWFZ@@iEFDc&Cc2%toexJ#Kk|D<_E_-)8v%`!AFa$WiaLYb)G7dClYz>46KH0EHJg` zKY6C6R&fEV;go--=3oDP)CSU;u?o&NXDauPe;*Y|_BocqS=vl$$_dY;9+k6=6>$zR zRdfj6Cp8t78O0clQ*tR<6h@xXMC14jSt*O*I1QG;d--tsWGzXG#eAGx$@pF$bB(95 z88yRkwj%@E`Ct~AQA6V#J%)bav|Bc_-&ksRoDj$G<}>c#B~ckQJkB0t_}z`};FU2M zH5Ht~#Z+Ew;;PVoOX4tUR^tR0#x82%vS}1X4U7{_ z7&zql4 F^nWAGj|u<) literal 0 HcmV?d00001 diff --git a/packages/dapi-grpc/README.md b/packages/dapi-grpc/README.md index c6df2d5e98c..5f78069832e 100644 --- a/packages/dapi-grpc/README.md +++ b/packages/dapi-grpc/README.md @@ -140,3 +140,35 @@ Feel free to dive in! [Open an issue](https://github.com/dashpay/platform/issues ## License [MIT](LICENSE) © Dash Core Group, Inc. + +## Building generated clients + +From the Platform monorepo, run `yarn install`, then provision the native +client generators once: + +```sh +python3 packages/dapi-grpc/scripts/setup-codegen.py --install +yarn workspace @dashevo/dapi-grpc build +``` + +Installation needs Python 3.12 or newer, CMake and a C++ compiler. It builds +checksum-verified sources into your user cache without sudo or Docker. Linux +runner images provide the same tools at `/opt/client-codegen`. Set +`DAPI_GRPC_TOOLCHAIN` to use an explicitly provisioned installation; a missing or +mismatched installation fails rather than silently selecting a different protoc. + +`codegen.json` pins the recipe and native generator versions. The client compiler +is deliberately separate from the Rust build's protoc 32.0: the existing client +output uses protobuf 3.18.1, gRPC 1.46.3, gRPC Java 1.42.1 and the Yarn-locked +`ts-protoc-gen` 0.15.0. Update the recipe, Platform lock and runner requirements +together, with generated-output compatibility checks. Ordinary builds never +install system packages or start containers. + +Generation stages all languages before replacing `clients/`, so a failed plugin +preserves the previous output. Java, Objective-C and Python are generated for +repository consumers; the NPM archive continues to exclude them and ships the +Node and web clients. Run the generation regressions after installing the tools: + +```sh +yarn workspace @dashevo/dapi-grpc exec python3 -m unittest discover -s tests/codegen -v +``` diff --git a/packages/dapi-grpc/codegen.json b/packages/dapi-grpc/codegen.json new file mode 100644 index 00000000000..78ce0fc3e06 --- /dev/null +++ b/packages/dapi-grpc/codegen.json @@ -0,0 +1,31 @@ +{ + "recipe_repository": "dashpay/dash-selfhosted-image", + "recipe_revision": "e49e8bc9977f5f961a76ba1d1f7673c72173679f", + "recipe_files": { + "build.py": "17e260ff1e79e416d9addd7da32a7a04e10d7d4db07e3d919e34dac7e471dd49", + "CMakeLists.txt": "814bf56f8efd9d8ddd91e64201050ad8397071a03e21b59d6fbd83f365d137e4", + "lock.json": "f67f983d739633cf4632e9b30786a7928f1d526df969c4d4acea8804295232cc" + }, + "toolchain": { + "schema": 1, + "versions": { + "protobuf": "3.18.1", + "grpc": "1.46.3", + "grpc_java": "1.42.1" + }, + "sources": [ + { + "url": "https://github.com/protocolbuffers/protobuf/releases/download/v3.18.1/protobuf-cpp-3.18.1.tar.gz", + "sha256": "6ee35eda3f79e49608d2ace8d866313fdec539d8bb14c6c54e8d2a16fa4e6780" + }, + { + "url": "https://github.com/grpc/grpc/archive/refs/tags/v1.46.3.tar.gz", + "sha256": "d6cbf22cb5007af71b61c6be316a79397469c58c82a942552a62e708bce60964" + }, + { + "url": "https://github.com/grpc/grpc-java/archive/refs/tags/v1.42.1.tar.gz", + "sha256": "33775a1ad05974bbba6ff97801cd9b326485b21fab91b08a4e1bc53e500e6326" + } + ] + } +} diff --git a/packages/dapi-grpc/package.json b/packages/dapi-grpc/package.json index c59aa6a8225..42708c51f5d 100644 --- a/packages/dapi-grpc/package.json +++ b/packages/dapi-grpc/package.json @@ -60,6 +60,7 @@ "mocha": "^11.1.0", "mocha-sinon": "^2.1.2", "sinon": "^18.0.1", - "sinon-chai": "^3.7.0" + "sinon-chai": "^3.7.0", + "ts-protoc-gen": "0.15.0" } } diff --git a/packages/dapi-grpc/scripts/build.sh b/packages/dapi-grpc/scripts/build.sh index 148404e9837..5d3f2e6cdb9 100755 --- a/packages/dapi-grpc/scripts/build.sh +++ b/packages/dapi-grpc/scripts/build.sh @@ -1,252 +1,66 @@ #!/usr/bin/env bash -# shellcheck disable=SC2250 -# +# Generate all published clients with the locked native toolchain. +set -euo pipefail -SKIP_GRPC_PROTO_BUILD=${SKIP_GRPC_PROTO_BUILD:-0} -if [[ "${SKIP_GRPC_PROTO_BUILD}" == "1" ]]; then - echo WARN: Skipping GRPC protobuf definitions rebuild +if [[ "${SKIP_GRPC_PROTO_BUILD:-0}" == 1 ]]; then + echo 'WARN: Skipping GRPC protobuf definitions rebuild' exit 0 fi -PROTOS_PATH="$PWD/protos" - -CORE_PROTO_PATH="$PWD/protos/core/v0" -CORE_CLIENTS_PATH="$PWD/clients/core/v0" - -PLATFORM_PROTO_PATH="$PWD/protos/platform/v0" -PLATFORM_CLIENTS_PATH="$PWD/clients/platform/v0" - -DRIVE_PROTO_PATH="$PWD/protos/drive/v0" -DRIVE_CLIENTS_PATH="$PWD/clients/drive/v0" - -CORE_WEB_OUT_PATH="$CORE_CLIENTS_PATH/web" -PLATFORM_WEB_OUT_PATH="$PLATFORM_CLIENTS_PATH/web" -DRIVE_WEB_OUT_PATH="$DRIVE_CLIENTS_PATH/web" - -CORE_JAVA_OUT_PATH="$CORE_CLIENTS_PATH/java" -PLATFORM_JAVA_OUT_PATH="$PLATFORM_CLIENTS_PATH/java" - -CORE_OBJ_C_OUT_PATH="$CORE_CLIENTS_PATH/objective-c" -PLATFORM_OBJ_C_OUT_PATH="$PLATFORM_CLIENTS_PATH/objective-c" - -CORE_PYTHON_OUT_PATH="$CORE_CLIENTS_PATH/python" -PLATFORM_PYTHON_OUT_PATH="$PLATFORM_CLIENTS_PATH/python" - -PROTOC_IMAGE="rvolosatovs/protoc:4.0.0" - -set -ex - -################################################# -# Generate JavaScript client for `Core` service # -################################################# - -rm -rf "${CORE_WEB_OUT_PATH:?}/*" || true - -docker run -v "$CORE_PROTO_PATH:$CORE_PROTO_PATH" \ - -v "$CORE_WEB_OUT_PATH:$CORE_WEB_OUT_PATH" \ - --rm \ - "$PROTOC_IMAGE" \ - --js_out="import_style=commonjs:$CORE_WEB_OUT_PATH" \ - --ts_out="service=grpc-web:$CORE_WEB_OUT_PATH" \ - -I="$CORE_PROTO_PATH" \ - "core.proto" - -# Clean node message classes - -rm -rf "$CORE_CLIENTS_PATH/nodejs/*_protoc.js" || true -rm -rf "$CORE_CLIENTS_PATH/nodejs/*_pbjs.js" || true - -# Copy compiled modules with message classes - -cp "$CORE_WEB_OUT_PATH/core_pb.js" "$CORE_CLIENTS_PATH/nodejs/core_protoc.js" - -# Generate node message classes -pbjs \ - -t static-module \ - -w commonjs \ - -r core_root \ - -o "$CORE_CLIENTS_PATH/nodejs/core_pbjs.js" \ - "$CORE_PROTO_PATH/core.proto" - -##################################################### -# Generate JavaScript client for `DriveInternal` service # -##################################################### - -rm -rf "${DRIVE_WEB_OUT_PATH:?}/*" || true - -docker run -v "$DRIVE_PROTO_PATH:$DRIVE_PROTO_PATH" \ - -v "$DRIVE_WEB_OUT_PATH:$DRIVE_WEB_OUT_PATH" \ - -v "$PROTOS_PATH:$PROTOS_PATH" \ - --rm \ - "$PROTOC_IMAGE" \ - --js_out="import_style=commonjs:$DRIVE_WEB_OUT_PATH" \ - --ts_out="service=grpc-web:$DRIVE_WEB_OUT_PATH" \ - -I="$DRIVE_PROTO_PATH" \ - -I="$PROTOS_PATH" \ - "drive.proto" - -# Clean node message classes - -rm -rf "$DRIVE_CLIENTS_PATH/nodejs/*_protoc.js" || true -rm -rf "$DRIVE_CLIENTS_PATH/nodejs/*_pbjs.js" || true - -# Copy compiled modules with message classes - -cp "$DRIVE_WEB_OUT_PATH/drive_pb.js" "$DRIVE_CLIENTS_PATH/nodejs/drive_protoc.js" - -pbjs \ - -t static-module \ - -w commonjs \ - -r platform_root \ - -p "$PROTOS_PATH" \ - -o "$DRIVE_CLIENTS_PATH/nodejs/drive_pbjs.js" \ - "$DRIVE_PROTO_PATH/drive.proto" - -##################################################### -# Generate JavaScript client for `Platform` service # -##################################################### - -rm -rf "${PLATFORM_WEB_OUT_PATH:?}/*" || true - -docker run -v "$PLATFORM_PROTO_PATH:$PLATFORM_PROTO_PATH" \ - -v "$PLATFORM_WEB_OUT_PATH:$PLATFORM_WEB_OUT_PATH" \ - -v "$PROTOS_PATH:$PROTOS_PATH" \ - --rm \ - "$PROTOC_IMAGE" \ - --js_out="import_style=commonjs:$PLATFORM_WEB_OUT_PATH" \ - --ts_out="service=grpc-web:$PLATFORM_WEB_OUT_PATH" \ - -I="$PLATFORM_PROTO_PATH" \ - -I="$PROTOS_PATH" \ - "platform.proto" - -# Clean node message classes - -rm -rf "$PLATFORM_CLIENTS_PATH/nodejs/*_protoc.js" || true -rm -rf "$PLATFORM_CLIENTS_PATH/nodejs/*_pbjs.js" || true - -# Copy compiled modules with message classes - -cp "$PLATFORM_WEB_OUT_PATH/platform_pb.js" "$PLATFORM_CLIENTS_PATH/nodejs/platform_protoc.js" - -pbjs \ - -t static-module \ - -w commonjs \ - -r platform_root \ - -o "$PLATFORM_CLIENTS_PATH/nodejs/platform_pbjs.js" \ - -p "$PROTOS_PATH" \ - "$PLATFORM_PROTO_PATH/platform.proto" - -################################### -# Generate Java client for `Core` # -################################### - -rm -rf "${CORE_JAVA_OUT_PATH:?}/*" || true - -docker run -v "$CORE_PROTO_PATH:$CORE_PROTO_PATH" \ - -v "$CORE_JAVA_OUT_PATH:$CORE_JAVA_OUT_PATH" \ - -v "$PROTOS_PATH:$PROTOS_PATH" \ - --rm \ - "$PROTOC_IMAGE" \ - --plugin=protoc-gen-grpc=/usr/bin/protoc-gen-grpc-java \ - --grpc-java_out="$CORE_JAVA_OUT_PATH" \ - --proto_path="$CORE_PROTO_PATH" \ - -I="$CORE_PROTO_PATH" \ - -I="$PROTOS_PATH" \ - "core.proto" - -####################################### -# Generate Java client for `Platform` # -####################################### - -rm -rf "${PLATFORM_JAVA_OUT_PATH:?}/*" || true - -docker run -v "$PLATFORM_PROTO_PATH:$PLATFORM_PROTO_PATH" \ - -v "$PLATFORM_JAVA_OUT_PATH:$PLATFORM_JAVA_OUT_PATH" \ - -v "$PROTOS_PATH:$PROTOS_PATH" \ - --rm \ - "$PROTOC_IMAGE" \ - --plugin=protoc-gen-grpc=/usr/bin/protoc-gen-grpc-java \ - --grpc-java_out="$PLATFORM_JAVA_OUT_PATH" \ - --proto_path="$PLATFORM_PROTO_PATH" \ - -I="$PLATFORM_PROTO_PATH" \ - -I="$PROTOS_PATH" \ - "platform.proto" - -########################################## -# Generate Objective-C client for `Core` # -########################################## - -rm -rf "${CORE_OBJ_C_OUT_PATH:?}/*" || true - -docker run -v "$CORE_PROTO_PATH:$CORE_PROTO_PATH" \ - -v "$CORE_OBJ_C_OUT_PATH:$CORE_OBJ_C_OUT_PATH" \ - -v "$PROTOS_PATH:$PROTOS_PATH" \ - --rm \ - "$PROTOC_IMAGE" \ - --plugin=protoc-gen-grpc=/usr/bin/grpc_objective_c_plugin \ - --objc_out="$CORE_OBJ_C_OUT_PATH" \ - --grpc_out="$CORE_OBJ_C_OUT_PATH" \ - --proto_path="$CORE_PROTO_PATH" \ - -I="$CORE_PROTO_PATH" \ - -I="$PROTOS_PATH" \ - "core.proto" - -############################################## -# Generate Objective-C client for `Platform` # -############################################## - -rm -rf "${PLATFORM_OBJ_C_OUT_PATH:?}/*" || true - -docker run -v "$PLATFORM_PROTO_PATH:$PLATFORM_PROTO_PATH" \ - -v "$PLATFORM_OBJ_C_OUT_PATH:$PLATFORM_OBJ_C_OUT_PATH" \ - -v "$PROTOS_PATH:$PROTOS_PATH" \ - --rm \ - "$PROTOC_IMAGE" \ - --plugin=protoc-gen-grpc=/usr/bin/grpc_objective_c_plugin \ - --objc_out="$PLATFORM_OBJ_C_OUT_PATH" \ - --grpc_out="$PLATFORM_OBJ_C_OUT_PATH" \ - --proto_path="$PLATFORM_PROTO_PATH" \ - -I="$PLATFORM_PROTO_PATH" \ - -I="$PROTOS_PATH" \ - "platform.proto" - -##################################### -# Generate Python client for `Core` # -##################################### - -rm -rf "${CORE_PYTHON_OUT_PATH:?}/*" || true - -docker run -v "$CORE_PROTO_PATH:$CORE_PROTO_PATH" \ - -v "$CORE_PYTHON_OUT_PATH:$CORE_PYTHON_OUT_PATH" \ - -v "$PROTOS_PATH:$PROTOS_PATH" \ - --rm \ - "$PROTOC_IMAGE" \ - --plugin=protoc-gen-grpc=/usr/bin/grpc_python_plugin \ - --python_out="$CORE_PYTHON_OUT_PATH" \ - --grpc_out="$CORE_PYTHON_OUT_PATH" \ - --proto_path="$CORE_PROTO_PATH" \ - -I="$CORE_PROTO_PATH" \ - -I="$PROTOS_PATH" \ - "core.proto" - -######################################### -# Generate Python client for `Platform` # -######################################### - -rm -rf "${PLATFORM_PYTHON_OUT_PATH:?}/*" || true - -docker run -v "$PLATFORM_PROTO_PATH:$PLATFORM_PROTO_PATH" \ - -v "$PLATFORM_PYTHON_OUT_PATH:$PLATFORM_PYTHON_OUT_PATH" \ - -v "$PROTOS_PATH:$PROTOS_PATH" \ - --rm \ - "$PROTOC_IMAGE" \ - --plugin=protoc-gen-grpc=/usr/bin/grpc_python_plugin \ - --python_out="$PLATFORM_PYTHON_OUT_PATH" \ - --grpc_out="$PLATFORM_PYTHON_OUT_PATH" \ - --proto_path="$PLATFORM_PROTO_PATH" \ - -I="$PLATFORM_PROTO_PATH" \ - -I="$PROTOS_PATH" \ - "platform.proto" - -# Patch generated protobuf files -exec "${PWD}/scripts/patch-protobuf-js.sh" +PACKAGE_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +TOOLCHAIN=$(python3 "$PACKAGE_DIR/scripts/setup-codegen.py") +PROTOC="$TOOLCHAIN/bin/protoc" +TS_PLUGIN=$(command -v protoc-gen-ts) +command -v pbjs >/dev/null + +# Keep the existing clients intact when any generator fails. Stage beneath the +# package so replacement stays on the same filesystem and Yarn PnP still works. +STAGING=$(mktemp -d "$PACKAGE_DIR/.clients-build.XXXXXX") +cleanup() { + # Restore the previous tree if interrupted between the two renames. + if [[ ! -e "$PACKAGE_DIR/clients" && -d "$STAGING/previous" ]]; then + mv "$STAGING/previous" "$PACKAGE_DIR/clients" + fi + rm -rf "$STAGING" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +cp -R "$PACKAGE_DIR/clients" "$STAGING/clients" + +for service in core drive platform; do + output="$STAGING/clients/$service/v0" + schema="$PACKAGE_DIR/protos/$service/v0/$service.proto" + includes=(-I"$PACKAGE_DIR/protos/$service/v0" -I"$PACKAGE_DIR/protos" -I"$TOOLCHAIN/include") + # Preserve handwritten PromiseClient modules and documentation. + rm -f "$output/web/${service}_pb"* "$output/nodejs/${service}_protoc.js" "$output/nodejs/${service}_pbjs.js" + "$PROTOC" "${includes[@]}" --plugin="protoc-gen-ts=$TS_PLUGIN" \ + --js_out="import_style=commonjs:$output/web" \ + --ts_out="service=grpc-web:$output/web" "$schema" + cp "$output/web/${service}_pb.js" "$output/nodejs/${service}_protoc.js" + root=platform_root + if [[ "$service" == core ]]; then root=core_root; fi + pbjs -t static-module -w commonjs -r "$root" -p "$PACKAGE_DIR/protos" \ + -o "$output/nodejs/${service}_pbjs.js" "$schema" + if [[ "$service" != drive ]]; then + for language in java objective-c python; do + rm -rf "${output:?}/$language" + mkdir -p "$output/$language" + done + "$PROTOC" "${includes[@]}" --plugin="protoc-gen-grpc-java=$TOOLCHAIN/bin/protoc-gen-grpc-java" \ + --grpc-java_out="$output/java" "$schema" + "$PROTOC" "${includes[@]}" --plugin="protoc-gen-grpc=$TOOLCHAIN/bin/grpc_objective_c_plugin" \ + --objc_out="$output/objective-c" --grpc_out="$output/objective-c" "$schema" + "$PROTOC" "${includes[@]}" --plugin="protoc-gen-grpc=$TOOLCHAIN/bin/grpc_python_plugin" \ + --python_out="$output/python" --grpc_out="$output/python" "$schema" + fi +done + +(cd "$STAGING" && "$PACKAGE_DIR/scripts/patch-protobuf-js.sh") +# Generation and patching have all succeeded. Preserve handwritten files by +# replacing the staged copy of the complete client tree. +mv "$PACKAGE_DIR/clients" "$STAGING/previous" +if ! mv "$STAGING/clients" "$PACKAGE_DIR/clients"; then + mv "$STAGING/previous" "$PACKAGE_DIR/clients" + exit 1 +fi diff --git a/packages/dapi-grpc/scripts/check-packed-clients.py b/packages/dapi-grpc/scripts/check-packed-clients.py new file mode 100644 index 00000000000..61603317c35 --- /dev/null +++ b/packages/dapi-grpc/scripts/check-packed-clients.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Check that packing preserved the freshly generated DAPI clients.""" +import json +from pathlib import Path +import sys +import tarfile + +package = Path(__file__).resolve().parent.parent +expected = {p.relative_to(package).as_posix(): p.read_bytes() + for p in (package / 'clients').rglob('*') + if p.is_file() and p.relative_to(package).parts[3] in ('web', 'nodejs')} +found = 0 +for archive in Path(sys.argv[1]).glob('*.tgz'): + with tarfile.open(archive) as tar: + manifest = tar.extractfile('package/package.json') + if manifest is None or json.load(manifest)['name'] != '@dashevo/dapi-grpc': + continue + found += 1 + for name, content in expected.items(): + member = tar.extractfile('package/' + name) + if member is None or member.read() != content: + raise SystemExit(f'Packed client differs from generated source: {name}') +if found != 1: + raise SystemExit(f'Expected one DAPI package archive, found {found}') +print(f'Verified {len(expected)} packed client files') diff --git a/packages/dapi-grpc/scripts/patch-protobuf-js.sh b/packages/dapi-grpc/scripts/patch-protobuf-js.sh index d046c513ff6..2c6fd34dc16 100755 --- a/packages/dapi-grpc/scripts/patch-protobuf-js.sh +++ b/packages/dapi-grpc/scripts/patch-protobuf-js.sh @@ -1,8 +1,8 @@ #!/bin/bash # shellcheck disable=SC2250 -set -e +set -euo pipefail -files=$(find "$PWD/clients/core/v0/web" "$PWD/clients/core/v0/nodejs" "$PWD/clients/platform/v0/web" "$PWD/clients/platform/v0/nodejs" "$PWD/clients/drive/v0/web" "$PWD/clients/drive/v0/nodejs" -name "*_pb.js" -o -name "*_protoc.js") OS=$(uname) +OS=$(uname) function replace_in_file() { if [[ "$OS" = 'Darwin' ]]; then @@ -15,18 +15,23 @@ function replace_in_file() { } # Loop over the files -for file in $files; do +while IFS= read -r -d '' file; do replace_in_file 's/var global = Function('\''return this'\'')();/const proto = {};/g' "$file" if grep -qrE "[^a-zA-Z]Function\(" "$file"; then - echo "Error: Function( still present" + echo "Error: Function( still present in $file" >&2 + exit 1 fi replace_in_file 's/, global);/, { proto });/g' "$file" if grep -qrE '(^|[^a-zA-Z."])global([^a-zA-Z]|$)' "$file"; then - echo "Error: global still present" + echo "Error: global still present in $file" >&2 + exit 1 fi replace_in_file 's/require('\''.\/platform\/v0\/platform_pb.js'\'')/require('\''..\/..\/..\/platform\/v0\/web\/platform_pb.js'\'')/g' "$file" -done +done < <(find "$PWD/clients/core/v0/web" "$PWD/clients/core/v0/nodejs" \ + "$PWD/clients/platform/v0/web" "$PWD/clients/platform/v0/nodejs" \ + "$PWD/clients/drive/v0/web" "$PWD/clients/drive/v0/nodejs" \ + \( -name "*_pb.js" -o -name "*_protoc.js" \) -print0) diff --git a/packages/dapi-grpc/scripts/setup-codegen.py b/packages/dapi-grpc/scripts/setup-codegen.py new file mode 100644 index 00000000000..957c82d9319 --- /dev/null +++ b/packages/dapi-grpc/scripts/setup-codegen.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Verify or install the pinned native DAPI generators (no root or Docker).""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import subprocess +import sys +import tempfile +import urllib.request + +PACKAGE = Path(__file__).resolve().parent.parent +CONFIG = PACKAGE / 'codegen.json' +BINARIES = ('protoc', 'protoc-gen-grpc-java', 'grpc_objective_c_plugin', 'grpc_python_plugin') + + +def verify(destination, config): + if json.loads((destination / 'lock.json').read_text()) != config['toolchain']: + raise ValueError(f'Client generator versions differ at {destination}') + for binary in BINARIES: + if not os.access(destination / 'bin' / binary, os.X_OK): + raise ValueError(f'Missing client generator: {binary}') + version = subprocess.check_output([str(destination / 'bin/protoc'), '--version'], text=True).strip() + if version != 'libprotoc ' + config['toolchain']['versions']['protobuf']: + raise ValueError(f'Wrong client compiler: {version}') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--install', action='store_true', help='Build a user-local toolchain when absent') + args = parser.parse_args() + config = json.loads(CONFIG.read_text()) + revision = config['recipe_revision'] + cache = Path(os.environ.get('XDG_CACHE_HOME', str(Path.home() / '.cache'))) + local = cache / 'dash/client-codegen' / f'{revision}-{platform.system()}-{platform.machine()}' + explicit = os.environ.get('DAPI_GRPC_TOOLCHAIN') + if explicit: + destination = Path(explicit).resolve() + elif Path('/opt/client-codegen').exists(): + destination = Path('/opt/client-codegen') + else: + destination = local + if destination.exists(): + verify(destination, config) + elif args.install and not explicit: + with tempfile.TemporaryDirectory(prefix='client-codegen-recipe-') as tmp: + recipe = Path(tmp) + for name, expected in config['recipe_files'].items(): + url = f"https://raw.githubusercontent.com/{config['recipe_repository']}/{revision}/client-codegen/{name}" + with urllib.request.urlopen(url, timeout=120) as response: + content = response.read() + if hashlib.sha256(content).hexdigest() != expected: + raise ValueError(f'Recipe checksum mismatch: {name}') + (recipe / name).write_bytes(content) + if json.loads((recipe / 'lock.json').read_text()) != config['toolchain']: + raise ValueError('Recipe and Platform client toolchain locks differ') + subprocess.run([sys.executable, str(recipe / 'build.py'), str(destination)], + check=True, stdout=sys.stderr) + verify(destination, config) + else: + raise ValueError('Native client generators are missing. Run ' + '`python3 packages/dapi-grpc/scripts/setup-codegen.py --install` ' + '(requires Python 3.12+, CMake and a C++ compiler), or provision the matching runner image.') + print(destination) + + +if __name__ == '__main__': + try: + main() + except (ValueError, OSError, subprocess.CalledProcessError) as error: + print(f'Client codegen: {error}', file=sys.stderr) + sys.exit(1) diff --git a/packages/dapi-grpc/tests/codegen/test_generation.py b/packages/dapi-grpc/tests/codegen/test_generation.py new file mode 100644 index 00000000000..1743421dbd0 --- /dev/null +++ b/packages/dapi-grpc/tests/codegen/test_generation.py @@ -0,0 +1,78 @@ +"""Integration regressions; run with `yarn workspace @dashevo/dapi-grpc exec python3 -m unittest discover -s tests/codegen -v`.""" +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +PACKAGE = Path(__file__).resolve().parents[2] + + +def snapshot(path): + return {str(p.relative_to(path)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in path.rglob('*') if p.is_file()} + + +class GenerationTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.toolchain = Path(subprocess.check_output( + ['python3', str(PACKAGE / 'scripts/setup-codegen.py')], text=True).strip()) + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='client generation with spaces ') + self.addCleanup(self.tmp.cleanup) + self.package = Path(self.tmp.name) / 'dapi-grpc' + shutil.copytree(PACKAGE, self.package, ignore=shutil.ignore_patterns('.clients-build.*')) + + def run_build(self, toolchain=None): + env = dict(os.environ, DAPI_GRPC_TOOLCHAIN=str(toolchain or self.toolchain)) + return subprocess.run(['bash', str(self.package / 'scripts/build.sh')], + env=env, capture_output=True, text=True) + + def test_should_generate_all_languages_and_be_idempotent(self): + manual = self.package / 'clients/core/v0/web/handwritten.txt' + manual.write_text('preserve me') + result = self.run_build() + self.assertEqual(result.returncode, 0, result.stderr) + clients = self.package / 'clients' + for name in ('core/v0/java/org/dash/platform/dapi/v0/CoreGrpc.java', + 'core/v0/objective-c/Core.pbrpc.h', 'core/v0/python/core_pb2_grpc.py', + 'platform/v0/web/platform_pb_service.d.ts', 'drive/v0/nodejs/drive_pbjs.js'): + self.assertTrue((clients / name).is_file(), name) + first = snapshot(clients) + result = self.run_build() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(snapshot(clients), first) + self.assertEqual(manual.read_text(), 'preserve me') + + def test_should_preserve_clients_when_a_late_generator_fails(self): + broken = Path(self.tmp.name) / 'broken-toolchain' + (broken / 'bin').mkdir(parents=True) + shutil.copy2(self.toolchain / 'lock.json', broken / 'lock.json') + for binary in (self.toolchain / 'bin').iterdir(): + if binary.name != 'grpc_python_plugin': + (broken / 'bin' / binary.name).symlink_to(binary) + (broken / 'include').symlink_to(self.toolchain / 'include', target_is_directory=True) + plugin = broken / 'bin/grpc_python_plugin' + plugin.write_text('#!/bin/sh\nexit 23\n') + plugin.chmod(0o755) + before = snapshot(self.package / 'clients') + result = self.run_build(broken) + self.assertNotEqual(result.returncode, 0) + self.assertIn('Plugin failed', result.stderr) + self.assertEqual(snapshot(self.package / 'clients'), before) + self.assertEqual(list(self.package.glob('.clients-build.*')), []) + + def test_should_reject_mismatched_toolchain_before_touching_clients(self): + broken = Path(self.tmp.name) / 'wrong-version' + broken.mkdir() + (broken / 'lock.json').write_text(json.dumps({'versions': {'protobuf': '32.0'}})) + before = snapshot(self.package / 'clients') + result = self.run_build(broken) + self.assertNotEqual(result.returncode, 0) + self.assertIn('versions differ', result.stderr) + self.assertEqual(snapshot(self.package / 'clients'), before) diff --git a/yarn.lock b/yarn.lock index 59a947649b2..884fa0a45c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1593,6 +1593,7 @@ __metadata: mocha-sinon: "npm:^2.1.2" sinon: "npm:^18.0.1" sinon-chai: "npm:^3.7.0" + ts-protoc-gen: "npm:0.15.0" languageName: unknown linkType: soft @@ -10004,6 +10005,13 @@ __metadata: languageName: node linkType: hard +"google-protobuf@npm:^3.15.5": + version: 3.21.4 + resolution: "google-protobuf@npm:3.21.4" + checksum: 10/0d87fe8ef221d105cbaa808f4024bd577638524d8e461469e3733f2e4933391ad4da86b7fcbd11e8781bee04eacf2e8ba19aaacd5f9deb336a220485841d980f + languageName: node + linkType: hard + "gopd@npm:^1.0.1": version: 1.0.1 resolution: "gopd@npm:1.0.1" @@ -17476,6 +17484,17 @@ __metadata: languageName: node linkType: hard +"ts-protoc-gen@npm:0.15.0": + version: 0.15.0 + resolution: "ts-protoc-gen@npm:0.15.0" + dependencies: + google-protobuf: "npm:^3.15.5" + bin: + protoc-gen-ts: bin/protoc-gen-ts + checksum: 10/de1d526b47886e1994995836b1e1e5765193f476ee0aded067a2e4154d60df0bd8fb33a1f56654a1ea779576c158c184b7beca36e59f620c9382217040f91d64 + languageName: node + linkType: hard + "tsconfck@npm:^3.0.0": version: 3.0.0 resolution: "tsconfck@npm:3.0.0" From 3cb2a8de734b35a834a1b76cacbfaea2e7013443 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 27 Sep 2026 11:17:58 -0500 Subject: [PATCH 042/113] fix(release): require all generated clients in packed archives --- packages/dapi-grpc/.npmignore | 4 ++++ packages/dapi-grpc/scripts/check-packed-clients.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/packages/dapi-grpc/.npmignore b/packages/dapi-grpc/.npmignore index 2b3ec03d4f9..11b735e7df2 100644 --- a/packages/dapi-grpc/.npmignore +++ b/packages/dapi-grpc/.npmignore @@ -6,3 +6,7 @@ node_modules # Ultra runner build cache .ultra.cache.json + +# Native generator regression tests and local Python bytecode are not runtime files. +tests/codegen +**/__pycache__ diff --git a/packages/dapi-grpc/scripts/check-packed-clients.py b/packages/dapi-grpc/scripts/check-packed-clients.py index 61603317c35..e46913b4a24 100644 --- a/packages/dapi-grpc/scripts/check-packed-clients.py +++ b/packages/dapi-grpc/scripts/check-packed-clients.py @@ -9,6 +9,13 @@ expected = {p.relative_to(package).as_posix(): p.read_bytes() for p in (package / 'clients').rglob('*') if p.is_file() and p.relative_to(package).parts[3] in ('web', 'nodejs')} +required = {f'clients/{service}/v0/{directory}/{service}{suffix}' + for service in ('core', 'drive', 'platform') + for directory, suffix in (('web', '_pb.js'), ('web', '_pb.d.ts'), + ('web', '_pb_service.js'), ('web', '_pb_service.d.ts'), + ('nodejs', '_protoc.js'), ('nodejs', '_pbjs.js'))} +if not required <= expected.keys(): + raise SystemExit('Missing generated source clients: ' + ', '.join(sorted(required - expected.keys()))) found = 0 for archive in Path(sys.argv[1]).glob('*.tgz'): with tarfile.open(archive) as tar: From a7a4c57b70f767a913d3a4e6da4dba6b7f24217b Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 27 Sep 2026 11:50:44 -0500 Subject: [PATCH 043/113] fix(ci): isolate release runners from PR build state --- .github/NPM_RUNNER.md | 35 ++++++++- .github/actionlint.yaml | 1 + .github/scripts/runner-image.py | 2 +- .../tests/test_npm_release_boundary.py | 41 ++++++++++ .github/scripts/tests/test_runner_image.py | 2 +- .github/workflows/npm-runner-validation.yml | 12 +++ .github/workflows/release-npm-build.yml | 78 +++++++++++++++++++ .github/workflows/release.yml | 58 +------------- 8 files changed, 170 insertions(+), 59 deletions(-) create mode 100644 .github/scripts/tests/test_npm_release_boundary.py create mode 100644 .github/workflows/release-npm-build.yml diff --git a/.github/NPM_RUNNER.md b/.github/NPM_RUNNER.md index 3d11549f761..351b55fd00e 100644 --- a/.github/NPM_RUNNER.md +++ b/.github/NPM_RUNNER.md @@ -1,6 +1,7 @@ # NPM release runners -NPM release compilation uses `[self-hosted, Linux, X64, npm-build]`. Publishing +NPM release compilation uses `[self-hosted, Linux, X64, npm-build]` in the +restricted `platform-npm-releases` runner group. Publishing continues on GitHub-hosted Ubuntu with OIDC; the builder receives no publishing credentials. The `npm-release-build` action is shared by releases and image validation so both compile and pack with the same setup. @@ -21,7 +22,7 @@ runner needs no Docker CLI/socket or KVM device. ## Provisioning and promotion Use the reviewed `dashpay/dash-selfhosted-image` recipe and a tested immutable -image digest, not a moving tag. Register new capacity with `npm-build` only after +image digest, not a moving tag. Register dedicated release capacity with `npm-build` only after the NPM validation workflow succeeds on that image. Drain old registrations before replacement; retain their image/configuration for rollback. Old release tags still contain their original workflows and do not automatically gain this fix. @@ -52,3 +53,33 @@ freshly generated files. It uploads tarballs but never publishes them. checks committed generated output, tests failure recovery and validates packing. Local setup and generator test commands are in `packages/dapi-grpc/README.md`. + +## Separate PR and release state + +The `platform-npm-releases` organization runner group must select only the +`dashpay/platform` repository and restrict execution to: + +```text +dashpay/platform/.github/workflows/release-npm-build.yml@refs/heads/v4.2-dev +``` + +Protect that branch with the normal maintainer review policy. `release.yml` +invokes that protected reusable workflow; the reusable workflow rejects PR +callers and arbitrary branch dispatches before checkout. A PR cannot select the +release group by changing its own workflow to request the `npm-build` label. +The group-level selected-workflow restriction is a required operator setting, +not something a repository workflow can grant itself. + +Use separate runner containers/VMs and separate registration, workspace, HOME, +Cargo registry and target-cache storage for PR and release pools. Do not mount +the same cache volumes into both pools. Release caches can persist between +releases; no PR may write them. Ordinary PR validation uses `npm-pr`; image +candidates continue to use fresh one-job registrations and volumes. Do not add +`npm-pr`, `rust-ci` or `kotlin-ci` to the release registration. + +For another maintained branch, create its reviewed protected reusable-workflow +ref and corresponding group policy explicitly. Do not wildcard the workflow +restriction or allow PR refs. Validate the policy by attempting a PR job that +requests the release group: it must be rejected, while a permitted release dry +run succeeds. The workflow guard is defense in depth; enabling the release +pool without its group restriction does not establish this boundary. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index bcf2dd1828b..6527a051ce5 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -10,3 +10,4 @@ self-hosted-runner: - rust-ci - kotlin-ci - npm-build + - npm-pr diff --git a/.github/scripts/runner-image.py b/.github/scripts/runner-image.py index e61ae30f263..60d9638b505 100644 --- a/.github/scripts/runner-image.py +++ b/.github/scripts/runner-image.py @@ -77,7 +77,7 @@ def export_environment(manifest, output): def select(manifest, kind, output, wait_seconds): - fallback = ["self-hosted", {"rust": "rust-ci", "kotlin": "kotlin-ci", "npm": "npm-build"}[kind]] + fallback = ["self-hosted", {"rust": "rust-ci", "kotlin": "kotlin-ci", "npm": "npm-pr"}[kind]] event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) requested = event.get("pull_request") labels, changed = fallback, False diff --git a/.github/scripts/tests/test_npm_release_boundary.py b/.github/scripts/tests/test_npm_release_boundary.py new file mode 100644 index 00000000000..9a98a9197d2 --- /dev/null +++ b/.github/scripts/tests/test_npm_release_boundary.py @@ -0,0 +1,41 @@ +"""Execute the protected reusable workflow's guard against caller contexts.""" +import os +from pathlib import Path +import subprocess +import unittest + +ROOT = Path(__file__).resolve().parents[3] + + +def release_guard(): + workflow = (ROOT / '.github/workflows/release-npm-build.yml').read_text() + # The guard must run before checkout or any caller-controlled source. + start = workflow.index(' - name: Reject untrusted release callers') + end = workflow.index(' - uses: softwareforgood/', start) + block = workflow[start:end] + script = block.split(' run: |\n', 1)[1] + return '\n'.join(line[10:] for line in script.splitlines() if line.strip()) + + +class ReleaseBoundaryTests(unittest.TestCase): + def run_guard(self, event, ref, repository='dashpay/platform'): + return subprocess.run(['bash', '-c', release_guard()], capture_output=True, text=True, + env=dict(os.environ, GITHUB_REPOSITORY=repository, + GITHUB_EVENT_NAME=event, GITHUB_REF=ref)).returncode + + def test_should_allow_release_tags_and_protected_branch_dry_runs(self): + for event, ref in [('release', 'refs/tags/v4.2.0-beta.5'), + ('workflow_dispatch', 'refs/tags/v4.2.0-beta.5'), + ('workflow_dispatch', 'refs/heads/v4.2-dev')]: + with self.subTest(event=event, ref=ref): + self.assertEqual(self.run_guard(event, ref), 0) + + def test_should_reject_prs_forks_and_unprotected_dispatches(self): + for event, ref in [('pull_request', 'refs/pull/5068/merge'), + ('pull_request_target', 'refs/heads/v4.2-dev'), + ('workflow_dispatch', 'refs/heads/attacker'), + ('push', 'refs/heads/v4.2-dev'), + ('release', 'refs/heads/v4.2-dev')]: + with self.subTest(event=event, ref=ref): + self.assertNotEqual(self.run_guard(event, ref), 0) + self.assertNotEqual(self.run_guard('release', 'refs/tags/v4.2.0', 'unknown/platform'), 0) diff --git a/.github/scripts/tests/test_runner_image.py b/.github/scripts/tests/test_runner_image.py index 68efbbe8c12..7e74e4a56c0 100644 --- a/.github/scripts/tests/test_runner_image.py +++ b/.github/scripts/tests/test_runner_image.py @@ -61,7 +61,7 @@ def test_exact_candidate_includes_head_digest_and_kind(self): def test_npm_candidates_and_ordinary_pool_have_distinct_labels(self): labels = json.loads(self.select(kind="npm")["labels"]) self.assertEqual(labels[-1], f"platform-image-pr-4702-{HEAD}-{DIGEST[7:]}-npm") - self.assertEqual(json.loads(self.select({}, kind="npm")["labels"]), ["self-hosted", "npm-build"]) + self.assertEqual(json.loads(self.select({}, kind="npm")["labels"]), ["self-hosted", "npm-pr"]) def test_new_head_or_closed_pr_rejects_stale_run(self): event = {"pull_request": copy.deepcopy(self.pr)} diff --git a/.github/workflows/npm-runner-validation.yml b/.github/workflows/npm-runner-validation.yml index 37c8d4998a9..f8eb9336c12 100644 --- a/.github/workflows/npm-runner-validation.yml +++ b/.github/workflows/npm-runner-validation.yml @@ -3,16 +3,28 @@ on: pull_request: paths: - '.github/runner-requirements.json' + - '.github/scripts/runner-image.py' + - '.github/scripts/tests/**' - '.github/actions/npm-release-build/**' - '.github/actions/rust/**' - '.github/actions/nodejs/**' - '.github/workflows/npm-runner-validation.yml' - '.github/workflows/release.yml' + - '.github/workflows/release-npm-build.yml' - 'packages/dapi-grpc/**' workflow_dispatch: permissions: contents: read jobs: + runner-contract-tests: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Test runner selection and release caller restrictions + run: python3 -m unittest discover -s .github/scripts/tests -v select-runner: if: >- github.event_name != 'pull_request' diff --git a/.github/workflows/release-npm-build.yml b/.github/workflows/release-npm-build.yml new file mode 100644 index 00000000000..a177e314f04 --- /dev/null +++ b/.github/workflows/release-npm-build.yml @@ -0,0 +1,78 @@ +name: Build release NPM artifacts + +# The runner group permits only this reusable workflow at the protected +# v4.2-dev ref. Callers cannot replace these steps with PR workflow code. +on: + workflow_call: + +permissions: + contents: read + +jobs: + build: + name: Build NPM packages + if: >- + github.repository == 'dashpay/platform' + && (github.event_name == 'release' + || (github.event_name == 'workflow_dispatch' + && (github.ref == 'refs/heads/v4.2-dev' || startsWith(github.ref, 'refs/tags/')))) + runs-on: + group: platform-npm-releases + labels: [self-hosted, Linux, X64, npm-build] + timeout-minutes: 120 + steps: + - name: Reject untrusted release callers + shell: bash + run: | + set -euo pipefail + test "$GITHUB_REPOSITORY" = dashpay/platform + case "$GITHUB_EVENT_NAME:$GITHUB_REF" in + release:refs/tags/*|workflow_dispatch:refs/tags/*|workflow_dispatch:refs/heads/v4.2-dev) ;; + *) echo '::error::NPM release runners accept only release tags or protected-branch dispatches'; exit 1 ;; + esac + + - uses: softwareforgood/check-artifact-v4-existence@v0 + id: check-artifact + with: + name: js-build-${{ github.sha }} + + # Start each release from a fresh clone so Git configuration and hooks + # from earlier releases cannot affect checkout. Release-only build + # caches live outside the workspace. + - name: Empty the workspace left by earlier jobs + if: ${{ steps.check-artifact.outputs.exists != 'true' }} + run: | + find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + + - name: Check out repo + uses: actions/checkout@v4 + with: + persist-credentials: false + if: ${{ steps.check-artifact.outputs.exists != 'true' }} + + - name: Build and pack NPM packages + uses: ./.github/actions/npm-release-build + if: ${{ steps.check-artifact.outputs.exists != 'true' }} + + - name: Get modified files + id: diff + run: | + { + echo "files<> "$GITHUB_OUTPUT" + if: ${{ steps.check-artifact.outputs.exists != 'true' }} + + - name: Upload the archive of built files + uses: actions/upload-artifact@v4 + with: + name: js-build-${{ github.sha }} + path: | + ${{ steps.diff.outputs.files }} + npm-packages/*.tgz + # Keep the handoff alive long enough to re-run only a failed publish. + retention-days: 7 + if-no-files-found: error + include-hidden-files: true + if: ${{ steps.check-artifact.outputs.exists != 'true' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b77a59ce61..5d28b68e641 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,64 +25,12 @@ permissions: jobs: build-npm: - name: Build NPM packages - # Reuse the persistent Linux runner's Cargo registry and a release-only - # target cache. The publish job stays on a GitHub-hosted runner because npm - # trusted publishing does not support self-hosted runners. - runs-on: [self-hosted, Linux, X64, npm-build] - timeout-minutes: 120 if: github.event_name == 'release' || startsWith(inputs.tag, 'npm-test:') - # In particular, do not mint an OIDC token for the persistent build host. permissions: contents: read - steps: - - uses: softwareforgood/check-artifact-v4-existence@v0 - id: check-artifact - with: - name: js-build-${{ github.sha }} - - # PR jobs run in this same workspace on this persistent runner. Git - # state they leave behind (.git/hooks, .git/config, .git/info/attributes) - # would run inside actions/checkout's own `git checkout`, before any - # later cleanup could remove it. Start from an empty directory and a - # fresh clone; the release build cache lives outside the workspace. - - name: Empty the workspace left by earlier jobs - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - run: | - find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + - - - name: Check out repo - uses: actions/checkout@v4 - with: - persist-credentials: false - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - - name: Build and pack NPM packages - uses: ./.github/actions/npm-release-build - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - - name: Get modified files - id: diff - run: | - { - echo "files<> "$GITHUB_OUTPUT" - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - - - name: Upload the archive of built files - uses: actions/upload-artifact@v4 - with: - name: js-build-${{ github.sha }} - path: | - ${{ steps.diff.outputs.files }} - npm-packages/*.tgz - # Keep the handoff alive long enough to re-run only a failed publish. - retention-days: 7 - if-no-files-found: error - include-hidden-files: true - if: ${{ steps.check-artifact.outputs.exists != 'true' }} + # Runner-group policy allows this protected reusable workflow only, never + # a copy supplied by a pull request. The checkout remains the caller SHA. + uses: dashpay/platform/.github/workflows/release-npm-build.yml@v4.2-dev release-npm: name: Publish NPM packages From ab2aaa4b3f281761cbf45da0302caeed2275fd00 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:14:34 +0300 Subject: [PATCH 044/113] fix(swift-sdk): take migration copies out of WAL mode A SQLite backup copies page 1, so a copy of a WAL store keeps the WAL header but has no -wal/-shm beside it. The legacy bridge then opens its snapshot read-only for the integrity check; on iOS that connection cannot create the -shm and fails with SQLITE_CANTOPEN ("unable to open database file"). Every store written by App Store 9.0.0 is a WAL store that needs the bridge, so updating straight to 9.1.1 showed "Couldn't open your wallet data". Switch each new copy to DELETE journal mode on the connection that wrote it. Promotion into the live store is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../Persistence/DashLegacyStoreSQLite.swift | 10 ++++ .../DashLegacySchemaMigrationTests.swift | 48 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift index 4e5e1fe6784..95d4468e1a4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift @@ -114,6 +114,16 @@ enum DashLegacyStoreSQLite { guard status == SQLITE_OK else { throw sqliteFailure(handle: output.handle, operation: "committing the database copy", status: status) } + // The backup copies page 1, so a copy of a WAL store is marked WAL + // but has no -wal/-shm. A read-only connection cannot create them + // and fails with SQLITE_CANTOPEN, so leave WAL on the new copy. + if lockedDestinationCheck == nil { + var modes: [String] = [] + try output.query("PRAGMA journal_mode=DELETE") { modes.append(string($0, 0).lowercased()) } + guard modes == ["delete"] else { + throw Failure.database("The database copy did not leave WAL mode") + } + } } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index 4bdc2221cfa..982d9ab0afd 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -515,6 +515,54 @@ final class DashLegacySchemaMigrationTests: XCTestCase { } } + /// Bytes 18/19 of the SQLite header: 1 = rollback journal, 2 = WAL. + private func journalFormat(_ url: URL) throws -> [UInt8] { + let file = try FileHandle(forReadingFrom: url) + defer { try? file.close() } + return Array(try XCTUnwrap(file.read(upToCount: 20)).suffix(2)) + } + + // App-written stores are WAL. A copy that kept the WAL header but has no + // -wal/-shm cannot be read by a read-only connection on iOS, which is how + // every App Store 9.0.0 store failed its snapshot integrity check. + func testCopyOfWALStoreLeavesWALModeAndReadsReadOnly() throws { + try withStore { url in + let writer = try DashLegacyStoreSQLite.Connection(url, writable: true) + try writer.execute("PRAGMA journal_mode=WAL") + try writer.execute("PRAGMA wal_autocheckpoint=0") + try writer.execute("UPDATE ZPERSISTENTWALLET SET ZNAME='copied from WAL'") + XCTAssertEqual(try journalFormat(url), [2, 2]) + let copy = url.deletingLastPathComponent().appendingPathComponent("copy.store") + try DashLegacyStoreSQLite.copy(from: url, to: copy) + XCTAssertEqual(try journalFormat(copy), [1, 1]) + XCTAssertNoThrow(try DashLegacyStoreSQLite.integrityCheck(copy)) + var names: [String] = [] + try DashLegacyStoreSQLite.Connection(copy, writable: false).query("SELECT ZNAME FROM ZPERSISTENTWALLET") { + names.append(String(cString: sqlite3_column_text($0, 0))) + } + XCTAssertEqual(names, ["copied from WAL"]) + } + } + + func testWALStoreSnapshotIsReadableBeforeMigration() throws { + try withStore { url in + let writer = try DashLegacyStoreSQLite.Connection(url, writable: true) + try writer.execute("PRAGMA journal_mode=WAL") + try writer.execute("UPDATE ZPERSISTENTWALLET SET ZNAME='WAL snapshot'") + var checkedSnapshot = false + let migrated = try open(url, hooks: .init(visit: { phase, _ in + guard phase == .afterSnapshot else { return } + let backup = try XCTUnwrap(try self.operationDirectories(url).first) + .appendingPathComponent("original.store") + XCTAssertEqual(try self.journalFormat(backup), [1, 1]) + try DashLegacyStoreSQLite.integrityCheck(backup) + checkedSnapshot = true + })) + XCTAssertTrue(checkedSnapshot) + try verifyRows(migrated.mainContext, walletName: "WAL snapshot") + } + } + func testCheckpointRejectsBusyWALAndConfirmsDeleteMode() throws { try withStore { url in try autoreleasepool { From e090ae2bb830388865cd70e880735dac89ee5f17 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 00:31:32 +0700 Subject: [PATCH 045/113] fix(dpp)!: refuse token cost and unruled keyword changes on update with a consensus error (PV14) (#5069) Co-authored-by: Claude Opus 5.5 --- .../methods/validate_update/v1/mod.rs | 344 +++++++++++++++++- .../validate_schema_compatibility/v1/mod.rs | 264 +++++++++++++- .../data_contract_update/mod.rs | 124 +++++++ 3 files changed, 717 insertions(+), 15 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs index 92a2115c0ec..c709f3d7552 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs @@ -25,7 +25,7 @@ use crate::consensus::basic::data_contract::{ }; use crate::consensus::state::data_contract::document_type_update_error::DocumentTypeUpdateError; use crate::data_contract::document_type::accessors::{ - DocumentTypeV0Getters, DocumentTypeV2Getters, + DocumentTypeV0Getters, DocumentTypeV1Getters, DocumentTypeV2Getters, }; use crate::data_contract::document_type::{DocumentPropertyType, DocumentTypeRef}; use crate::validation::SimpleConsensusValidationResult; @@ -127,6 +127,13 @@ impl DocumentTypeRef<'_> { return Ok(result); } + // Validate that the token costs are unchanged + let result = self.validate_token_costs_unchanged(new_document_type); + + if !result.is_valid() { + return Ok(result); + } + // Validate schema compatibility self.validate_schema_with_options(new_document_type, platform_version, &options) } @@ -266,6 +273,74 @@ impl DocumentTypeRef<'_> { ) } + /// The token costs of a document type are fixed when it is published, as + /// its action fees are: whoever holds a document of the type got it + /// knowing what replacing, deleting, transferring or selling it costs in + /// tokens, which token, and who pays the gas. An update may not add, + /// change or remove the cost of any action. A document type added by the + /// update is not judged here and may declare its own. No earlier protocol + /// version let an update change them either: the schema compatibility + /// differ failed on any `tokenCost` diff. + fn validate_token_costs_unchanged( + &self, + new_document_type: DocumentTypeRef, + ) -> SimpleConsensusValidationResult { + let costs = [ + ( + "create", + self.document_creation_token_cost(), + new_document_type.document_creation_token_cost(), + ), + ( + "replace", + self.document_replacement_token_cost(), + new_document_type.document_replacement_token_cost(), + ), + ( + "delete", + self.document_deletion_token_cost(), + new_document_type.document_deletion_token_cost(), + ), + ( + "transfer", + self.document_transfer_token_cost(), + new_document_type.document_transfer_token_cost(), + ), + ( + "update_price", + self.document_update_price_token_cost(), + new_document_type.document_update_price_token_cost(), + ), + ( + "purchase", + self.document_purchase_token_cost(), + new_document_type.document_purchase_token_cost(), + ), + ]; + for (action, old_cost, new_cost) in costs { + if old_cost == new_cost { + continue; + } + let change = match (old_cost, new_cost) { + (None, Some(_)) => "add", + (Some(_), None) => "remove", + _ => "change", + }; + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not {change} the token cost of its {action} action: \ + token costs are fixed when the document type is published" + ), + ) + .into(), + ); + } + SimpleConsensusValidationResult::new() + } + /// Whether moderators may delete documents of a type is fixed when the /// type is created: whoever wrote a document knows from the type's first /// version who may take it down, and a type that is the target of a @@ -325,8 +400,9 @@ impl DocumentTypeRef<'_> { /// paid for that lifetime: adding a `ttl` would leave the stored documents without an /// entry the cleanup could find, removing it would leave entries deleting documents /// the type says live forever, and changing it would move expiries nobody paid for. - /// The schema compatibility differ has no rule for the key, so this check has to run - /// before it. A document type added by an update declares `ttl` freely. + /// It runs before the schema compatibility differ, which only freezes the key's text, + /// so a real change gets this error. A document type added by an update declares `ttl` + /// freely. fn validate_documents_ttl_unchanged( &self, new_document_type: DocumentTypeRef, @@ -559,6 +635,9 @@ mod tests { use crate::consensus::basic::BasicError; use crate::consensus::state::state_error::StateError; use crate::consensus::ConsensusError; + use crate::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use crate::data_contract::associated_token::token_configuration::TokenConfiguration; + use crate::data_contract::config::moderation::{ContractModerationConfig, ContractModerators}; use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::DocumentType; use assert_matches::assert_matches; @@ -645,10 +724,6 @@ mod tests { // promise of never changing. #[test] fn should_return_invalid_result_when_can_be_deleted_by_moderators_is_changed() { - use crate::data_contract::config::moderation::{ - ContractModerationConfig, ContractModerators, - }; - let platform_version = PlatformVersion::latest(); let data_contract_id = Identifier::random(); @@ -696,7 +771,7 @@ mod tests { let new_document_type = make_document_type(schema(new_flag)); // The whole generation 1 pipeline: the rule answers before the schema - // compatibility differ, which has no rule for the keyword. + // compatibility differ, which only freezes the keyword's text. let result = old_document_type .as_ref() .validate_update(new_document_type.as_ref(), 2, platform_version) @@ -717,10 +792,6 @@ mod tests { #[test] fn should_return_invalid_result_when_the_moderators_window_is_changed() { - use crate::data_contract::config::moderation::{ - ContractModerationConfig, ContractModerators, - }; - let platform_version = PlatformVersion::latest(); let data_contract_id = Identifier::random(); let config = DataContractConfig::default_for_version(platform_version) @@ -834,7 +905,7 @@ mod tests { // Stored documents carry the expiry they were written and paid with: adding, // removing, lengthening and shortening the time to live are all refused, before the - // schema compatibility differ, which has no rule for the key. + // schema compatibility differ, which only freezes the key's text. for (old_ttl, new_ttl, from, to) in [ (Some(86400), Some(172800), "86400 seconds", "172800 seconds"), (Some(86400), Some(3600), "86400 seconds", "3600 seconds"), @@ -1015,6 +1086,253 @@ mod tests { assert!(result.is_valid(), "{:?}", result.errors); } + /// A document type with a string `a` and a required integer `n`, whose + /// schema also carries every `(key, value)` of `extra`. Its contract has a + /// token at position 0 and declares moderation, so any document type + /// keyword may be set. + fn doc_type_with_keywords(extra: Value, platform_version: &PlatformVersion) -> DocumentType { + let mut schema = platform_value!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "n": {"type": "integer", "position": 1, "minimum": 0, "maximum": 1000_u64}, + }, + "required": ["n"], + "additionalProperties": false, + }); + for (key, value) in extra.into_btree_string_map().expect("extra is a map") { + schema + .insert(key, value) + .expect("expected to set the keyword"); + } + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config") + .with_moderation(Some(ContractModerationConfig { + banlist: true, + suspensions: false, + moderators: ContractModerators::ContractOwner, + warnings: false, + })); + let token_configurations = BTreeMap::from([( + 0, + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()), + )]); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "test", + schema, + None, + &token_configurations, + &config, + true, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + // Token costs are fixed when the document type is published, as its action fees are, + // and a change is refused with a consensus error before the schema compatibility + // differ runs. + #[test] + fn should_return_invalid_result_when_token_costs_are_changed() { + let platform_version = PlatformVersion::latest(); + let costing = |token_cost: Value| { + doc_type_with_keywords( + platform_value!({ "tokenCost": token_cost }), + platform_version, + ) + }; + let free = doc_type_with_keywords(platform_value!({}), platform_version); + let create_1 = + costing(platform_value!({"create": {"tokenPosition": 0_u64, "amount": 1_u64}})); + + for (old, new, expected) in [ + ( + &free, + &create_1, + "can not add the token cost of its create action", + ), + ( + &create_1, + &free, + "can not remove the token cost of its create action", + ), + ( + &create_1, + &costing(platform_value!({"create": {"tokenPosition": 0_u64, "amount": 2_u64}})), + "can not change the token cost of its create action", + ), + ( + &create_1, + &costing(platform_value!({ + "create": {"tokenPosition": 0_u64, "amount": 1_u64, "effect": 1_u64} + })), + "can not change the token cost of its create action", + ), + ( + &create_1, + &costing(platform_value!({ + "create": {"tokenPosition": 0_u64, "amount": 1_u64, "gasFeesPaidBy": 1_u64} + })), + "can not change the token cost of its create action", + ), + ( + &create_1, + &costing(platform_value!({ + "create": {"tokenPosition": 0_u64, "amount": 1_u64, "optional": true} + })), + "can not change the token cost of its create action", + ), + ( + &create_1, + &costing(platform_value!({ + "create": {"tokenPosition": 0_u64, "amount": 1_u64}, + "delete": {"tokenPosition": 0_u64, "amount": 1_u64}, + })), + "can not add the token cost of its delete action", + ), + ] { + let result = old + .as_ref() + .validate_update(new.as_ref(), 2, platform_version) + .expect("expected the update to be judged"); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError(StateError::DocumentTypeUpdateError(e))] + if e.additional_message().contains(expected), + "{expected}: {:?}", + result.errors + ); + } + + let result = create_1 + .as_ref() + .validate_update(create_1.as_ref(), 2, platform_version) + .expect("expected the update to be judged"); + assert!(result.is_valid(), "{:?}", result.errors); + } + + // An edit that leaves every parsed value as it was, such as writing out a default or + // switching to the averageable shorthand, still changes the schema text. None of these + // keywords has a rule in the shared rule set; the differ freezes each, so the edit is an + // incompatible schema change, as the same edit to `documentsMutable` is. + #[test] + fn should_refuse_a_schema_edit_that_leaves_the_parsed_document_type_unchanged() { + let platform_version = PlatformVersion::latest(); + let cost = platform_value!({"tokenPosition": 0_u64, "amount": 1_u64}); + let cost_written_out = platform_value!({ + "tokenPosition": 0_u64, + "amount": 1_u64, + "effect": 0_u64, + "gasFeesPaidBy": 0_u64, + "optional": false, + }); + + for (old_keywords, new_keywords, expected_path) in [ + ( + platform_value!({"tokenCost": {"create": cost.clone()}}), + platform_value!({"tokenCost": {"create": cost_written_out}}), + "/tokenCost/create/effect", + ), + ( + platform_value!({"actionFees": {"create": {"owner": 10_u64}}}), + platform_value!({"actionFees": {"create": {"owner": 10_u64, "moderators": 0_u64}}}), + "/actionFees/create/moderators", + ), + ( + platform_value!({}), + platform_value!({"keepsTransferHistory": false}), + "/keepsTransferHistory", + ), + ( + platform_value!({}), + platform_value!({"keepsPurchaseHistory": false}), + "/keepsPurchaseHistory", + ), + ( + platform_value!({}), + platform_value!({"keepsPricingHistory": false}), + "/keepsPricingHistory", + ), + ( + platform_value!({}), + platform_value!({"documentsCountable": false}), + "/documentsCountable", + ), + ( + platform_value!({}), + platform_value!({"rangeCountable": false}), + "/rangeCountable", + ), + ( + platform_value!({}), + platform_value!({"indexOnly": false}), + "/indexOnly", + ), + ( + platform_value!({}), + platform_value!({"canBeDeletedByModerators": false}), + "/canBeDeletedByModerators", + ), + ( + platform_value!({"documentsCountable": true, "documentsSummable": "n"}), + platform_value!({"documentsAverageable": "n"}), + "/documentsAverageable", + ), + ( + platform_value!({ + "documentsAverageable": "n", + "rangeCountable": true, + "rangeSummable": true, + }), + platform_value!({"documentsAverageable": "n", "rangeAverageable": true}), + "/rangeAverageable", + ), + ( + platform_value!({}), + platform_value!({"minProperties": 0_u64}), + "/minProperties", + ), + ( + platform_value!({"maxProperties": 2_u64}), + platform_value!({"maxProperties": 3_u64}), + "/maxProperties", + ), + ] { + let old = doc_type_with_keywords(old_keywords.clone(), platform_version); + let new = doc_type_with_keywords(new_keywords.clone(), platform_version); + let result = old + .as_ref() + .validate_update(new.as_ref(), 2, platform_version) + .unwrap_or_else(|error| { + panic!("{old_keywords:?} -> {new_keywords:?} must be judged, got {error:?}") + }); + assert!( + !result.errors.is_empty() + && result.errors.iter().all(|error| matches!( + error, + ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(_) + ) + )), + "{old_keywords:?} -> {new_keywords:?}: {:?}", + result.errors + ); + assert!( + result.errors.iter().any(|error| matches!( + error, + ConsensusError::BasicError(BasicError::IncompatibleDocumentTypeSchemaError(e)) + if e.property_path() == expected_path + )), + "{old_keywords:?} -> {new_keywords:?}: {:?}", + result.errors + ); + } + } + /// Like `doc_type_with_immutable`, with an `immutableAllowSetting` list /// as well. fn doc_type_with_immutable_lists( diff --git a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs index fff7568e70e..521c3b6beae 100644 --- a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs @@ -38,8 +38,17 @@ //! does the top-level `propertyConstraints` object (protocol version 14): //! every stored document was judged against the rules it names, so none may be //! added, removed or changed. - -use crate::data_contract::document_type::property_names::{PROPERTY_CONSTRAINTS, TRANSIENT}; +//! +//! Every other keyword the document meta-schema admits and the shared rule set +//! has no rule for gets the same frozen rule ([`FROZEN_KEYWORDS_WITHOUT_A_SHARED_RULE`]). +//! Generation 0 fails on a diff under any of them as an unsupported keyword. + +use crate::data_contract::document_type::property_names::{ + ACTION_FEES, CAN_BE_DELETED_BY_MODERATORS, CAN_BE_DELETED_BY_MODERATORS_FOR, + DOCUMENTS_AVERAGEABLE, DOCUMENTS_COUNTABLE, DOCUMENTS_SUMMABLE, ENTRY_PAYLOAD, INDEX_ONLY, + KEEPS_PRICING_HISTORY, KEEPS_PURCHASE_HISTORY, KEEPS_TRANSFER_HISTORY, PROPERTY_CONSTRAINTS, + RANGE_AVERAGEABLE, RANGE_COUNTABLE, RANGE_SUMMABLE, TRANSIENT, TTL, +}; use crate::data_contract::document_type::schema::IncompatibleJsonSchemaOperation; use crate::data_contract::errors::{DataContractError, JsonSchemaError}; use crate::data_contract::JsonValue; @@ -85,6 +94,7 @@ static OPTIONS: Lazy = Lazy::new(|| { // The top-level `propertyConstraints` gets it too: a rule added later // would judge replaces of documents stored without it, and a rule changed // or removed would leave stored documents judged by one no longer there. + // So does every keyword in `FROZEN_KEYWORDS_WITHOUT_A_SHARED_RULE`. let refers_to_rule = KEYWORD_COMPATIBILITY_RULES.get("refersTo"); let frozen_doctype_rules = [ "ownerRefersTo", @@ -93,6 +103,7 @@ static OPTIONS: Lazy = Lazy::new(|| { PROPERTY_CONSTRAINTS, ] .into_iter() + .chain(FROZEN_KEYWORDS_WITHOUT_A_SHARED_RULE) .filter_map(|keyword| refers_to_rule.map(|rule| (keyword, rule.clone()))); Options { @@ -104,6 +115,45 @@ static OPTIONS: Lazy = Lazy::new(|| { } }); +/// The keywords the document meta-schema admits that have no rule in the +/// shared rule set, which generation 0 also reads, and are not stripped by +/// [`prepared_for_diff`]. Without a rule, a diff under one fails as an +/// unsupported keyword: an internal error, where a contract update should get +/// a consensus error. Each is frozen, so adding, removing or changing it is an +/// incompatible change. +/// +/// Where the document type parse reads the keyword, `validate_update` v1 +/// compares the parsed values first and refuses a real change with +/// `DocumentTypeUpdateError`. What reaches this rule is then an edit the parse +/// reads the same, such as writing out a default or switching to the +/// `documentsAverageable` shorthand, refused like the same edit to +/// `documentsMutable` or `canBeDeleted`. `minProperties`, `maxProperties` and +/// `contains` have no parsed value; the first two are also admitted on object +/// properties and `contains` only on properties, where the rule applies too. +/// `$schema` needs no rule: the parse refuses a document type schema that +/// carries it and adds it only to the copy it validates. +const FROZEN_KEYWORDS_WITHOUT_A_SHARED_RULE: [&str; 19] = [ + "tokenCost", + TTL, + ACTION_FEES, + INDEX_ONLY, + ENTRY_PAYLOAD, + KEEPS_TRANSFER_HISTORY, + KEEPS_PURCHASE_HISTORY, + KEEPS_PRICING_HISTORY, + DOCUMENTS_COUNTABLE, + RANGE_COUNTABLE, + DOCUMENTS_SUMMABLE, + RANGE_SUMMABLE, + DOCUMENTS_AVERAGEABLE, + RANGE_AVERAGEABLE, + CAN_BE_DELETED_BY_MODERATORS, + CAN_BE_DELETED_BY_MODERATORS_FOR, + "minProperties", + "maxProperties", + "contains", +]; + /// The document type's own top-level keys whose changes are validated by /// dedicated checks in `validate_update` v1 instead of the JSON diff: /// `indices` (index definitions compared by name), `required` @@ -185,9 +235,11 @@ pub(super) fn validate_schema_compatibility_v1( #[cfg(test)] mod tests { use super::super::validate_schema_compatibility; + use super::{OPTIONS, TOP_LEVEL_VALIDATED_KEYS}; use crate::data_contract::errors::{DataContractError, JsonSchemaError}; use crate::ProtocolError; use assert_matches::assert_matches; + use json_schema_compatibility_validator::KEYWORD_COMPATIBILITY_RULES; use platform_version::version::PlatformVersion; use serde_json::json; @@ -539,4 +591,212 @@ mod tests { .is_valid() ); } + + fn document_type_schema() -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "a": {"type": "integer", "position": 0}, + "list": {"type": "array", "items": {"type": "integer"}, "position": 1}, + "object": { + "type": "object", + "properties": {"b": {"type": "integer", "position": 0}}, + "additionalProperties": false, + "position": 2 + }, + }, + "additionalProperties": false, + }) + } + + /// Where a keyword sits, a value, another value, and the path of the first + /// incompatible change reported between the two. + type FrozenKeywordCase = ( + &'static str, + serde_json::Value, + serde_json::Value, + &'static str, + ); + + /// Every keyword the differ freezes with no shared rule, at the top of the + /// document type or in a property's schema. + fn frozen_keyword_cases() -> Vec { + let mut cases: Vec = vec![ + ( + "/tokenCost", + json!({"create": {"tokenPosition": 0, "amount": 1}}), + json!({"create": {"tokenPosition": 0, "amount": 1, "effect": 0}}), + "/tokenCost/create/effect", + ), + ( + "/actionFees", + json!({"create": {"owner": 10}}), + json!({"create": {"owner": 10, "moderators": 0}}), + "/actionFees/create/moderators", + ), + ( + "/entryPayload", + json!(["a", "b"]), + json!(["b", "a"]), + "/entryPayload/0", + ), + ( + "/properties/list/contains", + json!({"minimum": 1}), + json!({"minimum": 0}), + "/properties/list/contains/minimum", + ), + ]; + // Scalars, whose change is reported at the keyword itself + let scalars = [ + ("/ttl", json!(86400), json!(3600)), + ("/indexOnly", json!(true), json!(false)), + ("/keepsTransferHistory", json!(true), json!(false)), + ("/keepsPurchaseHistory", json!(true), json!(false)), + ("/keepsPricingHistory", json!(true), json!(false)), + ("/documentsCountable", json!(true), json!(false)), + ("/rangeCountable", json!(true), json!(false)), + ("/documentsSummable", json!("a"), json!("b")), + ("/rangeSummable", json!(true), json!(false)), + ("/documentsAverageable", json!("a"), json!("b")), + ("/rangeAverageable", json!(true), json!(false)), + ("/canBeDeletedByModerators", json!(true), json!(false)), + ("/canBeDeletedByModeratorsFor", json!(3600), json!(7200)), + ("/minProperties", json!(1), json!(0)), + ("/maxProperties", json!(2), json!(3)), + ("/properties/object/minProperties", json!(1), json!(0)), + ("/properties/object/maxProperties", json!(1), json!(2)), + ]; + cases.extend( + scalars + .into_iter() + .map(|(pointer, value, other_value)| (pointer, value, other_value, pointer)), + ); + cases + } + + fn with_pointer(pointer: &str, value: Option) -> serde_json::Value { + let mut schema = document_type_schema(); + let (parent, key) = pointer.rsplit_once('/').expect("a pointer has a parent"); + let parent = schema + .pointer_mut(parent) + .and_then(serde_json::Value::as_object_mut) + .expect("the parent is an object"); + if let Some(value) = value { + parent.insert(key.to_string(), value); + } + schema + } + + /// Meta-schema v3 admits these keywords, which the shared rule set has no + /// rule for: each is frozen, so adding, removing or changing one, even to + /// a value the parse reads the same, is an incompatible change and not an + /// unsupported keyword. Where the parse reads a value, `validate_update` + /// refuses a real change before this check runs. + #[test] + fn should_report_every_change_to_a_keyword_without_a_shared_rule_as_incompatible() { + let platform_version = PlatformVersion::latest(); + for (pointer, value, other_value, changed_path) in frozen_keyword_cases() { + // Adding, removing, then changing the value: the first incompatible + // change reported is at the keyword, then inside its value + for (original, new, first_change_path) in [ + (None, Some(value.clone()), pointer), + (Some(value.clone()), None, pointer), + (Some(value.clone()), Some(other_value), changed_path), + ] { + let result = validate_schema_compatibility( + &with_pointer(pointer, original.clone()), + &with_pointer(pointer, new.clone()), + platform_version, + ) + .unwrap_or_else(|error| { + panic!("{pointer}: {original:?} -> {new:?} must be judged, got {error:?}") + }); + assert_matches!( + result.errors.as_slice(), + [change, ..] if change.path == first_change_path, + "{pointer}: {original:?} -> {new:?}" + ); + } + + let unchanged = with_pointer(pointer, Some(value)); + assert!( + validate_schema_compatibility(&unchanged, &unchanged, platform_version) + .expect("an unchanged schema is judged") + .is_valid(), + "{pointer}" + ); + } + } + + /// Every keyword meta-schema v3 admits at the top of a document type, in a + /// property's schema or in a typed array's element schema is judged by the + /// differ or stripped before it, so no update can fail on one as an + /// unsupported keyword. A keyword added to the meta-schema without a rule + /// fails here. + #[test] + fn should_have_a_rule_for_every_keyword_meta_schema_v3_admits() { + let meta_schema: serde_json::Value = serde_json::from_str(include_str!( + "../../../../../../schema/meta_schemas/document/v3/document-meta.json" + )) + .expect("the v3 document meta-schema is JSON"); + let keywords = |schema: &serde_json::Value| -> Vec { + schema["properties"] + .as_object() + .expect("the schema declares its keywords") + .keys() + .cloned() + .collect() + }; + let has_rule = |keyword: &str| { + OPTIONS.override_rules.contains_key(keyword) + || KEYWORD_COMPATIBILITY_RULES.contains_key(keyword) + }; + + for keyword in keywords(&meta_schema) { + // The parse refuses a schema carrying `$schema`, so no diff reaches it + if keyword == "$schema" || TOP_LEVEL_VALIDATED_KEYS.contains(&keyword.as_str()) { + continue; + } + assert!( + has_rule(&keyword), + "top-level keyword {keyword} has no rule" + ); + } + for definition in ["documentSchema", "documentArrayItem"] { + for keyword in keywords(&meta_schema["$defs"][definition]) { + assert!( + has_rule(&keyword), + "{definition} keyword {keyword} has no rule" + ); + } + } + } + + // Replay-safety pin: protocol version 13 dispatches to v0, where a diff + // under a keyword without a shared rule still hits the unsupported-keyword + // hard error. + #[test] + fn should_hard_error_on_a_token_cost_diff_at_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13 must exist"); + let error = validate_schema_compatibility( + &with_pointer( + "/tokenCost", + Some(json!({"create": {"tokenPosition": 0, "amount": 1}})), + ), + &with_pointer( + "/tokenCost", + Some(json!({"create": {"tokenPosition": 0, "amount": 2}})), + ), + platform_version, + ) + .expect_err("a tokenCost diff must hard-error under v0"); + + assert_matches!( + error, + ProtocolError::DataContractError(DataContractError::JsonSchema( + JsonSchemaError::SchemaCompatibilityValidationError(message) + )) if message == "schema keyword 'tokenCost' at path '/tokenCost/create/amount' is not supported" + ); + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs index 03db53f8041..812d65f0934 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs @@ -270,9 +270,13 @@ mod tests { use dpp::platform_value::platform_value; use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransition; + use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::ValidationMode; + use dpp::platform_value::Value; + use dpp::validation::ConsensusValidationResult; use dpp::version::TryFromPlatformVersioned; + use drive::state_transition_action::StateTransitionAction; use platform_version::{DefaultForPlatformVersion, TryIntoPlatformVersioned}; #[test] @@ -435,6 +439,126 @@ mod tests { ); } + /// Validates the state of an update giving the fixture's `niceDocument` + /// every `(key, value)` of `keywords` on top of its stored schema. + fn validate_state_of_nice_document_update( + keywords: Value, + ) -> Result, Error> { + let platform_version = PlatformVersion::latest(); + let TestData { + mut data_contract, + platform, + } = setup_test(); + apply_contract(&platform, &data_contract, Default::default()); + + let mut updated_document = data_contract + .document_type_for_name("niceDocument") + .expect("the fixture's niceDocument") + .schema() + .clone(); + for (key, value) in keywords + .into_btree_string_map() + .expect("the keywords are a map") + { + updated_document + .set_value(&key, value) + .expect("the keyword sets"); + } + + data_contract.increment_version(); + data_contract + .set_document_schema( + "niceDocument", + updated_document, + true, + &mut vec![], + platform_version, + ) + .expect("to be able to set document schema"); + + let state_transition = DataContractUpdateTransitionV0 { + identity_contract_nonce: 1, + data_contract: DataContractInSerializationFormat::try_from_platform_versioned( + data_contract, + platform_version, + ) + .expect("to be able to convert data contract to serialization format"), + user_fee_increase: 0, + signature: BinaryData::new(vec![0; 65]), + signature_public_key_id: 0, + }; + + let state = platform.state.load(); + + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("expected a platform version"); + + DataContractUpdateTransition::V0(state_transition).validate_state( + None, + &platform_ref, + ValidationMode::Validator, + &BlockInfo::default(), + &mut execution_context, + None, + ) + } + + /// Token costs are fixed when a document type is published. The schema + /// compatibility check had no rule for `tokenCost` and failed with an + /// internal error, dropping the transition unpaid; the parsed costs are + /// now compared first and the update is refused with a consensus error. + #[test] + pub fn should_refuse_an_update_adding_a_token_cost_as_a_document_type_update_error() { + let result = validate_state_of_nice_document_update(platform_value!({ + "tokenCost": { + "create": { + "contractId": Identifier::new([7; 32]).to_buffer(), + "tokenPosition": 0_u64, + "amount": 1_u64, + } + } + })) + .expect("a token cost change is a consensus error, not an internal one"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError(StateError::DocumentTypeUpdateError(e))] + if e.document_type_name() == "niceDocument" + && e.additional_message().contains( + "can not add the token cost of its create action" + ) + ); + } + + /// Writing out a default leaves the parsed document type as it was but + /// changes its schema. The schema compatibility check had no rule for + /// the keyword and failed with an internal error; the keyword is frozen + /// now, and the update is refused as an incompatible schema change. + #[test] + pub fn should_refuse_an_update_writing_out_a_default_as_an_incompatible_schema() { + let result = validate_state_of_nice_document_update(platform_value!({ + "keepsTransferHistory": false, + })) + .expect("writing out a default is a consensus error, not an internal one"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.document_type_name() == "niceDocument" + && e.operation() == "add" + && e.property_path() == "/keepsTransferHistory" + ); + } + #[test] pub fn should_keep_history_if_contract_config_keeps_history_is_true() { let TestData { From d7d5c8459ff060aed5795a2e05949ff01ed722a8 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 00:51:27 +0700 Subject: [PATCH 046/113] fix(dpp)!: accept a reordered entryPayload and refuse unruled keywords as incompatible (PV14) (#5074) Co-authored-by: Claude Opus 5.5 --- .../try_from_schema/common/mod.rs | 2 +- .../methods/validate_update/v1/mod.rs | 221 +++++++--------- .../src/data_contract/document_type/mod.rs | 7 + .../validate_schema_compatibility/v1/mod.rs | 227 +++++++++++++---- .../methods/validate_update/v1/mod.rs | 44 ++++ .../data_contract_update/mod.rs | 237 +++++++++++++----- 6 files changed, 493 insertions(+), 245 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 714e964f7cb..507b533bfc5 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -1445,7 +1445,7 @@ fn parse_token_costs( ctx: &CoreParseContext<'_>, schema: &Value, ) -> Result { - let token_costs_value = schema.get_optional_value("tokenCost")?; + let token_costs_value = schema.get_optional_value(property_names::TOKEN_COST)?; let extract_cost = |key: &str| -> Result, ProtocolError> { token_costs_value diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs index c709f3d7552..1168d973dcc 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs @@ -1000,25 +1000,37 @@ mod tests { ); } - /// A document type with one string property and, when given, `actionFees`. - fn doc_type_with_action_fees( - action_fees: Option, - platform_version: &PlatformVersion, - ) -> DocumentType { + /// A document type with a string `a` and a required integer `n`, whose + /// schema also carries every `(key, value)` of `extra`. Its contract has a + /// token at position 0 and declares moderation, so any document type + /// keyword may be set. + fn doc_type_with_keywords(extra: Value, platform_version: &PlatformVersion) -> DocumentType { let mut schema = platform_value!({ "type": "object", "properties": { "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "n": {"type": "integer", "position": 1, "minimum": 0, "maximum": 1000_u64}, }, + "required": ["n"], "additionalProperties": false, }); - if let Some(action_fees) = action_fees { + for (key, value) in extra.into_btree_string_map().expect("extra is a map") { schema - .insert("actionFees".to_string(), action_fees) - .expect("expected to set the action fees"); + .insert(key, value) + .expect("expected to set the keyword"); } let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); + .expect("should create a default config") + .with_moderation(Some(ContractModerationConfig { + banlist: true, + suspensions: false, + moderators: ContractModerators::ContractOwner, + warnings: false, + })); + let token_configurations = BTreeMap::from([( + 0, + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()), + )]); DocumentType::try_from_schema( Identifier::new([1; 32]), 1, @@ -1026,7 +1038,7 @@ mod tests { "test", schema, None, - &BTreeMap::new(), + &token_configurations, &config, true, &mut Vec::new(), @@ -1040,19 +1052,16 @@ mod tests { #[test] fn should_reject_adding_changing_or_removing_action_fees() { let platform_version = PlatformVersion::latest(); - let free = doc_type_with_action_fees(None, platform_version); - let priced = doc_type_with_action_fees( - Some(platform_value!({"create": {"owner": 10_u64}})), - platform_version, - ); - let repriced = doc_type_with_action_fees( - Some(platform_value!({"create": {"owner": 11_u64}})), - platform_version, - ); - let fixed = doc_type_with_action_fees( - Some(platform_value!({"pricing": "fixed", "create": {"owner": 10_u64}})), - platform_version, - ); + let fees = |action_fees: Value| { + doc_type_with_keywords( + platform_value!({ "actionFees": action_fees }), + platform_version, + ) + }; + let free = doc_type_with_keywords(platform_value!({}), platform_version); + let priced = fees(platform_value!({"create": {"owner": 10_u64}})); + let repriced = fees(platform_value!({"create": {"owner": 11_u64}})); + let fixed = fees(platform_value!({"pricing": "fixed", "create": {"owner": 10_u64}})); for (old, new, change) in [ (&free, &priced, "add"), @@ -1075,8 +1084,8 @@ mod tests { #[test] fn should_accept_unchanged_action_fees() { let platform_version = PlatformVersion::latest(); - let priced = doc_type_with_action_fees( - Some(platform_value!({"create": {"owner": 10_u64, "moderators": 3_u64}})), + let priced = doc_type_with_keywords( + platform_value!({"actionFees": {"create": {"owner": 10_u64, "moderators": 3_u64}}}), platform_version, ); let result = priced @@ -1086,115 +1095,27 @@ mod tests { assert!(result.is_valid(), "{:?}", result.errors); } - /// A document type with a string `a` and a required integer `n`, whose - /// schema also carries every `(key, value)` of `extra`. Its contract has a - /// token at position 0 and declares moderation, so any document type - /// keyword may be set. - fn doc_type_with_keywords(extra: Value, platform_version: &PlatformVersion) -> DocumentType { - let mut schema = platform_value!({ - "type": "object", - "properties": { - "a": {"type": "string", "position": 0, "maxLength": 60_u32}, - "n": {"type": "integer", "position": 1, "minimum": 0, "maximum": 1000_u64}, - }, - "required": ["n"], - "additionalProperties": false, - }); - for (key, value) in extra.into_btree_string_map().expect("extra is a map") { - schema - .insert(key, value) - .expect("expected to set the keyword"); - } - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config") - .with_moderation(Some(ContractModerationConfig { - banlist: true, - suspensions: false, - moderators: ContractModerators::ContractOwner, - warnings: false, - })); - let token_configurations = BTreeMap::from([( - 0, - TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()), - )]); - DocumentType::try_from_schema( - Identifier::new([1; 32]), - 1, - config.version(), - "test", - schema, - None, - &token_configurations, - &config, - true, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create document type") - } - // Token costs are fixed when the document type is published, as its action fees are, // and a change is refused with a consensus error before the schema compatibility // differ runs. #[test] fn should_return_invalid_result_when_token_costs_are_changed() { let platform_version = PlatformVersion::latest(); - let costing = |token_cost: Value| { + // Transferable and tradeable, so that every action may carry a cost + let costing = |token_cost: Vec<(&str, Value)>| { + let token_cost = Value::Map( + token_cost + .into_iter() + .map(|(action, cost)| (Value::Text(action.to_string()), cost)) + .collect(), + ); doc_type_with_keywords( - platform_value!({ "tokenCost": token_cost }), + platform_value!({"transferable": 1_u64, "tradeMode": 1_u64, "tokenCost": token_cost}), platform_version, ) }; - let free = doc_type_with_keywords(platform_value!({}), platform_version); - let create_1 = - costing(platform_value!({"create": {"tokenPosition": 0_u64, "amount": 1_u64}})); - - for (old, new, expected) in [ - ( - &free, - &create_1, - "can not add the token cost of its create action", - ), - ( - &create_1, - &free, - "can not remove the token cost of its create action", - ), - ( - &create_1, - &costing(platform_value!({"create": {"tokenPosition": 0_u64, "amount": 2_u64}})), - "can not change the token cost of its create action", - ), - ( - &create_1, - &costing(platform_value!({ - "create": {"tokenPosition": 0_u64, "amount": 1_u64, "effect": 1_u64} - })), - "can not change the token cost of its create action", - ), - ( - &create_1, - &costing(platform_value!({ - "create": {"tokenPosition": 0_u64, "amount": 1_u64, "gasFeesPaidBy": 1_u64} - })), - "can not change the token cost of its create action", - ), - ( - &create_1, - &costing(platform_value!({ - "create": {"tokenPosition": 0_u64, "amount": 1_u64, "optional": true} - })), - "can not change the token cost of its create action", - ), - ( - &create_1, - &costing(platform_value!({ - "create": {"tokenPosition": 0_u64, "amount": 1_u64}, - "delete": {"tokenPosition": 0_u64, "amount": 1_u64}, - })), - "can not add the token cost of its delete action", - ), - ] { + let cost = |amount: u64| platform_value!({"tokenPosition": 0_u64, "amount": amount}); + let assert_refused = |old: &DocumentType, new: &DocumentType, expected: &str| { let result = old .as_ref() .validate_update(new.as_ref(), 2, platform_version) @@ -1206,13 +1127,55 @@ mod tests { "{expected}: {:?}", result.errors ); + }; + + let free = costing(vec![]); + for action in [ + "create", + "replace", + "delete", + "transfer", + "update_price", + "purchase", + ] { + let priced = costing(vec![(action, cost(1))]); + let repriced = costing(vec![(action, cost(2))]); + assert_refused( + &free, + &priced, + &format!("can not add the token cost of its {action} action"), + ); + assert_refused( + &priced, + &free, + &format!("can not remove the token cost of its {action} action"), + ); + assert_refused( + &priced, + &repriced, + &format!("can not change the token cost of its {action} action"), + ); + + let result = priced + .as_ref() + .validate_update(priced.as_ref(), 2, platform_version) + .expect("expected the update to be judged"); + assert!(result.is_valid(), "{action}: {:?}", result.errors); } - let result = create_1 - .as_ref() - .validate_update(create_1.as_ref(), 2, platform_version) - .expect("expected the update to be judged"); - assert!(result.is_valid(), "{:?}", result.errors); + // Every part of a cost is fixed with it, not only the amount + let create_1 = costing(vec![("create", cost(1))]); + for changed in [ + platform_value!({"tokenPosition": 0_u64, "amount": 1_u64, "effect": 1_u64}), + platform_value!({"tokenPosition": 0_u64, "amount": 1_u64, "gasFeesPaidBy": 1_u64}), + platform_value!({"tokenPosition": 0_u64, "amount": 1_u64, "optional": true}), + ] { + assert_refused( + &create_1, + &costing(vec![("create", changed)]), + "can not change the token cost of its create action", + ); + } } // An edit that leaves every parsed value as it was, such as writing out a default or diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 19b86c847a4..79c65ff0dad 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -82,6 +82,10 @@ pub(crate) mod property_names { /// v3+ (protocol version 14). See `parse_action_fees_keyword` in /// `try_from_schema::common`. pub const ACTION_FEES: &str = "actionFees"; + /// Doctype-level object of token costs, one per document action (`create`, + /// `replace`, `delete`, `transfer`, `update_price`, `purchase`). Meta-schema + /// v0+ (protocol version 9). See `parse_token_costs` in `try_from_schema::common`. + pub const TOKEN_COST: &str = "tokenCost"; /// Doctype-level array naming the [`IMMUTABLE`] properties a replace may /// still set when the stored document has no value for them. Once set /// they are frozen like the rest of the list. Every entry must also be in @@ -99,6 +103,9 @@ pub(crate) mod property_names { pub const MAX_ITEMS: &str = "maxItems"; pub const ITEMS: &str = "items"; pub const UNIQUE_ITEMS: &str = "uniqueItems"; + pub const MIN_PROPERTIES: &str = "minProperties"; + pub const MAX_PROPERTIES: &str = "maxProperties"; + pub const CONTAINS: &str = "contains"; pub const MIN_LENGTH: &str = "minLength"; pub const MAX_LENGTH: &str = "maxLength"; pub const BYTE_ARRAY: &str = "byteArray"; diff --git a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs index 521c3b6beae..805078214bb 100644 --- a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs @@ -41,19 +41,25 @@ //! //! Every other keyword the document meta-schema admits and the shared rule set //! has no rule for gets the same frozen rule ([`FROZEN_KEYWORDS_WITHOUT_A_SHARED_RULE`]). +//! A keyword that still has no rule is frozen as well: its change is reported +//! as incompatible instead of failing the update as an unsupported keyword. //! Generation 0 fails on a diff under any of them as an unsupported keyword. use crate::data_contract::document_type::property_names::{ - ACTION_FEES, CAN_BE_DELETED_BY_MODERATORS, CAN_BE_DELETED_BY_MODERATORS_FOR, + ACTION_FEES, CAN_BE_DELETED_BY_MODERATORS, CAN_BE_DELETED_BY_MODERATORS_FOR, CONTAINS, DOCUMENTS_AVERAGEABLE, DOCUMENTS_COUNTABLE, DOCUMENTS_SUMMABLE, ENTRY_PAYLOAD, INDEX_ONLY, - KEEPS_PRICING_HISTORY, KEEPS_PURCHASE_HISTORY, KEEPS_TRANSFER_HISTORY, PROPERTY_CONSTRAINTS, - RANGE_AVERAGEABLE, RANGE_COUNTABLE, RANGE_SUMMABLE, TRANSIENT, TTL, + KEEPS_PRICING_HISTORY, KEEPS_PURCHASE_HISTORY, KEEPS_TRANSFER_HISTORY, MAX_PROPERTIES, + MIN_PROPERTIES, PROPERTY_CONSTRAINTS, RANGE_AVERAGEABLE, RANGE_COUNTABLE, RANGE_SUMMABLE, + TOKEN_COST, TRANSIENT, TTL, }; use crate::data_contract::document_type::schema::IncompatibleJsonSchemaOperation; use crate::data_contract::errors::{DataContractError, JsonSchemaError}; use crate::data_contract::JsonValue; use crate::validation::SimpleValidationResult; use crate::ProtocolError; +use json_schema_compatibility_validator::error::{ + Error as CompatibilityError, UnsupportedSchemaKeywordError, +}; use json_schema_compatibility_validator::{ validate_schemas_compatibility, CompatibilityRulesCollection, Options, KEYWORD_COMPATIBILITY_RULES, @@ -132,8 +138,12 @@ static OPTIONS: Lazy = Lazy::new(|| { /// properties and `contains` only on properties, where the rule applies too. /// `$schema` needs no rule: the parse refuses a document type schema that /// carries it and adds it only to the copy it validates. +/// +/// A keyword missing from this list is still refused, by the fallback in +/// [`validate_schema_compatibility_v1`], but only the first change under it is +/// reported: the list keeps every change reported at its own path. const FROZEN_KEYWORDS_WITHOUT_A_SHARED_RULE: [&str; 19] = [ - "tokenCost", + TOKEN_COST, TTL, ACTION_FEES, INDEX_ONLY, @@ -149,9 +159,9 @@ const FROZEN_KEYWORDS_WITHOUT_A_SHARED_RULE: [&str; 19] = [ RANGE_AVERAGEABLE, CAN_BE_DELETED_BY_MODERATORS, CAN_BE_DELETED_BY_MODERATORS_FOR, - "minProperties", - "maxProperties", - "contains", + MIN_PROPERTIES, + MAX_PROPERTIES, + CONTAINS, ]; /// The document type's own top-level keys whose changes are validated by @@ -166,29 +176,35 @@ const FROZEN_KEYWORDS_WITHOUT_A_SHARED_RULE: [&str; 19] = [ const TOP_LEVEL_VALIDATED_KEYS: [&str; 4] = ["indices", "required", "immutable", "immutableAllowSetting"]; +/// The document type's own top-level lists of property names that the parse +/// reads as sets: `transient`, and `entryPayload`, whose properties are framed +/// in each stored entry in name order whatever order the list gives. +const TOP_LEVEL_NAME_SETS: [&str; 2] = [TRANSIENT, ENTRY_PAYLOAD]; + /// Prepares a document type schema to be diffed: strips -/// [`TOP_LEVEL_VALIDATED_KEYS`], and sorts and deduplicates the top-level -/// `transient` list, which the parse reads as a set, so reordering or -/// repeating names is no change. Only the document type's own top-level keys -/// are touched; a nested object property's `required` array lives under -/// `/properties//required` and stays governed by the differ's frozen -/// `required` rule, as do properties named `indices`, `required`, -/// `immutable` or `transient`. +/// [`TOP_LEVEL_VALIDATED_KEYS`], and sorts and deduplicates each list of +/// [`TOP_LEVEL_NAME_SETS`], so reordering or repeating names is no change. +/// Only the document type's own top-level keys are touched; a nested object +/// property's `required` array lives under `/properties//required` and +/// stays governed by the differ's frozen `required` rule, as do properties +/// named `indices`, `required`, `immutable`, `transient` or `entryPayload`. fn prepared_for_diff(schema: &JsonValue) -> Cow<'_, JsonValue> { match schema { JsonValue::Object(map) - if map.contains_key(TRANSIENT) - || TOP_LEVEL_VALIDATED_KEYS - .iter() - .any(|key| map.contains_key(*key)) => + if TOP_LEVEL_NAME_SETS + .iter() + .chain(TOP_LEVEL_VALIDATED_KEYS.iter()) + .any(|key| map.contains_key(*key)) => { let mut map = map.clone(); for key in TOP_LEVEL_VALIDATED_KEYS { map.remove(key); } - if let Some(JsonValue::Array(names)) = map.get_mut(TRANSIENT) { - names.sort_by(|a, b| a.as_str().cmp(&b.as_str())); - names.dedup(); + for key in TOP_LEVEL_NAME_SETS { + if let Some(JsonValue::Array(names)) = map.get_mut(key) { + names.sort_by(|a, b| a.as_str().cmp(&b.as_str())); + names.dedup(); + } } Cow::Owned(JsonValue::Object(map)) } @@ -212,8 +228,8 @@ pub(super) fn validate_schema_compatibility_v1( let original_schema = prepared_for_diff(original_schema); let new_schema = prepared_for_diff(new_schema); - validate_schemas_compatibility(&original_schema, &new_schema, OPTIONS.deref()) - .map(|result| { + match validate_schemas_compatibility(&original_schema, &new_schema, OPTIONS.deref()) { + Ok(result) => { let errors = result .into_changes() .into_iter() @@ -223,13 +239,35 @@ pub(super) fn validate_schema_compatibility_v1( }) .collect::>(); - SimpleValidationResult::new_with_errors(errors) - }) - .map_err(|error| { - ProtocolError::DataContractError(DataContractError::JsonSchema( - JsonSchemaError::SchemaCompatibilityValidationError(error.to_string()), + Ok(SimpleValidationResult::new_with_errors(errors)) + } + // A keyword with no rule at all is frozen like those listed: its change + // is an incompatible one, not an internal error. The validator stops at + // it, so it is the only change reported. The operation is read off the + // two schemas as the diff chose it: a path the original lacks was added, + // one the new schema lacks was removed, and any other was replaced. + Err(CompatibilityError::UnsupportedSchemaKeyword(UnsupportedSchemaKeywordError { + path, + .. + })) => { + let name = match (original_schema.pointer(&path), new_schema.pointer(&path)) { + (None, _) => "add", + (_, None) => "remove", + _ => "replace", + }; + Ok(SimpleValidationResult::new_with_error( + IncompatibleJsonSchemaOperation { + name: name.to_string(), + path, + }, )) - }) + } + Err(error) => Err(ProtocolError::DataContractError( + DataContractError::JsonSchema(JsonSchemaError::SchemaCompatibilityValidationError( + error.to_string(), + )), + )), + } } #[cfg(test)] @@ -240,7 +278,7 @@ mod tests { use crate::ProtocolError; use assert_matches::assert_matches; use json_schema_compatibility_validator::KEYWORD_COMPATIBILITY_RULES; - use platform_version::version::PlatformVersion; + use platform_version::version::{PlatformVersion, PLATFORM_VERSIONS}; use serde_json::json; #[test] @@ -637,8 +675,8 @@ mod tests { ( "/entryPayload", json!(["a", "b"]), - json!(["b", "a"]), - "/entryPayload/0", + json!(["a", "c"]), + "/entryPayload/1", ), ( "/properties/list/contains", @@ -729,17 +767,14 @@ mod tests { } } - /// Every keyword meta-schema v3 admits at the top of a document type, in a - /// property's schema or in a typed array's element schema is judged by the - /// differ or stripped before it, so no update can fail on one as an - /// unsupported keyword. A keyword added to the meta-schema without a rule - /// fails here. + /// Every keyword the document meta-schema admits, at the top of a document + /// type, in a property's schema or in a typed array's element schema, is + /// judged by a rule of its own or stripped before the diff, for every + /// protocol version that selects this generation. A keyword added to the + /// meta-schema without a rule fails here, and so does a new meta-schema + /// diffed by this generation until it is listed below. #[test] - fn should_have_a_rule_for_every_keyword_meta_schema_v3_admits() { - let meta_schema: serde_json::Value = serde_json::from_str(include_str!( - "../../../../../../schema/meta_schemas/document/v3/document-meta.json" - )) - .expect("the v3 document meta-schema is JSON"); + fn should_have_a_rule_for_every_keyword_the_meta_schema_admits() { let keywords = |schema: &serde_json::Value| -> Vec { schema["properties"] .as_object() @@ -753,23 +788,109 @@ mod tests { || KEYWORD_COMPATIBILITY_RULES.contains_key(keyword) }; - for keyword in keywords(&meta_schema) { - // The parse refuses a schema carrying `$schema`, so no diff reaches it - if keyword == "$schema" || TOP_LEVEL_VALIDATED_KEYS.contains(&keyword.as_str()) { + for platform_version in PLATFORM_VERSIONS { + let schema_versions = &platform_version + .dpp + .contract_versions + .document_type_versions + .schema; + if schema_versions.validate_schema_compatibility != 1 { continue; } - assert!( - has_rule(&keyword), - "top-level keyword {keyword} has no rule" - ); - } - for definition in ["documentSchema", "documentArrayItem"] { - for keyword in keywords(&meta_schema["$defs"][definition]) { + let meta_schema: serde_json::Value = match schema_versions.document_type_schema { + 3 => serde_json::from_str(include_str!( + "../../../../../../schema/meta_schemas/document/v3/document-meta.json" + )) + .expect("the v3 document meta-schema is JSON"), + version => panic!( + "protocol version {} diffs document meta-schema {version} with this \ + generation: list it here", + platform_version.protocol_version + ), + }; + + for keyword in keywords(&meta_schema) { + // The parse refuses a schema carrying `$schema`, so no diff reaches it + if keyword == "$schema" || TOP_LEVEL_VALIDATED_KEYS.contains(&keyword.as_str()) { + continue; + } assert!( has_rule(&keyword), - "{definition} keyword {keyword} has no rule" + "top-level keyword {keyword} has no rule" ); } + for definition in ["documentSchema", "documentArrayItem"] { + for keyword in keywords(&meta_schema["$defs"][definition]) { + assert!( + has_rule(&keyword), + "{definition} keyword {keyword} has no rule" + ); + } + } + } + } + + /// A keyword with no rule at all is refused as an incompatible change, with + /// the operation the diff made, instead of failing as an unsupported + /// keyword. The meta-schema admits no such keyword today; the fallback is + /// what keeps a later one from failing the update with an internal error. + #[test] + fn should_report_a_change_under_a_keyword_with_no_rule_as_incompatible() { + let platform_version = PlatformVersion::latest(); + for (pointer, original, new, change_name, change_path) in [ + ("/unruled", None, Some(json!(1)), "add", "/unruled"), + ("/unruled", Some(json!(1)), None, "remove", "/unruled"), + ( + "/unruled", + Some(json!(1)), + Some(json!(2)), + "replace", + "/unruled", + ), + ( + "/unruled", + Some(json!({"a": 1})), + Some(json!({"a": 1, "b": 2})), + "add", + "/unruled/b", + ), + ( + "/properties/a/unruled", + Some(json!([1, 2])), + Some(json!([1])), + "remove", + "/properties/a/unruled/1", + ), + ] { + let result = validate_schema_compatibility( + &with_pointer(pointer, original.clone()), + &with_pointer(pointer, new.clone()), + platform_version, + ) + .unwrap_or_else(|error| { + panic!("{pointer}: {original:?} -> {new:?} must be judged, got {error:?}") + }); + assert_matches!( + result.errors.as_slice(), + [change] if change.name == change_name && change.path == change_path, + "{pointer}: {original:?} -> {new:?}" + ); + } + } + + /// The parse reads `entryPayload` as a set, as it does `transient`, so + /// reordering or repeating names changes nothing a stored entry depends on. + #[test] + fn should_accept_a_reordered_or_repeated_entry_payload() { + let platform_version = PlatformVersion::latest(); + for new in [json!(["b", "a"]), json!(["a", "b", "a"])] { + let result = validate_schema_compatibility( + &with_pointer("/entryPayload", Some(json!(["a", "b"]))), + &with_pointer("/entryPayload", Some(new.clone())), + platform_version, + ) + .expect("an entryPayload change is judged, not an unsupported keyword"); + assert!(result.is_valid(), "{new:?}: {:?}", result.errors); } } diff --git a/packages/rs-dpp/src/data_contract/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/methods/validate_update/v1/mod.rs index 45fa85c3fab..3ba6b4423d6 100644 --- a/packages/rs-dpp/src/data_contract/methods/validate_update/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/validate_update/v1/mod.rs @@ -343,4 +343,48 @@ mod tests { assert!(result.is_valid(), "unexpected errors: {:?}", result.errors); } + + /// The contract's `$defs` are diffed with the rules document types are, so + /// a change under a keyword the shared rule set has no rule for, such as + /// `maxProperties`, is an incompatible schema change and not an internal + /// error. + #[test] + fn should_refuse_a_defs_change_under_a_keyword_without_a_shared_rule() { + let platform_version = PlatformVersion::latest(); + let defs = |max_properties: u64| { + platform_value!({ + "lastName": { "type": "string" }, + "address": { "type": "object", "maxProperties": max_properties }, + }) + .into_btree_string_map() + .expect("the definitions are a map") + }; + + let mut old_data_contract = get_data_contract_fixture( + None, + IdentityNonce::default(), + platform_version.protocol_version, + ) + .data_contract_owned(); + old_data_contract + .set_schema_defs(Some(defs(2)), false, &mut Vec::new(), platform_version) + .expect("failed to set schema defs"); + + let mut new_data_contract = old_data_contract.clone(); + new_data_contract.set_version(old_data_contract.version() + 1); + new_data_contract + .set_schema_defs(Some(defs(3)), false, &mut Vec::new(), platform_version) + .expect("failed to set schema defs"); + + let result = old_data_contract + .validate_update(&new_data_contract, &BlockInfo::default(), platform_version) + .expect("a $defs change is judged, not an unsupported keyword"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDataContractSchemaError(e) + )] if e.operation() == "replace" && e.field_path() == "/$defs/address/maxProperties" + ); + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs index 812d65f0934..922da9217cc 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs @@ -366,68 +366,10 @@ mod tests { /// transition unpaid; it now reports an incompatible schema change. #[test] pub fn should_refuse_an_update_changing_the_transient_list_as_an_incompatible_schema() { - let platform_version = PlatformVersion::latest(); - let TestData { - mut data_contract, - platform, - } = setup_test(); - apply_contract(&platform, &data_contract, Default::default()); - - let mut updated_document = data_contract - .document_type_for_name("niceDocument") - .expect("the fixture's niceDocument") - .schema() - .clone(); - updated_document - .set_value("transient", platform_value!(["name"])) - .expect("the transient list sets"); - - data_contract.increment_version(); - data_contract - .set_document_schema( - "niceDocument", - updated_document, - true, - &mut vec![], - platform_version, - ) - .expect("to be able to set document schema"); - - let state_transition = DataContractUpdateTransitionV0 { - identity_contract_nonce: 1, - data_contract: DataContractInSerializationFormat::try_from_platform_versioned( - data_contract, - platform_version, - ) - .expect("to be able to convert data contract to serialization format"), - user_fee_increase: 0, - signature: BinaryData::new(vec![0; 65]), - signature_public_key_id: 0, - }; - - let state = platform.state.load(); - - let platform_ref = PlatformRef { - drive: &platform.drive, - state: &state, - config: &platform.config, - core_rpc: &platform.core_rpc, - }; - - let mut execution_context = - StateTransitionExecutionContext::default_for_platform_version(platform_version) - .expect("expected a platform version"); - - let result = DataContractUpdateTransition::V0(state_transition) - .validate_state( - None, - &platform_ref, - ValidationMode::Validator, - &BlockInfo::default(), - &mut execution_context, - None, - ) - .expect("a transient change is a consensus error, not an internal one"); + let result = validate_state_of_nice_document_update(platform_value!({ + "transient": ["name"], + })) + .expect("a transient change is a consensus error, not an internal one"); assert_matches!( result.errors.as_slice(), @@ -1234,6 +1176,177 @@ mod tests { } } + /// A contract update refused by the document type comparison or the schema + /// comparison is a paid consensus error: the transition stays in the block, + /// its identity contract nonce is bumped and its fee is charged. Protocol + /// version 14 used to fail these updates with an internal error, which + /// left the transition out of the block. + mod refused_keyword_updates { + use super::*; + use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dpp::data_contract::schema::DataContractSchemaMethodsV0; + use dpp::identity::identity_nonce::IDENTITY_NONCE_VALUE_FILTER; + use dpp::platform_value::{platform_value, Value}; + + /// Processes an update, signed with identity contract nonce 1, giving + /// the fixture's `niceDocument` every `(key, value)` of `keywords` on + /// top of its stored schema. Returns the execution result, then the + /// identity's contract nonce and the credits it was charged once the + /// block is committed. + async fn process_nice_document_update( + keywords: Value, + ) -> (StateTransitionExecutionResult, Option, Credits) { + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + let initial_balance = dash_to_credits!(1.0); + let (identity, signer, key) = setup_identity(&mut platform, 958, initial_balance); + let identity_id = identity.id(); + + let platform_state = platform.state.load(); + let platform_version = platform_state + .current_platform_version() + .expect("expected to get current platform version"); + + let mut data_contract = + get_data_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + data_contract.set_owner_id(identity_id); + apply_contract(&platform, &data_contract, BlockInfo::default()); + + let mut updated_document = data_contract + .document_type_for_name("niceDocument") + .expect("the fixture's niceDocument") + .schema() + .clone(); + for (key, value) in keywords + .into_btree_string_map() + .expect("the keywords are a map") + { + updated_document + .set_value(&key, value) + .expect("the keyword sets"); + } + let mut updated_data_contract = data_contract.clone(); + updated_data_contract.set_version(2); + updated_data_contract + .set_document_schema( + "niceDocument", + updated_document, + true, + &mut vec![], + platform_version, + ) + .expect("to be able to set document schema"); + + let transition = DataContractUpdateTransition::new_from_data_contract( + updated_data_contract, + &identity.into_partial_identity_info(), + key.id(), + 1, + 0, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create data contract update transition"); + let serialized_transition = transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let mut execution_results = processing_result.into_execution_results(); + assert_eq!(execution_results.len(), 1, "{execution_results:?}"); + + let nonce = platform + .drive + .fetch_identity_contract_nonce( + identity_id.to_buffer(), + data_contract.id().to_buffer(), + true, + None, + platform_version, + ) + .expect("expected to fetch the identity contract nonce") + .map(|nonce| nonce & IDENTITY_NONCE_VALUE_FILTER); + let balance = platform + .drive + .fetch_identity_balance(identity_id.to_buffer(), None, platform_version) + .expect("expected to fetch the balance") + .expect("the identity has a balance"); + + ( + execution_results.remove(0), + nonce, + initial_balance - balance, + ) + } + + #[tokio::test] + async fn should_charge_a_refused_token_cost_change_as_a_paid_consensus_error() { + let (result, nonce, charged) = process_nice_document_update(platform_value!({ + "tokenCost": { + "create": { + "contractId": Identifier::new([7; 32]).to_buffer(), + "tokenPosition": 0_u64, + "amount": 1_u64, + } + } + })) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::DocumentTypeUpdateError(e)), + .. + } if e.additional_message().contains("can not add the token cost of its create action") + ); + assert_eq!(nonce, Some(1)); + assert!(charged > 0, "the fee is charged"); + } + + #[tokio::test] + async fn should_charge_a_refused_schema_edit_as_a_paid_consensus_error() { + let (result, nonce, charged) = process_nice_document_update(platform_value!({ + "keepsTransferHistory": false, + })) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + ), + .. + } if e.operation() == "add" && e.property_path() == "/keepsTransferHistory" + ); + assert_eq!(nonce, Some(1)); + assert!(charged > 0, "the fee is charged"); + } + } + mod group_tests { use super::*; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::UnpaidConsensusError; From 55f058796e0f84a79820730676825268028d095d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:58:59 +0300 Subject: [PATCH 047/113] docs(swift-sdk): say the migration copy is switched out of WAL mode Co-Authored-By: Claude Opus 5.5 --- .../SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift index 95d4468e1a4..cad4a7bab20 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift @@ -116,7 +116,7 @@ enum DashLegacyStoreSQLite { } // The backup copies page 1, so a copy of a WAL store is marked WAL // but has no -wal/-shm. A read-only connection cannot create them - // and fails with SQLITE_CANTOPEN, so leave WAL on the new copy. + // and fails with SQLITE_CANTOPEN, so switch the new copy out of WAL mode. if lockedDestinationCheck == nil { var modes: [String] = [] try output.query("PRAGMA journal_mode=DELETE") { modes.append(string($0, 0).lowercased()) } From 2302a800c13d72188865594c02ba388ed565799a Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 01:20:39 +0700 Subject: [PATCH 048/113] docs: give every contract keyword its own chapter in the book (#5075) Co-authored-by: Claude Opus 5.5 --- book/src/SUMMARY.md | 35 +- book/src/contract-keywords.md | 346 ++++-------------- book/src/contract-keywords/action-fees.md | 102 ++++++ book/src/contract-keywords/aggregates.md | 178 +++++++++ book/src/contract-keywords/contested.md | 81 ++++ book/src/contract-keywords/contract-config.md | 206 +++++++++++ book/src/contract-keywords/deletion.md | 148 ++++++++ book/src/contract-keywords/distinct-from.md | 82 +++++ book/src/contract-keywords/document-shape.md | 169 +++++++++ book/src/contract-keywords/encrypted-for.md | 115 ++++++ book/src/contract-keywords/history.md | 143 ++++++++ book/src/contract-keywords/index-only.md | 184 ++++++++++ book/src/contract-keywords/indexes.md | 176 +++++++++ book/src/contract-keywords/max-bytes.md | 61 +++ book/src/contract-keywords/mutability.md | 155 ++++++++ book/src/contract-keywords/owner-refers-to.md | 123 +++++++ .../ownership-and-trading.md | 119 ++++++ .../contract-keywords/property-constraints.md | 300 +++++++++++++++ .../src/contract-keywords/property-schemas.md | 300 +++++++++++++++ book/src/contract-keywords/ranked.md | 129 +++++++ .../refers-to-expressions.md | 130 +++++++ .../refers-to-list-element.md | 87 +++++ .../src/contract-keywords/refers-to-lookup.md | 124 +++++++ book/src/contract-keywords/refers-to.md | 324 ++++++++++++++++ book/src/contract-keywords/required-since.md | 61 +++ book/src/contract-keywords/signing-keys.md | 147 ++++++++ .../contract-keywords/system-properties.md | 142 +++++++ book/src/contract-keywords/time-range.md | 96 +++++ book/src/contract-keywords/token-cost.md | 126 +++++++ book/src/contract-keywords/transient.md | 71 ++++ book/src/contract-keywords/ttl.md | 64 ++++ book/src/contract-keywords/typed-arrays.md | 107 ++++++ book/src/data-model/contract-moderation.md | 2 +- book/src/drive/document-sum-trees.md | 2 +- book/src/drive/indexes.md | 4 +- book/src/drive/sum-index-examples.md | 2 +- book/src/sdk/identity-keys.md | 4 +- .../serialization/document-serialization.md | 6 +- 38 files changed, 4373 insertions(+), 278 deletions(-) create mode 100644 book/src/contract-keywords/action-fees.md create mode 100644 book/src/contract-keywords/aggregates.md create mode 100644 book/src/contract-keywords/contested.md create mode 100644 book/src/contract-keywords/contract-config.md create mode 100644 book/src/contract-keywords/deletion.md create mode 100644 book/src/contract-keywords/distinct-from.md create mode 100644 book/src/contract-keywords/document-shape.md create mode 100644 book/src/contract-keywords/encrypted-for.md create mode 100644 book/src/contract-keywords/history.md create mode 100644 book/src/contract-keywords/index-only.md create mode 100644 book/src/contract-keywords/indexes.md create mode 100644 book/src/contract-keywords/max-bytes.md create mode 100644 book/src/contract-keywords/mutability.md create mode 100644 book/src/contract-keywords/owner-refers-to.md create mode 100644 book/src/contract-keywords/ownership-and-trading.md create mode 100644 book/src/contract-keywords/property-constraints.md create mode 100644 book/src/contract-keywords/property-schemas.md create mode 100644 book/src/contract-keywords/ranked.md create mode 100644 book/src/contract-keywords/refers-to-expressions.md create mode 100644 book/src/contract-keywords/refers-to-list-element.md create mode 100644 book/src/contract-keywords/refers-to-lookup.md create mode 100644 book/src/contract-keywords/refers-to.md create mode 100644 book/src/contract-keywords/required-since.md create mode 100644 book/src/contract-keywords/signing-keys.md create mode 100644 book/src/contract-keywords/system-properties.md create mode 100644 book/src/contract-keywords/time-range.md create mode 100644 book/src/contract-keywords/token-cost.md create mode 100644 book/src/contract-keywords/transient.md create mode 100644 book/src/contract-keywords/ttl.md create mode 100644 book/src/contract-keywords/typed-arrays.md diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 6a428598fcb..e7afab4aa5c 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -62,6 +62,40 @@ - [Identities](data-model/identities.md) - [Key Budgets and Expiry](data-model/key-limits.md) +# Contract Keywords + +- [Overview](contract-keywords.md) +- [Document Shape](contract-keywords/document-shape.md) +- [Property Schemas](contract-keywords/property-schemas.md) +- [Typed Arrays](contract-keywords/typed-arrays.md) +- [System Properties](contract-keywords/system-properties.md) +- [requiredSince](contract-keywords/required-since.md) +- [transient](contract-keywords/transient.md) +- [Mutability](contract-keywords/mutability.md) +- [Deletion](contract-keywords/deletion.md) +- [Time To Live (ttl)](contract-keywords/ttl.md) +- [Creation, Transfers and Trading](contract-keywords/ownership-and-trading.md) +- [History](contract-keywords/history.md) +- [Signing and Keys](contract-keywords/signing-keys.md) +- [References (refersTo)](contract-keywords/refers-to.md) + - [Lookups](contract-keywords/refers-to-lookup.md) + - [Expressions](contract-keywords/refers-to-expressions.md) + - [List Elements](contract-keywords/refers-to-list-element.md) + - [Writer and Creator References](contract-keywords/owner-refers-to.md) +- [distinctFrom](contract-keywords/distinct-from.md) +- [maxBytes](contract-keywords/max-bytes.md) +- [encryptedFor](contract-keywords/encrypted-for.md) +- [propertyConstraints](contract-keywords/property-constraints.md) +- [Token Costs (tokenCost)](contract-keywords/token-cost.md) +- [Action Fees (actionFees)](contract-keywords/action-fees.md) +- [Indexes (indices)](contract-keywords/indexes.md) + - [Contested Indexes](contract-keywords/contested.md) + - [Counts, Sums and Averages](contract-keywords/aggregates.md) + - [Ranked Indexes](contract-keywords/ranked.md) + - [Time-Range Indexes](contract-keywords/time-range.md) + - [Index-Only Types](contract-keywords/index-only.md) +- [Contract-Level Keys and config](contract-keywords/contract-config.md) + # Drive - [The GroveDB Structure](drive/grovedb-structure.md) @@ -119,5 +153,4 @@ # Appendix -- [Contract Keywords Reference](contract-keywords.md) - [API Reference](api-reference.md) diff --git a/book/src/contract-keywords.md b/book/src/contract-keywords.md index 7378e95df3f..88785d37953 100644 --- a/book/src/contract-keywords.md +++ b/book/src/contract-keywords.md @@ -1,8 +1,8 @@ -# Contract Keywords Reference +# Contract Keywords -This page lists every keyword a data contract's document type schema accepts. For each one it gives what the keyword does, where it may go, the protocol version it arrived in, what a contract update may do with it and, where one applies, the error a document that breaks it is refused with. The chapters linked from each entry explain the mechanics. +A data contract describes its documents with a JSON schema per document type, and Platform reads a set of keywords in those schemas: some from JSON Schema, most of its own. The chapters of this part take each keyword, or a small group that works together, and say what it does, how to write it, what is checked when a contract is registered and when a document is written, what a later contract update may do with it, and which errors it produces. The chapters of the Data Model and Drive parts explain the internals behind them and are linked from each chapter. -The list follows the document meta-schema of protocol version 14, `packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json`. Every document type schema is validated against it when a contract is registered or updated, and the parser (`try_from_schema`) checks the rules a JSON schema cannot express. When this page and the meta-schema disagree, the meta-schema is right and this page is out of date. +Everything here follows the document meta-schema of protocol version 14, `packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json`. Every document type schema is validated against it when a contract is registered or updated, and the parser (`try_from_schema`) checks the rules a JSON schema cannot express. When a chapter and the meta-schema disagree, the meta-schema is right and the chapter is out of date. ## Where keywords go @@ -34,282 +34,92 @@ A contract's `documentSchemas` maps each document type name to its schema. Keywo - **Index keywords** sit inside an entry of `indices` (`name`, `properties`). - **Property keywords** sit inside a property's schema (`type`, `maxLength`, `maxBytes`, `position`, `refersTo`). Most are ordinary JSON Schema; the rest are Platform's own. -## How to read the tables +The contract around the document types has keys of its own, and a `config` object: see [Contract-Level Keys and config](contract-keywords/contract-config.md). -- **From** is the first protocol version at which the keyword can be used. The document meta-schema changed at three versions: v1 at protocol version 12, v2 at 13 and v3 at 14. From 12 the meta-schema refuses a key it does not know; before 12 an unknown document type key was ignored. -- **Update** is what a contract update may do with the keyword on a document type that already exists. *Fixed* means adding, removing and changing it are all refused. A document type the update adds may use any keyword, as a new contract may. -- Codes in parentheses are consensus error codes (see [Error Codes](error-handling/error-codes.md)). A contract update that breaks an update rule is refused with `IncompatibleDocumentTypeSchemaError` (10246) when the schema comparison catches it, or `DocumentTypeUpdateError` (40212) when the document type comparison does. The tables say which. +## Reading the chapters -## Document type keywords +Each chapter opens with a short table for each keyword: -### Shape +- **Since** is the first protocol version at which the keyword can be used. +- **On update** is what a contract update may do with the keyword on a document type that already exists. *Fixed* means adding, removing and changing it are all refused. A document type the update adds may use any keyword, as a new contract may. +- **Errors** are consensus errors, written `ErrorName` (code). See [Error Codes](error-handling/error-codes.md) for the code ranges. -| Keyword | Value | What it does | From | Update | -|---|---|---|---|---| -| `type` | `"object"` | Required. A document is always an object. | 1 | Fixed | -| `$schema` | the meta-schema URL | The platform adds it when it reads the contract; a contract need not write it. | 1 | Fixed | -| `properties` | object | Required. The document's properties, 1 to 100 of them, each named with 1 to 64 letters, digits or underscores (from 14; earlier versions also admitted `-`). The system properties below may be listed here too. See [Property keywords](#property-keywords). | 1 | Properties may be added, optional or (with `requiredSince`) required; none may be removed (10246) | -| `additionalProperties` | `false` | Required, and only `false`: a document holds only the properties its type declares. | 1 | Fixed | -| `required` | array of names | The properties every document must hold. Listing a system timestamp or block height (`$createdAt`, `$updatedAtBlockHeight`) makes the platform record it on every document of the type; one that is not listed is never recorded. See [System properties](#system-properties). | 1 | May gain only a property the update adds, annotated with `requiredSince`; loses nothing (`DataContractInvalidRequiredFieldsUpdateError`, 10276) | -| `transient` | array of names | Properties validated on the transition but never stored. From 14 a replace drops them as a create does, each entry must name a top-level property, and no index, lookup key or `encryptedFor` may read one. See [Transient Properties](data-model/documents.md#transient-properties). | 1 | Fixed (10246); order and repeats are no change | -| `$comment`, `description` | string | Notes for readers; consensus ignores them. | 1 | Free | -| `minProperties`, `maxProperties` | integer | JSON Schema bounds on how many properties a document holds. | 12 | Fixed | -| `dependentRequired` | object | JSON Schema: a property that requires others when present. | 12 | May lose entries, not gain them (10246) | -| `$defs` | object | Definitions local to the document type, which `$ref` may point at. Contract-wide definitions live in the contract's `schemaDefs`. | 1 | Definitions may be added, not removed (10246) | +A contract update that breaks an update rule is refused with one of two errors, depending on which check catches it. `DocumentTypeUpdateError` (40212) comes from the comparison of the parsed document types, which judges flags such as `documentsMutable` by their meaning. `IncompatibleDocumentTypeSchemaError` (10246) comes from the comparison of the two JSON schemas, which judges property keywords such as `refersTo` or `maxLength` by their text. Top-level `required` and `indices` have errors of their own (10276 and 10217). Because the schema comparison reads text, an edit that changes how a keyword is written but not what it means, such as writing out a default or switching to the `documentsAverageable` shorthand, is refused with 10246. -### What may happen to a document +## Protocol versions -| Keyword | Value | What it does | From | Update | -|---|---|---|---|---| -| `documentsMutable` | boolean, default `true` | `false` makes documents unchangeable after creation: a replace is refused (`InvalidDocumentTransitionActionError`, 10404). | 1 | Fixed (40212) | -| `canBeDeleted` | boolean, default `true` | `false` stops a document's owner from deleting it (10404). Says nothing about moderators or a `ttl`. From 14 a type that keeps history may not allow deletion. | 1 | Fixed (40212), except that a type keeping history may turn it off | -| `immutable` | array of names | Top-level properties frozen at creation on an otherwise mutable type: a replace that changes, adds or removes one is refused (`DocumentImmutablePropertyChangedError`, 40128). Only with `documentsMutable: true`; no system, nested or transient names. See [Immutable Properties](data-model/documents.md#immutable-properties-on-mutable-document-types). | 14 | May gain entries, never lose one (40212) | -| `immutableAllowSetting` | array of names | The `immutable` properties a replace may still set once, while the stored document has no value for them. Every entry must also be in `immutable`. | 14 | May lose entries; may gain one only for a property made immutable in the same update (40212) | -| `transferable` | `0` never, `1` always | `1` lets an owner give a document to another identity with a transfer transition. A transfer of a type set to `0` is refused (10404). | 1 | Fixed (40212) | -| `tradeMode` | `0` none, `1` direct purchase | `1` lets an owner set a price and anyone buy the document at that price, with no approval. Refused price updates are 10404; a purchase of a document with no price is `DocumentNotForSaleError` (40108), one at the wrong price `DocumentIncorrectPurchasePriceError` (40109). | 1 | Fixed (40212) | -| `creationRestrictionMode` | `0` anyone, `1` contract owner only, `2` nobody | Who may create documents. `2` is for system contracts whose documents only the platform writes. A refused create is `DocumentCreationNotAllowedError` (10416). | 1 | Fixed (40212) | -| `ttl` | seconds, 3600 to 31536000 | The platform deletes each document this long after its `$createdAt`, whoever owns it. Storage is priced for the time the document lives and refunds nothing. Needs `$createdAt` in `required`; refused with `documentsKeepHistory`, `indexOnly` and a contested index. After expiry a document can only be deleted (`DocumentExpiredError`, 40140). See [Document Time To Live](data-model/document-ttl.md). | 14 | Fixed (40212) | -| `documentsKeepHistory` | boolean, default `false` | Drive keeps every revision of every document, not only the latest. | 1 | Fixed (40212) | -| `keepsTransferHistory` | boolean, default `false` | Records every transfer of a document of the type in the document history system contract. | 13 | Fixed (40212) | -| `keepsPurchaseHistory` | boolean, default `false` | Records every purchase in the document history system contract. | 13 | Fixed (40212) | -| `keepsPricingHistory` | boolean, default `false` | Records every price update in the document history system contract. | 13 | Fixed (40212) | +The document meta-schema has changed three times: -### Who may write - -| Keyword | Value | What it does | From | Update | -|---|---|---|---|---| -| `signatureSecurityLevelRequirement` | `1` critical, `2` high, `3` medium | The weakest identity key security level that may sign a transition on documents of the type. Default `2` (high). A key that is too weak is refused (`InvalidSignaturePublicKeySecurityLevelError`, 20004). See [Security Level](sdk/identity-keys.md#security-level). | 1 | Fixed (40212) | -| `requiresIdentityEncryptionBoundedKey` | `0` unique, `1` multiple, `2` multiple with a pointer to the latest | Lets identities add encryption keys bound to this document type, and says how they are kept: one key that cannot be replaced, several, or several with a pointer to the latest. A key may only be bound to a type that declares it. See [Contract Bounds](sdk/identity-keys.md#contract-bounds). | 1 | Fixed (40212) | -| `requiresIdentityDecryptionBoundedKey` | same as above | The same for decryption keys. | 1 | Fixed (40212) | -| `ownerRefersTo` | a `refersTo` declaration | A reference whose value is the writer (`$ownerId`) instead of a property: for example, the writer must own a document a lookup finds, or be an element of a list. Only on types whose documents can be neither transferred nor traded. Checked on create, and on a replace that changes what it reads. See [On the writer or the creator](data-model/documents.md#on-the-writer-or-the-creator-ownerrefersto-creatorrefersto). | 14 | Fixed (10246) | -| `creatorRefersTo` | a `refersTo` declaration | The same for the document's creator (`$creatorId`), for types whose documents can be transferred or traded. A type declares at most one of the two. | 14 | Fixed (10246) | -| `canBeDeletedByModerators` | boolean | Lets the contract's moderators delete documents of the type with a moderation transition, leaving a removal record. Needs `moderation` in the contract config; refused on types that keep history, are `indexOnly` or restrict creation. Makes the type count as deletable for references. See [Deleting Documents](data-model/contract-moderation.md#deleting-documents). | 14 | Fixed (40212) | -| `canBeDeletedByModeratorsFor` | seconds, 1 to 4294967295 | Limits the moderators' deletion to this long after the document's last change (`$updatedAt`). Later deletions are refused (`DocumentModerationWindowElapsedError`, 41116). Needs `canBeDeletedByModerators: true` and `$updatedAt` in `required`. | 14 | Fixed (40212) | - -### Rules over several properties - -| Keyword | Value | What it does | From | Update | -|---|---|---|---|---| -| `propertyConstraints` | object of named rules | Rules every created or replaced document must meet, where JSON Schema bounds one property at a time: comparisons and arithmetic over integer properties, string and identifier comparisons, value sets, presence tests, combined with `anyOf`, `allOf` and `not`. At most 16 rules of at most 32 nodes each. A broken rule refuses the document (`DocumentPropertyConstraintViolatedError`, 10422). See [the operators](#propertyconstraints-operators) and [Property Constraints](data-model/documents.md#property-constraints-propertyconstraints). | 14 | Fixed (10246) | - -### Costs - -| Keyword | Value | What it does | From | Update | -|---|---|---|---|---| -| `tokenCost` | object keyed by action | A token payment for an action on a document: `create`, `replace`, `delete`, `transfer`, `update_price` or `purchase`. Each takes the keys below. A transition that leaves out the payment a required cost asks for is refused (`RequiredTokenPaymentInfoNotSetError`, 40115). See [Fee System Overview](fees/overview.md#gas-paid-by-the-contract-owner). | 9 | Fixed | -| `tokenCost..tokenPosition` | integer | Required. Which token of the contract (`contractId` absent) or of the named contract is charged. | 9 | | -| `tokenCost..amount` | integer, at least 1 | Required. How many tokens the action costs. | 9 | | -| `tokenCost..contractId` | identifier | The contract whose token is charged, when it is not this one. | 9 | | -| `tokenCost..effect` | `0` transfer to the contract owner (default), `1` burn | What happens to the tokens paid. Burning is only allowed on the contract's own token (10261). | 9 | | -| `tokenCost..gasFeesPaidBy` | `0` document owner (default), `1` contract owner, `2` prefer contract owner | Who the contract owner offers to have pay the gas of a token-paid action. A transition that insists on a payer the type does not offer is refused (`GasFeesPaidByNotAllowedError`, 40129). Acted on from 14. | 14 | | -| `tokenCost..optional` | boolean, default `false` | `true` lets a transition skip the token and pay the gas in credits instead. See [Optional token costs](fees/overview.md#optional-token-costs). | 14 | | -| `actionFees` | object keyed by action | A fixed fee in credits, on top of the gas, for `create`, `replace`, `delete`, `transfer`, `update_price` or `purchase`, split between the contract owner's pot and the moderators' pot. The transition must state the fee it agrees to (`DocumentActionFeeAgreementNotSetError` 40132, `DocumentActionFeeAgreementMismatchError` 40133, `DocumentActionFeeMultiplierNotToleratedError` 40134). See [Document action fees](fees/overview.md#document-action-fees). | 14 | Fixed (40212) | -| `actionFees.pricing` | `"feeMultiplier"` (default) or `"fixed"` | Whether the amounts scale with the epoch's fee multiplier or are charged as written. | 14 | | -| `actionFees..owner` | credits | Added to the contract owner's pot, which the owner claims. | 14 | | -| `actionFees..moderators` | credits | Added to the moderators' pot, shared by the moderation team. Needs `moderation` in the contract config (10902). | 14 | | - -### Storage layout and aggregates - -| Keyword | Value | What it does | From | Update | -|---|---|---|---|---| -| `indices` | array of 1 to 10 indexes | The indexes documents are queried by, and the uniqueness rules they enforce. See [Index keywords](#index-keywords). | 1 | Fixed from 14: no index added, removed or changed (10217); reordering is no change | -| `documentsCountable` | boolean | Keeps a count of the type's documents in the primary key tree, so the total is read in one step. See [Document Count Trees](drive/document-count-trees.md#primary-key-tree-flags). | 12 | Fixed (40212) | -| `rangeCountable` | boolean | A provable count tree on the primary key, for counts over id ranges. Implies `documentsCountable`. | 12 | Fixed (40212) | -| `documentsSummable` | property name | Keeps the sum of one required integer property over all the type's documents. See [Document Sum Trees](drive/document-sum-trees.md#primary-key-tree-flags). | 12 | Fixed (40212) | -| `rangeSummable` | boolean | A provable sum tree on the primary key. Needs `documentsSummable` or `documentsAverageable`. Rarely useful; the index flag of the same name is what most contracts want. | 12 | Fixed (40212) | -| `documentsAverageable` | property name | Shorthand for `documentsCountable: true` plus `documentsSummable` on the named property. | 12 | Fixed (40212) | -| `rangeAverageable` | boolean | Shorthand for `rangeCountable` plus `rangeSummable`. Needs `documentsAverageable`. | 12 | Fixed (40212) | -| `indexOnly` | boolean | Documents are never written to primary storage: the index entries are the rows. Needs every property required and indexed, `$ownerId` in an index, `documentsMutable: false`, no transfers, trading, history or transient properties. See [Index-Only Document Types](drive/index-only-document-types.md). | 14 | Fixed (40212) | -| `entryPayload` | array of 1 to 16 names | On an `indexOnly` type, the properties stored in each entry's value instead of in a key. | 14 | Fixed (40212) | - -## Property keywords - -### JSON Schema keywords - -A property's schema is JSON Schema (draft 2020-12), limited to the keywords below. Every document is validated against it on create and replace, and a value it refuses is a `JsonSchemaError`. Unless a row says otherwise, a contract update that breaks a property's update rule is refused with `IncompatibleDocumentTypeSchemaError` (10246). - -| Keyword | Where | What it does | From | Update | -|---|---|---|---|---| -| `type` | every property | `string`, `integer`, `number`, `boolean`, `object` or `array`. An array is a byte array (`byteArray: true`) or, from 14, a typed array (`items`). | 1 | Fixed | -| `minLength`, `maxLength` | strings | Length in characters. A string with `pattern` or `format` must declare `maxLength` of at most 50000. | 1 | May be loosened: `maxLength` raised, `minLength` lowered, either removed | -| `pattern` | strings | A regular expression the value must match, in the syntax of Rust's `regex` crate (`IncompatibleRe2PatternError`, 10202). | 1 | May be removed, not added or changed | -| `format` | strings | A JSON Schema format such as `date-time` or `uri`. | 1 | May be removed, not added or changed | -| `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` | numbers | Numeric bounds. On an integer they also decide how many bytes it is stored in, when the contract sets `sizedIntegerTypes`. | 1 | May be loosened or removed, unless that changes an integer's stored width or sign (40212); `multipleOf` is fixed | -| `enum` | any | The values allowed, at least one, none repeated. | 1 | Values may be added, not removed; the keyword may be removed, not added. A value that widens an integer's stored width is refused (40212) | -| `const` | any | The one value allowed. | 1 | May be removed, not added or changed | -| `minItems`, `maxItems` | arrays | On a byte array, the length in bytes; on a typed array, the number of elements (`maxItems` required there, at most 1024). | 1 | May be loosened; a byte array may not switch between fixed and variable length (40212) | -| `uniqueItems` | arrays | On a typed array, no element may repeat. On a plain byte array, no byte may repeat; refused on an identifier. | 1 | May be removed, not added | -| `contains` | arrays | JSON Schema `contains`. | 1 | Fixed | -| `properties`, `required`, `additionalProperties` | objects | A nested object's own properties (each needs a `position`), which of them are required, and `additionalProperties: false`, which is required. | 1 | Nested properties may be added, not removed; a nested `required` and `additionalProperties` are fixed | -| `minProperties`, `maxProperties`, `dependentRequired` | objects | JSON Schema bounds on a nested object. | 1 | `dependentRequired` may lose entries, not gain them; the others are fixed | -| `$ref` | any | Points at a definition in the contract's `schemaDefs` (`#/$defs/...`). Only local references starting with `#`. | 1 | Fixed | -| `$id`, `$comment`, `description`, `examples` | any | Annotations; consensus ignores them. | 1 | Free, except that `$id` may only be added | - -### Platform keywords - -| Keyword | Where | What it does | From | Update | -|---|---|---|---|---| -| `position` | every property | The property's place in the stored document, which is encoded by position, not by name. Top-level positions, and those inside each object, run 0, 1, 2 with no gap (`MissingPositionsInDocumentTypePropertiesError`, 10411). See [Document Serialization](serialization/document-serialization.md). | 1 | Fixed | -| `byteArray` | arrays | `true` makes the array a string of bytes, stored raw. | 1 | Fixed | -| `contentMediaType` | byte arrays | `"application/x.dash.dpp.identifier"` makes a 32-byte array an identifier: shown in base58, converted from strings, and the only kind of property `refersTo` and `distinctFrom` accept. It must come with `byteArray: true`, `minItems: 32` and `maxItems: 32`. | 1 | Fixed | -| `items` | arrays | Makes the array a typed array: a list whose elements are all one scalar type (integer, number, string, boolean, byte array or identifier), stored inline. Elements take `enum`, bounds, `maxBytes`, `distinctFrom` and `refersTo`, but no `position` or `const`. See [Typed Arrays](data-model/documents.md#typed-arrays). | 14 | `items` may be neither added nor removed; the keywords inside it follow the rules above, and a change to how elements are stored is refused (40212) | -| `requiredSince` | top-level required properties | The contract version from which a property is required. It lets an update add a new required property: documents written under an earlier version may leave it out. On an update it must equal the version the update creates; on a new contract it may only be 1 (`DataContractInvalidRequiredFieldsUpdateError`, 10276). See [Adding Required Fields](data-model/data-contracts.md#evolving-a-contract-adding-required-fields). | 14 | Only on a property the update adds; an existing annotation is fixed | -| `maxBytes` | strings, and string elements | The most bytes the value may take in UTF-8, 1 to 65535, where `maxLength` counts characters of up to four bytes each. A longer value is refused (`DocumentPropertyMaxBytesExceededError`, 10421). See [Byte Caps on Strings](data-model/documents.md#byte-caps-on-strings-maxbytes). | 14 | May be raised or removed; not added or lowered (10246) | -| `distinctFrom` | identifiers, and identifier elements | The value must differ from another identifier property of the document, or from `$ownerId`. An equal pair is refused (`DocumentPropertyNotDistinctError`, 10419), and so is a transfer or purchase to the identity a `$ownerId` rule names. See [Distinct Identifier Properties](data-model/documents.md#distinct-identifier-properties). | 14 | Fixed (10246) | -| `encryptedFor` | byte arrays that are not identifiers | Declares how the property's ciphertext was made: `recipient` (an identifier property or `$ownerId`), `recipientKey` and `senderKey` (key id properties) and `scheme` (`"ecdh-secp256k1-aes256-cbc"`). All four are required. Consensus checks only the length shape (`InvalidEncryptedPropertyShapeError`, 10420). See [Encrypted Properties](data-model/documents.md#encrypted-properties-encryptedfor). | 14 | Fixed (10246) | -| `refersTo` | identifiers, identifier elements, and key id integers | What the value points at, checked when the document is written: the target must exist, and may have to meet further requirements. See [refersTo](#refersto) below. | 14 | Fixed (10246) | - -## `refersTo` - -A reference is checked when a document is created or replaced. Nothing checks it again when the target changes later: a `permanentDocument` target can never be deleted, and a `deletableDocument` reference is checked again on the referring document's next replace. A document carries at most 256 references, counting one per property, one for `ownerRefersTo` or `creatorRefersTo`, and `maxItems` per typed array of references. See [Document References](data-model/documents.md#document-references-refersto). - -### Targets - -| `type` | The value is | Keys it takes | -|---|---|---| -| `identity` | the id of an existing identity | none | -| `contract` | the id of an existing data contract | `contractRequirements` | -| `token` | the id of an existing token | none | -| `permanentDocument` | the id of a document of a type that can never be deleted, or with `lookup` a part of a unique index key that finds one | `documentType`, `contractId`, `propertyAgreement`, `lookup` | -| `deletableDocument` | the same for a type that can be deleted. Re-checked on every replace, so a reference to a deleted document must be repointed or cleared. | `documentType`, `contractId`, `propertyAgreement`, `lookup` | -| `identityPublicKey` | an identity key that exists and is not disabled. On an identifier, the identity, with `keyIdProperty` naming the key id property; on an integer from 0 to 4294967295, the key id, with `identityProperty` naming whose key it is. | `keyIdProperty` or `identityProperty`, `keyRequirements` | -| `listElement` | one of the identifiers a list of another document holds | `documentType`, `contractId`, `propertyAgreement` (with one `$id` pair), `inList` | - -Instead of a `type`, a declaration may hold only `anyOf` (at least one operand holds) or only `allOf` (every operand holds). An operand is an `identity`, a `permanentDocument`, a `listElement`, a `deletableDocument` with a `lookup`, or an expression of the other combinator. A list holds 2 to 4 operands and expressions nest at most 4 deep. See [Reference expressions](data-model/documents.md#reference-expressions-anyof-allof). - -### Keys - -| Key | For | What it does | -|---|---|---| -| `documentType` | document and list references | The referenced document type. For `permanentDocument` and `listElement` it must forbid deletion; for `deletableDocument` it must allow it. | -| `contractId` | document and list references | The contract holding `documentType`, as base58 or 32 bytes. Absent means this contract. | -| `propertyAgreement` | document and list references | Up to 10 pairs `{ "referring property": "referenced property" }` that must be equal when the document is written. The referring side may be `$ownerId`, which makes the pair a write gate; the referenced side may be `$ownerId`, `$creatorId` or `$id`. | -| `lookup` | `permanentDocument`, `deletableDocument` | `{ "index": ..., "keys": {...} }`: the value is part of a key, and the referenced document is the one a unique index of `documentType` finds. Each key maps an index property to a referring property path, `"$ownerId"` or `"."` (the value itself, exactly once). See [Resolved through a unique index](data-model/documents.md#resolved-through-a-unique-index-lookup). | -| `inList` | `listElement` | The typed array of identifiers on the referenced document the value must be in. The list must never change once written. See [An element of a list](data-model/documents.md#an-element-of-a-list-listelement). | -| `keyIdProperty` | `identityPublicKey` on an identifier | The sibling integer property holding the key id. | -| `identityProperty` | `identityPublicKey` on a key id | Whose key it is: `"$ownerId"`, `"$creatorId"` or an identifier property path. | -| `keyRequirements` | `identityPublicKey` | What the key must be: `purpose` (`authentication`, `encryption`, `decryption`, `transfer`, `voting` or `owner`) and `boundTo` (a document type of this contract the key must be bound to). | -| `contractRequirements` | `contract` | What the contract must be: `moderation` (`"elected"`, or `"electionOpen"` once its election delay has passed), `minimumAgeSeconds`, `minimumSecondsSinceUpdate`, `owner` (`"self"`, the writer, or `"other"`), `readonly: true`, `keepsHistory: true`, `ownerProtected` (boolean). See [Elected Moderation](data-model/contract-moderation.md#elected-moderation). | - -### Errors - -| Error | Code | When | -|---|---|---| -| `ReferencedEntityNotFoundError` | 40120 | The target does not exist, a lookup finds nothing, or a value is not in the list. | -| `ReferencedDocumentTypeNotFoundError` | 40121 | At registration: `documentType` does not exist. | -| `ReferencedDocumentTypeDeletableError` | 40122 | At registration: a `permanentDocument` or `listElement` reference names a type that can be deleted. | -| `ReferencedIdentityKeyNotFoundError` | 40123 | The key does not exist. | -| `ReferencedIdentityKeyDisabledError` | 40124 | The key is disabled. | -| `ReferencedKeyIdPropertyInvalidError` | 40125 | A key id is set without the identity it belongs to, or a key reference is declared twice. | -| `ReferencedDocumentPropertyAgreementInvalidError` | 40126 | At registration: a `propertyAgreement` pair names a missing property or mismatched kinds. | -| `ReferencedDocumentPropertyMismatchError` | 40127 | A `propertyAgreement` pair does not hold. | -| `ReferencedDocumentTypeNotDeletableError` | 40131 | At registration: a `deletableDocument` reference names a type that forbids deletion. | -| `ReferencedContractRequirementNotMetError` | 40135 | A `contractRequirements` entry is not met. | -| `ReferencedIdentityKeyRequirementNotMetError` | 40136 | A `keyRequirements` entry is not met. | -| `ReferencedDocumentLookupInvalidError` | 40137 | At registration: a lookup into another contract cannot resolve. | -| `ReferencedDocumentListInvalidError` | 40138 | At registration: an `inList` list of another contract does not qualify. | - -## Index keywords - -An index entry sits in `indices`. A document type has at most 10 indexes of at most 10 properties each. See [Indexes](drive/indexes.md). - -| Keyword | Value | What it does | From | -|---|---|---|---| -| `name` | string, 1 to 32 characters | Required. The index's name, unique within the type. Queries and errors name it. | 1 | -| `properties` | array of `{ "": "asc" }` | The indexed properties, in order; a query uses the index through a prefix of them. A nested property is named by its dotted path (`records.identity`), and system properties such as `$ownerId` and `$createdAt` may be indexed. An indexed string needs `maxLength` of at most 63 and a byte array `maxItems` of at most 255 (`InvalidIndexedPropertyConstraintError`, 10205); a typed array cannot be indexed (`InvalidIndexPropertyTypeError`, 10206). | 1 | -| `unique` | boolean | No two documents may hold the same values for all the properties (`DuplicateUniqueIndexError`, 40105). A document with a null among them is not held to it. | 1 | -| `nullSearchable` | boolean, default `true` | `false` leaves out of the index a document whose indexed properties are all null. | 1 | -| `contested` | object | Makes a unique index a contested resource: a document whose values match opens or joins a contest that masternodes vote on, instead of being refused as a duplicate. Takes `resolution` (required: `0` masternode vote, `1` masternode vote without a lock choice, from 14), `fieldMatches` (a list of `{ "field", "regexPattern" }`, the values that are contested) and `description`. Needs `unique: true` on a type whose documents cannot be replaced (`ContestedUniqueIndexOnMutableDocumentTypeError`, 10248). See [Contested Documents](data-model/contested-documents.md). | 1 | -| `countable` | `"notCountable"`, `"countable"`, `"countableAllowingOffset"`, or a boolean | Keeps a document count per indexed value, so counts are read without walking the documents. See [Document Count Trees](drive/document-count-trees.md#per-index-countable-flag). | 12 | -| `rangeCountable` | boolean | Counts over ranges of the indexed value in logarithmic time, with proofs. Implies `countable`. | 12 | -| `summable` | property name | Keeps the sum of a required integer property per indexed value. See [Document Sum Trees](drive/document-sum-trees.md#per-index-summable-flag). | 12 | -| `rangeSummable` | boolean | Sums over ranges of the indexed value. Needs `summable` or `averageable`. | 12 | -| `averageable` | property name | Shorthand for `countable: "countable"` plus `summable` on the named property. | 12 | -| `rangeAverageable` | boolean | Shorthand for `rangeCountable` plus `rangeSummable`. Needs `averageable`. | 12 | -| `rankedCountable` | boolean, or `{ "at": ... }` | Orders the indexed values by how many documents each has, for "top K" queries with proofs. `at` names the index level, or levels, that carry the ranking. Needs `rangeCountable`. See [Document Ranked Trees](drive/document-ranked-trees.md#contract-grammar). | 14 | -| `rankedSummable` | boolean | Orders the indexed values by the sum of the `summable` property. Needs `rangeSummable`. | 14 | -| `rankedAverageable` | boolean | Orders the indexed values by the average of the `averageable` property. Needs `rangeAverageable`, or `rangeCountable` and `rangeSummable`. | 14 | -| `timeRange` | `{ "on", "range", "step", "phase", "ttl" }` | Buckets the index's first property, a system timestamp, into time windows of `range` seconds starting every `step` seconds, offset by `phase`, for trending queries. `ttl` (at most one week) expires entries past their window. See [Time-Range Index TTL](drive/time-range-ttl.md#grammar-and-validation). | 14 | -| `terminal` | property name or list of names | On an `indexOnly` type, the property or properties whose values key each entry, in place of the document id. Default `$ownerId`. | 14 | -| `preallocated` | boolean | On an `indexOnly` type bound to a same-contract `permanentDocument` reference, creates the index's trees when the referenced document is created, so every entry costs the same. See [Preallocated index paths](drive/index-only-document-types.md#preallocated-index-paths). | 14 | -| `skipIfAbsent` | boolean | On an `indexOnly` type, a document without the index's first property writes no entry. See [Conditional participation](drive/index-only-document-types.md#conditional-participation-skipifabsent). | 14 | - -From protocol version 14 a contract update may not add, remove or change an index of an existing document type (`DataContractInvalidIndexDefinitionUpdateError`, 10217). Indexes are compared by name, so reordering `indices` is no change and renaming one is a removal plus an addition. A document type the update adds may declare any index. - -## `propertyConstraints` operators - -A rule is an object with one key. Paths are dotted property paths (`"meta.total"`); a bare string is always a path and a bare number always a value. - -| Operator | Form | Holds when | -|---|---|---| -| `equal`, `notEqual` | `[a, b]` | The two integer expressions are equal (or not). Also compares a string property with `{ "const": "..." }` or another string property, and an identifier property with a base58 `const`, another identifier property or `$ownerId`. | -| `lessThan`, `lessThanOrEqual`, `greaterThan`, `greaterThanOrEqual` | `[a, b]` | The comparison of the two integer expressions holds. | -| `in` | `[a, [v1, v2, ...]]` | `a` takes one of two or more distinct values: integers, or strings for a string or identifier property. | -| `present` | `"path"` | The document holds the property, with a value other than null. | -| `absent` | `"path"` | The document leaves the property out or sets it to null. | -| `anyOf` | `[c1, c2, ...]` | At least one condition holds, checked in order. | -| `allOf` | `[c1, c2, ...]` | Every condition holds, checked in order. | -| `not` | `c` | The condition does not hold. | - -An integer expression is a number, the path of an integer or boolean property (a property left out reads as 0, a boolean as 1 or 0), or an object with one key: - -| Operator | Form | Value | +| Meta-schema | Protocol versions | What it added | |---|---|---| -| `add`, `multiply` | `[a, b, ...]` | The sum or product of two or more operands. | -| `subtract`, `divide`, `modulo`, `power` | `[a, b]` | The result of the operation. `divide` and `modulo` are Euclidean. | -| `ifAbsent` | `["path", default]` | The property's value, or `default` when the document leaves it out. | -| `const` | `"string"` | A string or identifier constant, only as a side of `equal` or `notEqual`. | - -Arithmetic is exact over 128-bit integers: an overflow, a division by zero or a negative exponent breaks the rule. - -## System properties - -The platform manages these. A document type lists them to use them: in `required` to have the platform record them, in `indices` to query by them, and in `properties` where it wants to say more about them. - -| Property | What it holds | Recorded | -|---|---|---| -| `$id` | The document's id. | Always | -| `$ownerId` | The identity that owns the document: its creator, then whoever it was transferred or sold to. | Always | -| `$revision` | The document's revision: 1 at creation, raised by every replace, transfer, price update and purchase. | On types whose documents can be replaced, transferred or traded | -| `$createdAt`, `$updatedAt`, `$transferredAt` | Block times, in milliseconds, of the creation, the last replace or price update, and the last transfer. | When listed in `required` | -| `$createdAtBlockHeight`, `$updatedAtBlockHeight`, `$transferredAtBlockHeight` | Platform block heights of the same events. | When listed in `required` | -| `$createdAtCoreBlockHeight`, `$updatedAtCoreBlockHeight`, `$transferredAtCoreBlockHeight` | Core chain block heights of the same events. | When listed in `required` | -| `$creatorId` | The identity that created the document, which a transfer or purchase never changes. Not declared in `properties`: references, `creatorRefersTo` and indexes read it. | On transferable or tradeable types of format-1 contracts, from protocol version 10 | - -## Contract-level keys - -The document types sit in a contract, which has keys of its own. See [Data Contracts](data-model/data-contracts.md#what-v1-added). - -| Key | What it is | From | -|---|---|---| -| `$formatVersion` | The contract's serialization format, `"0"` or `"1"`. Format 1 is the default from protocol version 9 and carries every key below. | 1 | -| `id`, `ownerId`, `version` | The contract's id, the identity that owns it, and its version, which each update raises by one. | 1 | -| `config` | Contract-wide settings, below. | 1 | -| `documentSchemas` | The document types, by name. | 1 | -| `schemaDefs` | Definitions every document type may `$ref`. An update may add definitions, not remove them (`IncompatibleDataContractSchemaError`, 10213). | 1 | -| `groups` | Groups of identities that act together, each member with a voting power, where a token action needs a group's approval. | 9 | -| `tokens` | The contract's tokens, by position. | 9 | -| `keywords` | Up to 50 search keywords, 3 to 50 characters each, no repeats, for the keyword search contract. | 9 | -| `description` | 3 to 100 characters, for the keyword search contract. | 9 | -| `createdAt`, `updatedAt`, and their block heights and epochs | Set by the platform. | 9 | - -The `config` keys: - -| Key | Default | What it does | From | Update | -|---|---|---|---|---| -| `canBeDeleted` | `false` | Whether the contract itself may ever be deleted. No transition deletes a contract today. | 1 | Fixed (40002) | -| `readonly` | `false` | `true` means the contract can never be updated (`DataContractIsReadonlyError`, 40001). | 1 | Cannot be set by an update (40002) | -| `keepsHistory` | `false` | Drive keeps every version of the contract. | 1 | Fixed (40002) | -| `documentsKeepHistoryContractDefault` | `false` | The `documentsKeepHistory` of a document type that does not say. | 1 | Fixed (40002) | -| `documentsMutableContractDefault` | `true` | The `documentsMutable` of a document type that does not say. | 1 | Fixed (40002) | -| `documentsCanBeDeletedContractDefault` | `true` | The `canBeDeleted` of a document type that does not say. | 1 | Fixed (40002) | -| `requiresIdentityEncryptionBoundedKey`, `requiresIdentityDecryptionBoundedKey` | absent | The document type keywords of the same names, for keys bound to the whole contract. | 1 | Fixed (40002) | -| `sizedIntegerTypes` | `true` | Stores each integer in the smallest width its `minimum` and `maximum` allow, instead of 8 bytes. | 9 | May be turned on, not off (40002) | -| `moderation` | absent | Declares which of a banlist, a suspension list and a warning list the contract keeps, and who moderates: the owner, appointed identities or an elected team. Needed by `canBeDeletedByModerators` and the moderators' share of `actionFees`. See [Contract Moderation](data-model/contract-moderation.md). | 14 | The lists kept and an elected team are fixed; appointed moderators may change (40002) | +| v0 | 1 to 11 | The original keywords. A document type key the meta-schema did not know was ignored. | +| v1 | 12 | Unknown document type keys are refused. The count, sum and average keywords. | +| v2 | 13 | `keepsTransferHistory`, `keepsPurchaseHistory`, `keepsPricingHistory`. | +| v3 | 14 | References, typed arrays, `requiredSince`, `immutable`, `ttl`, `propertyConstraints`, `actionFees`, moderation deletion, ranked and time-range indexes, index-only types, and the rest marked 14 in these chapters. | + +Most keywords of v0 took effect at protocol version 1. The exceptions are `tokenCost` (9) and the index keyword `countable` (12). + +## Every keyword + +### Document type keywords + +| Keyword | Chapter | +|---|---| +| `$comment`, `$defs`, `$schema`, `additionalProperties`, `dependentRequired`, `description`, `maxProperties`, `minProperties`, `properties`, `required`, `type` | [Document Shape](contract-keywords/document-shape.md) | +| `actionFees` | [Action Fees](contract-keywords/action-fees.md) | +| `canBeDeleted`, `canBeDeletedByModerators`, `canBeDeletedByModeratorsFor` | [Deletion](contract-keywords/deletion.md) | +| `creationRestrictionMode`, `tradeMode`, `transferable` | [Creation, Transfers and Trading](contract-keywords/ownership-and-trading.md) | +| `creatorRefersTo`, `ownerRefersTo` | [Writer and Creator References](contract-keywords/owner-refers-to.md) | +| `documentsAverageable`, `documentsCountable`, `documentsSummable`, `rangeAverageable`, `rangeCountable`, `rangeSummable` | [Counts, Sums and Averages](contract-keywords/aggregates.md) | +| `documentsKeepHistory`, `keepsPricingHistory`, `keepsPurchaseHistory`, `keepsTransferHistory` | [History](contract-keywords/history.md) | +| `documentsMutable`, `immutable`, `immutableAllowSetting` | [Mutability](contract-keywords/mutability.md) | +| `entryPayload`, `indexOnly` | [Index-Only Types](contract-keywords/index-only.md) | +| `indices` | [Indexes](contract-keywords/indexes.md) | +| `propertyConstraints` | [propertyConstraints](contract-keywords/property-constraints.md) | +| `requiresIdentityDecryptionBoundedKey`, `requiresIdentityEncryptionBoundedKey`, `signatureSecurityLevelRequirement` | [Signing and Keys](contract-keywords/signing-keys.md) | +| `tokenCost` | [Token Costs](contract-keywords/token-cost.md) | +| `transient` | [transient](contract-keywords/transient.md) | +| `ttl` | [Time To Live](contract-keywords/ttl.md) | + +### Property keywords + +| Keyword | Chapter | +|---|---| +| `$comment`, `$id`, `$ref`, `additionalProperties`, `byteArray`, `const`, `contains`, `contentMediaType`, `dependentRequired`, `description`, `enum`, `examples`, `exclusiveMaximum`, `exclusiveMinimum`, `format`, `maxItems`, `maxLength`, `maxProperties`, `maximum`, `minItems`, `minLength`, `minProperties`, `minimum`, `multipleOf`, `pattern`, `position`, `properties`, `required`, `type`, `uniqueItems` | [Property Schemas](contract-keywords/property-schemas.md) | +| `distinctFrom` | [distinctFrom](contract-keywords/distinct-from.md) | +| `encryptedFor` | [encryptedFor](contract-keywords/encrypted-for.md) | +| `items` | [Typed Arrays](contract-keywords/typed-arrays.md) | +| `maxBytes` | [maxBytes](contract-keywords/max-bytes.md) | +| `refersTo` | [References](contract-keywords/refers-to.md) | +| `requiredSince` | [requiredSince](contract-keywords/required-since.md) | + +### Inside `refersTo` + +| Keyword | Chapter | +|---|---| +| `contractId`, `contractRequirements`, `documentType`, `identityProperty`, `keyIdProperty`, `keyRequirements`, `propertyAgreement`, `type` | [References](contract-keywords/refers-to.md) | +| `lookup` | [Lookups](contract-keywords/refers-to-lookup.md) | +| `anyOf`, `allOf` | [Expressions](contract-keywords/refers-to-expressions.md) | +| `inList`, `listElement` | [List Elements](contract-keywords/refers-to-list-element.md) | + +### Index keywords + +| Keyword | Chapter | +|---|---| +| `name`, `nullSearchable`, `properties`, `unique` | [Indexes](contract-keywords/indexes.md) | +| `contested` | [Contested Indexes](contract-keywords/contested.md) | +| `averageable`, `countable`, `rangeAverageable`, `rangeCountable`, `rangeSummable`, `summable` | [Counts, Sums and Averages](contract-keywords/aggregates.md) | +| `rankedAverageable`, `rankedCountable`, `rankedSummable` | [Ranked Indexes](contract-keywords/ranked.md) | +| `timeRange` | [Time-Range Indexes](contract-keywords/time-range.md) | +| `preallocated`, `skipIfAbsent`, `terminal` | [Index-Only Types](contract-keywords/index-only.md) | + +### System properties + +`$id`, `$ownerId`, `$revision`, `$creatorId`, and the creation, update and transfer times and block heights: see [System Properties](contract-keywords/system-properties.md). ## Limits -The first three come from the meta-schema, the rest from protocol version 14's `SystemLimits`. A contract over a limit is refused at registration. +The first three limits come from the meta-schema, the rest from protocol version 14's `SystemLimits`. A contract over a limit is refused at registration. | Limit | Value | Applies to | |---|---|---| diff --git a/book/src/contract-keywords/action-fees.md b/book/src/contract-keywords/action-fees.md new file mode 100644 index 00000000000..e5e4087b085 --- /dev/null +++ b/book/src/contract-keywords/action-fees.md @@ -0,0 +1,102 @@ +# Action Fees (actionFees) + +`actionFees` charges a fixed fee in credits, on top of the gas, for an action on a document of the type. Each fee has two parts: one for the contract owner and one for the contract's moderators, each collected in a pot that its recipients claim. Reach for it when an app wants to earn from the documents written under it, or to pay the people who moderate it. The transition that pays must state the fee it agrees to, so a fee can never surprise a signer. + +| | | +|---|---| +| **Where** | Document type | +| **Value** | An object with an optional `pricing`, and one or more of the actions `create`, `replace`, `delete`, `transfer`, `update_price`, `purchase`, each an object with `owner` and/or `moderators` (below) | +| **Default** | Absent: no action charges a fee | +| **Since** | protocol version 14 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212): an update may not add, change or remove the fees of an existing document type, nor switch their pricing. A document type the update adds may declare its own | +| **Errors** | On a document transition: `DocumentActionFeeAgreementNotSetError` (40132), `DocumentActionFeeAgreementMismatchError` (40133), `DocumentActionFeeMultiplierNotToleratedError` (40134), `DocumentActionFeeModeratorsShareMismatchError` (40139). At registration: `DocumentActionFeesWithoutModerationError` (10902), `JsonSchemaError` (10101), `InvalidContractStructure` (10231) | + +The keys: + +| Key | Value | Default | Meaning | +|---|---|---|---| +| `pricing` | `"feeMultiplier"` or `"fixed"` | `"feeMultiplier"` | Whether the amounts follow the network's fee multiplier or are charged as written | +| `.owner` | credits, 0 to 9223372036854775807 | 0 | Added to the contract's owner pot, which the contract owner claims | +| `.moderators` | credits, 0 to 9223372036854775807 | 0 | Added to the contract's moderators pot, which the moderation team shares. Needs `moderation` in the contract config | + +Amounts are in credits: 1 Dash is 100,000,000,000 credits (1000 credits per duff). + +## Example + +```json +"post": { + "type": "object", + "actionFees": { + "pricing": "feeMultiplier", + "create": { "moderators": 100000000, "owner": 10000000 } + }, + "properties": { + "text": { "type": "string", "minLength": 1, "maxLength": 280, "maxBytes": 560, "position": 0 } + }, + "required": ["text"], + "additionalProperties": false +} +``` + +In a contract that declares moderation, creating a post costs an extra 0.001 Dash for the moderation team and 0.0001 Dash for the contract owner, at a fee multiplier of 1. Replacing, deleting and every other action cost only their gas. + +## How it works + +- **What is charged.** With `fixed` pricing, the declared amounts. With `feeMultiplier`, the declared amounts scaled by the fee multiplier of the epoch the action executes in (`declared * multiplier_permille / 1000`, rounded down), so a fee follows the network's fees. A scaled amount is held at the maximum number of credits instead of overflowing; such a fee refuses the action for an insufficient balance. +- **Who pays.** Whoever pays the gas pays the fee: the signer, or the contract owner when they pay the gas of a token-paid action (see [Token Costs](token-cost.md#who-pays-the-gas-gasfeespaidby)). The contract owner never pays the `owner` part, which would only travel through their pot back to them: a contract owner who pays, as the signer or as the gas sponsor, pays the `moderators` part only. A contract that sponsors gas should price that in: a sponsor's balance must cover the gas and those fees, or the transition falls back to the signer or is refused, as the token cost's rules say. +- **Only executed actions pay.** A transition that fails, at any stage, owes no fee. +- **Where it goes.** One removal from the payer's balance, and one addition to each pot the fee has a part for. The fee is not part of the gas: the fee pools and block proposers get none of it. + +## The agreement: `$actionFeeAgreement` + +The contract is read when the transition executes, not when it was signed. So every transition on an action that charges a fee carries an action fee agreement in its base, naming the fee its signer saw (version 2 of the document base transition, the default from protocol version 14): + +```json +"$actionFeeAgreement": { + "$formatVersion": "0", + "owner": 10000000, + "moderators": 100000000, + "feeMultiplier": { "knownPermille": 1000, "increaseTolerancePercent": 20 } +} +``` + +| Field | Meaning | +|---|---| +| `owner`, `moderators` | The amounts the document type declares for the action, before any multiplier. They must match exactly, each part on its own | +| `feeMultiplier` | Present for a `feeMultiplier` fee, left out for a `fixed` one. `knownPermille` is the fee multiplier the signer priced the fee with, in thousandths (1000 is 1x). `increaseTolerancePercent` is how far above it the executing epoch's multiplier may be, in percent of the known one: 20 accepts up to 1.2 times | + +A transition on an action that charges a fee is refused: + +- without an agreement (`DocumentActionFeeAgreementNotSetError`, 40132), whoever pays, a sponsored transition included; +- with other amounts, parts moved between the pots, or the other pricing (`DocumentActionFeeAgreementMismatchError`, 40133). The signer reads the contract again; +- when the executing epoch's multiplier is above what the agreement tolerates (`DocumentActionFeeMultiplierNotToleratedError`, 40134). A multiplier that fell is always accepted, and what is charged follows the epoch's multiplier, never the known one. + +Each refusal bumps the signer's nonce, and no action fee is charged. The mempool applies the same checks on arrival and on every recheck, so a transition whose agreement no longer holds leaves the mempool with the same error. An agreement on an action that charges nothing is ignored. + +A client should build the agreement from the contract it showed its user, never from a contract fetched behind their back at signing time. In Rust, `DocumentActionFeeAgreement::for_document_type_action` builds it from a document type, and the SDK's document transition builders take it with `with_action_fee_agreement`. + +**A seated team's discount.** On a document type that an elected contract moderates, the `moderators` part of an agreement may name less than the declared amount: exactly the share the contract's seated moderation charter takes (its `moderatorsShare`, in percent, rounded down to the credit). Everything else must still match. The action is then charged the agreed amount. Any other amount below the declared one, including a discount on a contract with no seated charter yet, is refused (`DocumentActionFeeModeratorsShareMismatchError`, 40139). A lower amount anywhere else, on a type the contract does not moderate or a contract that is not elected, is the plain mismatch (40133). See [Elected Moderation](../data-model/contract-moderation.md#elected-moderation). + +## The pots and the claim + +The `owner` parts collect in the contract's **owner pot** and the `moderators` parts in its **moderators pot**. A `ContractFeeClaim` state transition pays a pot out: + +- the owner pot goes whole to the contract owner, the only identity that may claim it; +- the moderators pot is split equally between the moderation team (the identities the contract appoints, or the owner alone when it appoints none), and any member of the team may claim it for all of them. A seated elected team splits it by its charter's reward split instead. What a split leaves over, less than a credit per member, stays in the pot; +- each pot is paid out at most once per epoch, and the two are independent. + +A claim is refused when the signer is not a recipient of the pot (`ContractFeeClaimNotAllowedError`, 41113), when the pot was already paid out this epoch (`ContractFeesAlreadyClaimedThisEpochError`, 41111), or when a recipient would get less than a credit (`ContractFeesNothingToClaimError`, 41112). The JavaScript SDK reads the pots with `contracts.feePots` and claims with `contracts.claimFees`. + +## Rules at registration + +- The meta-schema checks the shape (`JsonSchemaError`, 10101): only `pricing` and the six action keys; `pricing` one of the two values; each action an object with `owner`, `moderators` or both, each an integer from 0 to 9223372036854775807. +- At least one action is priced, and a priced action charges something: an action whose parts are all 0 is refused, leave it out instead. The two parts of an action may not add up to more than 9223372036854775807 credits (`InvalidContractStructure`, 10231). +- A nonzero `moderators` part needs a contract whose config declares `moderation` (`DocumentActionFeesWithoutModerationError`, 10902): the moderation team is who that pot is for. This is checked when the contract is created and when it is updated. + +## See also + +- [Document action fees](../fees/overview.md#document-action-fees), the deep dive +- [Fee Pots and the Claim](../data-model/contract-moderation.md#fee-pots-and-the-claim), for the pots, the claim and its proofs +- [Token Costs (tokenCost)](token-cost.md), which prices the same six actions in tokens +- [Contract-Level Keys and config](contract-config.md), for `moderation` +- [Contract Keywords overview](../contract-keywords.md), for the conventions of these tables diff --git a/book/src/contract-keywords/aggregates.md b/book/src/contract-keywords/aggregates.md new file mode 100644 index 00000000000..35ca0a89946 --- /dev/null +++ b/book/src/contract-keywords/aggregates.md @@ -0,0 +1,178 @@ +# Counts, Sums and Averages + +Counting documents normally means fetching them and counting what comes back, which grows with the number of documents and proves every one of them. The keywords in this chapter make Drive keep running totals inside its trees instead, so a count, a sum or an average is read without visiting the documents, and proved with a short proof. The document type keywords keep totals over all of a type's documents. The index keywords keep totals per indexed value, and with their `range*` forms over ranges of values. An average is never stored as such: the platform returns the count and the sum of the same documents, and the client divides. + +Every total is updated by every write that touches it, so each flag adds to the cost of writing documents of the type. The flags choose the layout of the type's trees, so all of them are fixed when the document type is created. + +## Example + +```json +"tip": { + "type": "object", + "documentsMutable": false, + "documentsAverageable": "amount", + "indices": [ + { "name": "byRecipient", "properties": [{ "recipient": "asc" }], "averageable": "amount" }, + { "name": "byDay", "properties": [{ "day": "asc" }], "summable": "amount", "rangeSummable": true } + ], + "properties": { + "recipient": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "amount": { "type": "integer", "minimum": 1, "maximum": 4294967295, "position": 1 }, + "day": { "type": "integer", "minimum": 0, "maximum": 4294967295, "position": 2 } + }, + "required": ["recipient", "amount", "day"], + "additionalProperties": false +} +``` + +`documentsAverageable` keeps the number of tips and their total, so the average tip over the whole type is one read. `byRecipient` keeps the same pair per recipient, so "how many tips did this identity get, and how much on average" is one read. `byDay` keeps the sum per day and, with `rangeSummable`, answers "total tipped between day 100 and day 130" without visiting each day. + +## `documentsCountable` + +| | | +|---|---| +| **Where** | document type | +| **Value** | boolean | +| **Default** | `false` | +| **Since** | protocol version 12 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212) | + +Keeps the number of the type's documents in its primary tree, so a count query with no `where` clause is one read, with a proof. Without it, such a query is refused rather than answered slowly. + +## `documentsSummable` + +| | | +|---|---| +| **Where** | document type | +| **Value** | the name of an integer property, 1 to 64 characters | +| **Default** | absent | +| **Since** | protocol version 12 | +| **On update** | Fixed (40212) | + +Keeps the sum of the named property over all the type's documents, so a sum query with no `where` clause is one read. On a type with `documentsKeepHistory`, the sum counts each document's current revision only. + +The property must exist on the type, be listed in `required` (a missing value would leave the sum wrong when the document is deleted), and hold values that fit a signed 64-bit sum. Integers are stored in the smallest width their bounds allow when the contract uses [`sizedIntegerTypes`](contract-config.md#sizedintegertypes), and a property that becomes an unsigned 64-bit integer is refused: `"minimum": 0` with no `maximum` does, so add a `maximum` of at most 4294967295, or give no bounds at all. + +## `documentsAverageable` + +| | | +|---|---| +| **Where** | document type | +| **Value** | the name of an integer property, 1 to 64 characters | +| **Default** | absent | +| **Since** | protocol version 12 | +| **On update** | Fixed (40212) | + +Shorthand for `documentsCountable: true` plus `documentsSummable` on the named property: the count and the sum an average is computed from. The storage is exactly that of the two flags. When `documentsSummable` is also written it must name the same property, and an explicit `documentsCountable: false` beside it is refused as a contradiction. + +## Document type `rangeCountable`, `rangeSummable`, `rangeAverageable` + +| | | +|---|---| +| **Where** | document type | +| **Value** | boolean each | +| **Default** | `false` | +| **Since** | protocol version 12 | +| **On update** | Fixed (40212) | + +These keep the count, the sum, or both at every node of the primary tree rather than only at its root, which is what range and offset aggregates over the primary key need. They are rarely useful: a range count or sum is almost always wanted over an indexed property, which is what the [index keywords of the same names](#index-rangecountable-rangesummable-rangeaverageable) give. + +- `rangeCountable` implies `documentsCountable`. +- `rangeSummable` needs `documentsSummable` or `documentsAverageable`. +- `rangeAverageable` is shorthand for `rangeCountable` plus `rangeSummable`, and needs `documentsAverageable`. An explicit `false` for either of the two beside it is refused as a contradiction. + +## `countable` + +| | | +|---|---| +| **Where** | index | +| **Value** | `"notCountable"`, `"countable"`, `"countableAllowingOffset"`, or a boolean (`true` is `"countable"`, `false` is `"notCountable"`) | +| **Default** | `"notCountable"` | +| **Since** | protocol version 12 | +| **On update** | Fixed, like every index (`DataContractInvalidIndexDefinitionUpdateError`, 10217) | + +Keeps a document count for each value of the index, so "how many documents have these values" is one read per value, with a proof. A count query is served by a countable index whose properties exactly match its `where` clauses, each an equality or an `in`: a countable index on `[brand, color]` answers `brand == "a" AND color == "red"`, and `color in [...]` under a fixed brand with one count per color, but not `brand == "a"` alone. Declare one countable index per shape of count the application needs. + +`"countableAllowingOffset"` keeps a count at every node of the index's trees, not only per value. It costs more on every write and prepares the index for offset queries; use `"countable"` unless you need it. The boolean form is kept for contracts written before the string form existed. + +On a unique index the flag changes almost nothing, since each value holds at most one document; it only counts the documents that leave an indexed property out. + +## `summable` + +| | | +|---|---| +| **Where** | index | +| **Value** | the name of an integer property, 1 to 64 characters | +| **Default** | absent | +| **Since** | protocol version 12 | +| **On update** | Fixed (10217) | + +Keeps the sum of the named property for each value of the index, the way `countable` keeps a count, and serves sum queries under the same exact-match rule. The property follows the rules of `documentsSummable`: it exists, is required, and fits a signed 64-bit sum. + +## `averageable` + +| | | +|---|---| +| **Where** | index | +| **Value** | the name of an integer property, 1 to 64 characters | +| **Default** | absent | +| **Since** | protocol version 12 | +| **On update** | Fixed (10217) | + +Shorthand for `countable: "countable"` plus `summable` on the named property, which is what an average query per value needs. When `summable` is also written it must name the same property. An explicit `countable: "countableAllowingOffset"` beside it is kept; an explicit `"notCountable"` (or `false`) is refused as a contradiction. + +## Index `rangeCountable`, `rangeSummable`, `rangeAverageable` + +| | | +|---|---| +| **Where** | index | +| **Value** | boolean each | +| **Default** | `false` | +| **Since** | protocol version 12 | +| **On update** | Fixed (10217) | + +These answer aggregates over a **range** of the index's last property: "how many reviews between grade 60 and 80", "total tipped from day 100 to day 130". The count or sum is kept at every node of the tree of that property's values, so a range total is read by walking the edges of the range, with a proof whose size grows with the logarithm of the number of values rather than with the documents. The same index also returns one total per distinct value in a range. The query fixes the properties before the last one, with equalities or an `in`. + +- `rangeCountable` makes the index countable: an omitted `countable` becomes `"countable"`, an explicit `"countableAllowingOffset"` is kept, and an explicit `"notCountable"` is refused. Before protocol version 14, `countable` had to be written out beside it. +- `rangeSummable` needs `summable` or `averageable`. +- `rangeAverageable` is shorthand for `rangeCountable` plus `rangeSummable`, and needs `averageable`. An explicit `false` for either of the two beside it is refused. + +A range total costs more on each write than a per-value total, since every node of the tree carries it. The ranked keywords build on these flags: see [Ranked Indexes](ranked.md). + +## How they combine + +- **Count and sum on one tree.** An index with both a count and a sum (by `averageable`, or by `countable` plus `summable`) keeps both in one tree, and one proof returns the pair. The same holds for the document type flags. +- **One summed property per type.** Every `summable`, `averageable`, `documentsSummable` and `documentsAverageable` of a document type must name the same property: the sums share their trees, which have no room to tell two properties apart. To sum two properties, use two document types. +- **Type and index flags are independent.** `documentsCountable` gives the unfiltered total; a countable index gives filtered ones. A type may have both. +- **Index-only types** take the index keywords but refuse the document type ones, since they have no primary tree. See [Index-Only Types](index-only.md). +- **Queries must match.** A count or sum query that no index serves exactly is refused; there is no slow fallback. Pick the indexes for the queries the application will make. + +## Rules at registration + +- A summed property exists on the type, is an integer that fits a signed 64-bit sum (not an unsigned 64-bit integer), and is listed in `required`. +- All summed properties of a type are the same property. +- The shorthands agree with their longhand where both are written, and no explicit `false` or `"notCountable"` contradicts them. +- Each `range*` flag has its prerequisite, as listed above. +- The meta-schema refuses a malformed value (`JsonSchemaError`, 10101). + +## Choosing what to set + +| The application needs | Set | +|---|---| +| The number of documents of the type | `documentsCountable: true` | +| The sum, or the average, of a property over the whole type | `documentsSummable` or `documentsAverageable` | +| A count per value (`where author == x`) | `countable: "countable"` on an index whose properties are exactly those of the query | +| A sum or an average per value | `summable` or `averageable` on such an index | +| A count, sum or average over a range (`where grade > 60`) | the matching `range*` flag on an index whose last property is the ranged one | +| The top or bottom values by count, sum or average | the `range*` flags plus a ranking: see [Ranked Indexes](ranked.md) | + +## See also + +- [Document Count Trees](../drive/document-count-trees.md) and [Document Sum Trees](../drive/document-sum-trees.md) for the tree variants, the query endpoints and what each flag costs. +- [Count Index Examples](../drive/count-index-examples.md), [Sum Index Examples](../drive/sum-index-examples.md) and [Average Index Examples](../drive/average-index-examples.md) for worked queries and proof sizes. +- [Indexes](indexes.md) for the index keywords every index uses. +- [Ranked Indexes](ranked.md) for ordering values by these totals. diff --git a/book/src/contract-keywords/contested.md b/book/src/contract-keywords/contested.md new file mode 100644 index 00000000000..4d54eb46578 --- /dev/null +++ b/book/src/contract-keywords/contested.md @@ -0,0 +1,81 @@ +# Contested Indexes + +A unique index normally works first come, first served: the first document to take a value keeps it, and every later one is refused as a duplicate. `contested` changes that for the values a contract considers valuable. A document whose values fall in the contested range opens a **contest**, or joins one already open for the same values, and masternodes and evonodes vote on which identity gets them. DPNS uses it so that a short name such as `alice.dash` cannot simply be taken by whoever submits first. A contract author reaches for it when a unique value is scarce and should be awarded rather than raced for. + +| | | +|---|---| +| **Where** | a unique index | +| **Value** | object: `resolution` (required), `fieldMatches`, `description` | +| **Since** | protocol version 1; `resolution: 1` from protocol version 14 | +| **On update** | Fixed, like every index (`DataContractInvalidIndexDefinitionUpdateError`, 10217) | +| **Errors** | `DocumentContestNotPaidForError` (40114), `DocumentContestCurrentlyLockedError` (40110), `DocumentContestNotJoinableError` (40111), `DocumentContestIdentityAlreadyContestantError` (40112), `DocumentContestIndexMismatchError` (40118), `DocumentContestNotRequiredError` (40119), `DocumentContestMaximumContendersReachedError` (40141); `DuplicateUniqueIndexError` (40105) for values outside the contested range | + +## Example + +The `domain` document type of the DPNS contract: + +```json +{ + "name": "parentNameAndLabel", + "properties": [ + { "normalizedParentDomainName": "asc" }, + { "normalizedLabel": "asc" } + ], + "unique": true, + "contested": { + "fieldMatches": [ + { "field": "normalizedLabel", "regexPattern": "^[a-zA-Z01-]{3,19}$" } + ], + "resolution": 0, + "description": "If the normalized label part of this index is less than 20 characters (all alphabet a-z, A-Z, 0, 1, and -) then a masternode vote contest takes place to give out the name" + } +} +``` + +A name is unique under its parent domain. A label of 3 to 19 characters made of letters, `0`, `1` and `-` is contested: registering it opens a masternode vote, which may give it to a contender or lock it. Any other label, a longer one or one with other digits, is registered first come, first served, and a second registration of it is a duplicate. + +## The keys + +| Key | Value | What it does | +|---|---|---| +| `resolution` | `0` or `1`, required | How the contest is decided. `0`: masternodes vote for a contender, abstain, or **lock** the value so nobody gets it. `1` (from protocol version 14): masternodes vote for a contender or abstain; there is no lock, so the contest always ends with a winner. | +| `fieldMatches` | array of at least one `{ "field", "regexPattern" }` | Which values are contested. `field` is a property path of the document; `regexPattern` a regular expression its value must match. Each is 1 to 256 characters. | +| `description` | string, 1 to 256 characters | A note for readers. Consensus does not read it. | + +## How it works + +**Which documents are contested.** A document is contested when, for every `fieldMatches` entry, the document holds a string at `field` and `regexPattern` matches it. A missing value, a value that is not a string, or one entry that does not match makes the document an ordinary unique-index document. Without `fieldMatches`, every document the index covers is contested. The pattern uses the syntax of Rust's `regex` crate and matches anywhere in the value, so write `^` and `$` to match the whole of it, as DPNS does. + +**Values outside the contested range** behave exactly like any unique index: the first document takes the value, and a later create with the same values is refused with `DuplicateUniqueIndexError` (40105). + +**Opening or joining a contest.** A create whose values are contested must carry `$prefundedVotingBalance`, a pair `[indexName, amount]`: the contested index's name and the most credits the contender will pay to fund the vote. The document is not stored under the index yet; it is held as a contender until the contest ends. A create is refused: + +- with `DocumentContestNotPaidForError` (40114) when it carries no prefunded balance, or less than the contest's fund. The fund is the contested document fund, 0.1 Dash. From protocol version 14 it doubles once the contest holds 250 contenders and again for every 50 more, and a contender is charged the fund and keeps what it stated beyond it; before 14 every contender stated and paid exactly the fund. +- with `DocumentContestIndexMismatchError` (40118, from protocol version 14) when the pair names another index than the contested one its values match. +- with `DocumentContestNotRequiredError` (40119, from protocol version 14) when the document is not contested but carries a prefunded balance. +- with `DocumentContestNotJoinableError` (40111) when the contest's join window (one week on mainnet) has passed. +- with `DocumentContestIdentityAlreadyContestantError` (40112) when its owner is already a contender. +- with `DocumentContestMaximumContendersReachedError` (40141, from protocol version 14) when the contest already holds 1,000 contenders. +- with `DocumentContestCurrentlyLockedError` (40110) when an earlier contest for these values ended locked. + +**The vote.** Masternodes and evonodes vote with `MasternodeVote` transitions for the length of the poll (two weeks on mainnet). Under `resolution: 0` the contender with the most votes wins unless the lock tally beats it, and the contest always runs its full length, even with one contender. Under `resolution: 1` a contender always wins, and a contest that still has a single contender when its join window closes is awarded to it at once. From protocol version 14 a tie goes to the earliest contender. + +**After the contest.** The winning document is stored and held by the index like any unique-index document; the other contenders' documents are removed. The contest's result stays readable. + +The fund, the windows, the tallies and the special case of moderation elections are described in [Contested Documents](../data-model/contested-documents.md). + +## Rules at registration + +- The index is `unique: true`. A contested index that is not unique is refused (`InvalidContractStructure`, 10231). +- The document type's documents cannot be replaced: `documentsMutable: false` (`ContestedUniqueIndexOnMutableDocumentTypeError`, 10248). They may still be transferred and sold, as DPNS domains are. +- A document type has at most one contested index, and no other unique index beside it (`ContestedUniqueIndexWithUniqueIndexError`, 10249). +- `resolution` is present and is `0` or `1`; `1` is refused before protocol version 14. +- `fieldMatches`, when present, holds at least one entry, and each `regexPattern` is a valid regular expression (`RegexError`, 10247). +- A contested index cannot carry a [`timeRange`](time-range.md) or a ranking (a ranking needs a non-unique index), and an [index-only type](index-only.md) cannot have one. +- A document type with a contested index cannot set [`ttl`](ttl.md), nor `canBeDeletedByModerators` (see [Deletion](deletion.md)): a moderator's restore puts a document back by an ordinary insert, and a contested value is only awarded through a vote. + +## See also + +- [Contested Documents](../data-model/contested-documents.md) for the contest's lifecycle, fund, resolutions, ties and storage. +- [Indexes](indexes.md) for `unique` and the other index keywords. +- [Mutability](mutability.md) for `documentsMutable`, which a contested type sets to `false`. diff --git a/book/src/contract-keywords/contract-config.md b/book/src/contract-keywords/contract-config.md new file mode 100644 index 00000000000..e167a9b8674 --- /dev/null +++ b/book/src/contract-keywords/contract-config.md @@ -0,0 +1,206 @@ +# Contract-Level Keys and config + +The document types sit inside a data contract, which has keys of its own: its id, its owner, its version, the shared definitions its types may point at, its tokens and groups, and a search description. One of them, `config`, holds contract-wide settings: whether the contract can ever change, whether it keeps its history, the defaults its document types inherit, how integers are stored, and whether and how it is moderated. Almost every `config` setting is chosen once, at registration, and kept for the contract's life. + +## Example + +```json +{ + "$formatVersion": "1", + "id": "AY6xWncZUFv2GCrS5seqKthUfbW9yYyUXtF8diSuHQ4g", + "ownerId": "AtirhSVpAWF7dEt6dLAmesC4Sr1MsJ9bFC1nLAoNnq2S", + "version": 1, + "config": { + "$formatVersion": "2", + "canBeDeleted": false, + "readonly": false, + "keepsHistory": false, + "documentsKeepHistoryContractDefault": false, + "documentsMutableContractDefault": true, + "documentsCanBeDeletedContractDefault": true, + "sizedIntegerTypes": true, + "moderation": { + "banlist": true, + "suspensions": true, + "moderators": { "$type": "contractOwner" } + } + }, + "documentSchemas": { + "post": { + "type": "object", + "properties": { + "text": { "type": "string", "minLength": 1, "maxLength": 280, "position": 0 } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "keywords": ["social", "microblog"], + "description": "Short public posts" +} +``` + +A first version of a small social contract. Its config states the defaults explicitly, stores integers in the smallest width their bounds allow, and keeps a banlist and a suspension list that the owner edits. `keywords` and `description` make it findable through the keyword search contract. + +## Contract keys + +| Key | Value | What it is | Since | +|---|---|---|---| +| `$formatVersion` | `"0"` or `"1"` | The contract's serialization format. Format 1 is the default from protocol version 9 and is the one that carries `groups`, `tokens`, `keywords`, `description` and the timestamps. | 1 | +| `id` | identifier | The contract's id: a hash of `ownerId` and the identity nonce of the create transition. A create whose id is not that hash is refused (`InvalidDataContractIdError`, 10204). | 1 | +| `ownerId` | identifier | The identity that registers the contract, and the only one that can update it. | 1 | +| `version` | integer | 1 when the contract is created; each update must raise it by exactly one (`InvalidDataContractVersionError`, 10212). | 1 | +| `config` | object | Contract-wide settings: see [`config`](#config) below. Absent means the defaults. | 1 | +| `documentSchemas` | object | The document types, by name. See [`documentSchemas`](#documentschemas). | 1 | +| `schemaDefs` | object | Definitions every document type may point at with `$ref`. An update may add definitions, not remove them (`IncompatibleDataContractSchemaError`, 10213). | 1 | +| `groups` | object | Groups of identities that act together, each member with a voting power, whose approval some token actions need. See [Contract Groups](../data-model/contract-groups.md). | 9 | +| `tokens` | object | The contract's tokens, keyed by position `0`, `1`, and so on. Document types may charge them with [`tokenCost`](token-cost.md). | 9 | +| `keywords` | array of strings | Search keywords. See [`keywords` and `description`](#keywords-and-description). | 9 | +| `description` | string | A short description for search. See [`keywords` and `description`](#keywords-and-description). | 9 | +| `createdAt`, `updatedAt`, `createdAtBlockHeight`, `updatedAtBlockHeight`, `createdAtEpoch`, `updatedAtEpoch` | numbers | When the contract was created and last updated. The platform sets them; a contract does not write them. | 9 | + +### `documentSchemas` + +| | | +|---|---| +| **Where** | contract | +| **Value** | object mapping each document type name to its schema | +| **Since** | protocol version 1 | +| **On update** | Document types may be added; none may be removed (`DocumentTypeUpdateError`, 40212). Each existing type follows the update rules of its keywords. | +| **Errors** | `DocumentTypesAreMissingError` (10214), `InvalidDocumentTypeNameError` (10415), at registration | + +A contract has at least one document type, unless it defines tokens (`DocumentTypesAreMissingError`, 10214). A name is 1 to 64 ASCII letters, digits, `_` or `-`; from protocol version 14 a name may not contain `-` (`InvalidDocumentTypeNameError`, 10415). The keywords a schema takes are the subject of the rest of this part: see [Contract Keywords](../contract-keywords.md). + +### `keywords` and `description` + +| | | +|---|---| +| **Where** | contract | +| **Value** | `keywords`: array of at most 50 strings; `description`: string | +| **Default** | no keywords, no description | +| **Since** | protocol version 9 | +| **On update** | May be changed; the search entries are replaced | +| **Errors** | `TooManyKeywordsError` (10262), `InvalidKeywordLengthError` (10270), `InvalidKeywordCharacterError` (10269), `DuplicateKeywordsError` (10263), `InvalidDescriptionLengthError` (10264) | + +The keyword search system contract indexes each contract by its keywords and description, so applications can find contracts by topic. The rules, checked on every create and update: + +- At most 50 keywords (10262). +- Each keyword is 3 to 50 bytes of UTF-8 (10270) and contains no whitespace or control character (10269). +- No keyword appears twice (10263). +- A description is 3 to 100 bytes of UTF-8 (10264). + +## `config` + +| | | +|---|---| +| **Where** | contract | +| **Value** | object: `$formatVersion` and the keys below | +| **Default** | absent: every key takes its default | +| **Since** | protocol version 1 | +| **On update** | The keys below are fixed, with the exceptions each one names (`DataContractConfigUpdateError`, 40002) | +| **Errors** | `DataContractConfigUpdateError` (40002), `DataContractIsReadonlyError` (40001) | + +`$formatVersion` is the config's own version: `"0"` before protocol version 9, `"1"` from 9, and `"2"` from 14, which adds `moderation`. From protocol version 14 every new contract carries config version 2, moderated or not, and an older contract moves to it with its next update. + +### `canBeDeleted` + +| | | +|---|---| +| **Where** | `config` | +| **Value** | boolean | +| **Default** | `false` | +| **Since** | protocol version 1 | +| **On update** | Fixed (40002) | + +Whether the contract itself may ever be deleted. No transition deletes a contract today, so the flag has no effect yet beyond being recorded. + +### `readonly` + +| | | +|---|---| +| **Where** | `config` | +| **Value** | boolean | +| **Default** | `false` | +| **Since** | protocol version 1 | +| **On update** | Cannot be set by an update (40002) | +| **Errors** | `DataContractIsReadonlyError` (40001) | + +`true` freezes the contract at its first version: every update of it is refused with `DataContractIsReadonlyError` (40001). Only a create can set it, so a contract is read-only from the start or never. A document reference can require its target contract to be read-only (`contractRequirements.readonly`, see [References](refers-to.md)). + +### `keepsHistory` + +| | | +|---|---| +| **Where** | `config` | +| **Value** | boolean | +| **Default** | `false` | +| **Since** | protocol version 1 | +| **On update** | Fixed (40002) | + +`true` makes Drive keep every version of the contract, not only the latest, so a client can read and prove the contract as it was at an earlier version. + +### Document type defaults + +| | | +|---|---| +| **Where** | `config` | +| **Value** | `documentsKeepHistoryContractDefault`, `documentsMutableContractDefault`, `documentsCanBeDeletedContractDefault`: boolean each | +| **Default** | `false`, `true`, `true` | +| **Since** | protocol version 1 | +| **On update** | Fixed (40002) | + +The value of `documentsKeepHistory`, `documentsMutable` and `canBeDeleted` for each document type that does not set it itself. A type that sets the keyword overrides the default. Changing a default would change every type that relies on it, so all three are fixed. See [History](history.md), [Mutability](mutability.md) and [Deletion](deletion.md). + +### Bounded key requirements + +| | | +|---|---| +| **Where** | `config` | +| **Value** | `requiresIdentityEncryptionBoundedKey`, `requiresIdentityDecryptionBoundedKey`: `0` unique, `1` multiple, `2` multiple with a pointer to the latest | +| **Default** | absent | +| **Since** | protocol version 1 | +| **On update** | Fixed (40002) | + +Let identities add encryption, or decryption, keys bound to the whole contract, and say how they are kept: one key that cannot be replaced, several, or several with a pointer to the latest. The document type keywords of the same names do this for keys bound to one document type. See [Signing and Keys](signing-keys.md) and [Contract Bounds](../sdk/identity-keys.md#contract-bounds). + +### `sizedIntegerTypes` + +| | | +|---|---| +| **Where** | `config` | +| **Value** | boolean | +| **Default** | `true` in config version 1 and later; config version 0 has no such key and behaves as `false` | +| **Since** | protocol version 9 | +| **On update** | May be turned on, not off (40002) | + +With `true`, each integer property is stored in the smallest width its `minimum`, `maximum` or `enum` allow: `"minimum": 0, "maximum": 100` takes one byte. With `false`, every integer is a signed 8-byte value. Turning it off would make stored documents unreadable, so it is refused. Turning it on is allowed by the config check, but it changes the width of every bounded integer of the existing document types, and an update that changes how an existing property's values are stored is refused (`DocumentTypeUpdateError`, 40212); in practice it can only be turned on when it leaves the width of every existing integer property unchanged. + +The width also decides what a summed property may be: see [Counts, Sums and Averages](aggregates.md#documentssummable). + +### `moderation` + +| | | +|---|---| +| **Where** | `config` (config version 2) | +| **Value** | object: `banlist`, `suspensions`, `warnings` (booleans, default `false`) and `moderators` | +| **Default** | absent: the contract is not moderated | +| **Since** | protocol version 14 | +| **On update** | Which lists are kept is fixed, and an elected team can be neither declared, changed nor left; otherwise the moderators may change (40002) | +| **Errors** | `InvalidContractModerationConfigError` (10900), `ContractModeratorIdentityNotFoundError` (41110) | + +Declares which moderation lists the contract keeps and who edits them. An identity on the banlist, or suspended, cannot act on the contract's documents; a warning is a record that bars nothing. `moderators` is one of: + +- `{ "$type": "contractOwner" }`: the owner moderates alone. +- `{ "$type": "appointedModerators", "identities": [...] }`: the owner and 1 to 16 named identities, each acting alone. Every named identity must exist (`ContractModeratorIdentityNotFoundError`, 41110). +- `{ "$type": "elected", ... }`: a team elected by masternodes moderates, with the abilities the declaration gives it. See [Elected Moderation](../data-model/contract-moderation.md#elected-moderation). + +A declaration keeps at least one list, unless a document type sets `canBeDeletedByModerators`, and is refused otherwise (`InvalidContractModerationConfigError`, 10900). An unknown key is refused rather than ignored, so a misspelled list name cannot silently leave the contract without it. Because the lists are fixed, a contract that will ever need moderation declares it when it is created. + +`moderation` is what `canBeDeletedByModerators` (see [Deletion](deletion.md)) and the moderators' share of [`actionFees`](action-fees.md) require. + +## See also + +- [Data Contracts](../data-model/data-contracts.md) for the contract structure and its versions. +- [Contract Moderation](../data-model/contract-moderation.md) for the lists, the moderation transition and elected teams. +- [Contract Groups](../data-model/contract-groups.md) for `groups`. +- [Contract Keywords](../contract-keywords.md) for the document type keywords. diff --git a/book/src/contract-keywords/deletion.md b/book/src/contract-keywords/deletion.md new file mode 100644 index 00000000000..ed7189bc0f8 --- /dev/null +++ b/book/src/contract-keywords/deletion.md @@ -0,0 +1,148 @@ +# Deletion + +A document can leave the state three ways: its owner deletes it, the contract's moderators delete it, or the platform deletes it when its time to live runs out. `canBeDeleted` rules the first, `canBeDeletedByModerators` and `canBeDeletedByModeratorsFor` the second, and `ttl` the third (see [Time To Live](ttl.md)). Each is independent of the others: a type may let moderators remove what its authors cannot retract, or expire documents that nobody may delete by hand. + +## `canBeDeleted` + +Whether a document's owner may delete it. Set it to `false` for records that other documents or other people rely on staying put. + +| | | +|---|---| +| **Where** | document type | +| **Value** | boolean | +| **Default** | the contract config's `documentsCanBeDeletedContractDefault`, which is `true` unless the contract says otherwise | +| **Since** | protocol version 1 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212), except that a type which keeps history before and after the update may change it from `true` to `false`. Adding or removing the key without changing its value is refused too, as a schema change (`IncompatibleDocumentTypeSchemaError`, 10246). | +| **Errors** | `InvalidDocumentTransitionActionError` (10404) for a delete of a type set to `false`, or, from protocol version 14, of a type that keeps history; `DocumentOwnerIdMismatchError` (40102) for a delete by anyone but the owner | + +### Example + +```json +"comment": { + "type": "object", + "canBeDeleted": true, + "properties": { + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "text": { "type": "string", "maxLength": 500, "position": 1 } + }, + "required": ["postId", "text"], + "additionalProperties": false +} +``` + +A commenter may take a comment down at any time. Since `true` is the usual default, the key could be left out; writing it makes the intent plain. + +### How it works + +- The owner deletes a document with a delete transition that names its id. Anyone else is refused (`DocumentOwnerIdMismatchError`, 40102), and a document that does not exist is `DocumentNotFoundError` (40101). +- The owner is refunded the part of the document's storage fee that has not yet been paid out to past epochs. A document of a type with a `ttl` refunds nothing. See [Refunds](../fees/overview.md#refunds). +- A delete may carry a token cost or an action fee, like any document action. See [Token Costs](token-cost.md) and [Action Fees](action-fees.md). +- An identity that is banned or suspended on a moderated contract may still delete its own documents. See [Contract Moderation](../data-model/contract-moderation.md#the-model). +- `false` binds only the owner. The contract's moderators, when the type allows them, and the platform, when the type has a `ttl`, still delete such documents. +- Drive never deletes a document whose type keeps history (`documentsKeepHistory`). From protocol version 14 a delete of such a document is refused with 10404 whatever `canBeDeleted` says; before it, the delete failed inside Drive as an internal error. +- Documents of an `indexOnly` type are deleted with an index-only delete transition that carries their values, since there is no stored row to name by id. A delete by id of such a document is refused (10404). See [Index-Only Types](index-only.md). + +### Rules at registration + +- From protocol version 14, a type with `documentsKeepHistory: true` must set `canBeDeleted: false` (`InvalidContractStructure`, 10231). The default is `true`, so it has to be written out. A contract registered earlier with both flags on stays readable, but its next update is checked like a new contract, so that update must turn `canBeDeleted` off on the type. That is the one change to `canBeDeleted` an update may make. +- For references, a type whose owner may delete its documents is deletable: a `permanentDocument` or `listElement` reference may not point at it (`ReferencedDocumentTypeDeletableError`, 40122), and a `deletableDocument` reference may. See [References](refers-to.md). + +## `canBeDeletedByModerators` + +Lets the contract's moderators delete documents of the type, whoever owns them. It is how an application takes down content that breaks its rules, where a ban only stops an identity from writing more. + +| | | +|---|---| +| **Where** | document type, in a contract whose config declares `moderation` | +| **Value** | boolean | +| **Default** | `false` | +| **Since** | protocol version 14 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212) | +| **Errors** | `DocumentTypeNotDeletableByModeratorsError` (41115), `IdentityNotContractModeratorError` (41101), `ContractModerationTargetNotAllowedError` (41102), `DocumentNotFoundError` (40101), and `DocumentModerationWindowElapsedError` (41116) with a window | + +### Example + +```json +"post": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": false, + "canBeDeletedByModerators": true, + "canBeDeletedByModeratorsFor": 604800, + "properties": { + "text": { "type": "string", "maxLength": 280, "position": 0 } + }, + "required": ["$createdAt", "$updatedAt", "text"], + "additionalProperties": false +} +``` + +Authors cannot retract a post, but the contract's moderators can remove one for a week (604,800 seconds) after it was written or last edited. The contract around it must declare `moderation` in its config. + +### How it works + +- A moderator deletes a document with the contract user moderation transition, naming the document type, the document id and a reason. The moderators are the ones the contract's `moderation` config declares; see [Contract Moderation](../data-model/contract-moderation.md#the-model). +- The transition is checked in this order, each refusal paid: the document type exists (`InvalidDocumentTypeError`, 10406); it carries the keyword (41115); the signer is the contract owner or a moderator (41101); the document exists (40101); its owner is neither the contract owner nor a moderator (41102); and, when the type sets `canBeDeletedByModeratorsFor`, the window has not passed (41116). +- The document and all its index entries are deleted as an owner's delete would delete them, without the `canBeDeleted` check. A removal record is written under the contract: whose document it was, which moderator removed it, the reason, the block time and a hash of the document. The record is never deleted. +- The document's owner gets no storage refund, and the moderator pays neither the type's delete token cost nor its delete action fee. +- For a week after the deletion a moderator may restore the document exactly as it was. See [Restoring Documents](../data-model/contract-moderation.md#restoring-documents). + +### Rules at registration + +All refusals below are `InvalidContractStructure` (10231). + +- The contract's config must declare `moderation`. Moderation cannot be added by a later update, so without it nobody could ever delete anything. In return the `moderation` block may keep no banlist, suspension list or warning list at all when a document type carries this keyword. +- Refused on a type that keeps history (Drive never deletes those documents), on an `indexOnly` type (there is no stored row to name), on a type with `creationRestrictionMode` 1 or 2 (its documents are the contract owner's or the platform's), and on a type with a contested index (a restore could not go through the vote the index requires). +- For references, the type is deletable even with `canBeDeleted: false`: a `permanentDocument` or `listElement` reference may not point at it (40122), and a `deletableDocument` reference may. + +## `canBeDeletedByModeratorsFor` + +Limits the moderators' deletion to a window after a document's last change. Once the window has passed the document is settled: moderation acts on what was just written and does not reach back into what has stood unchallenged. + +| | | +|---|---| +| **Where** | document type, with `canBeDeletedByModerators: true` | +| **Value** | integer, seconds, 1 to 4294967295 | +| **Default** | absent: no limit | +| **Since** | protocol version 14 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212), in both directions: a longer window would reopen documents that had settled | +| **Errors** | `DocumentModerationWindowElapsedError` (41116) | + +### How it works + +- The window is measured from the document's `$updatedAt`, or from its `$createdAt` on a type that does not record `$updatedAt`. A deletion at exactly that time plus the window still passes; after it, every moderator is refused, the contract owner included. +- A replace or a price update moves `$updatedAt`, so new content opens the window again. A transfer or a purchase does not move it. +- A restored document comes back with its old `$updatedAt`, so it may already be settled. +- The window says nothing about the document's own owner, whose deletion `canBeDeleted` rules at any age. + +### Rules at registration + +- Needs `canBeDeletedByModerators: true` (`InvalidContractStructure`, 10231). +- A type whose documents can be replaced must list `$updatedAt` in `required`: measured from creation alone, an author could wait the window out and then rewrite a post into something no moderator can remove. A type with `documentsMutable: false` must list `$updatedAt` or `$createdAt`. Both refusals are 10231. +- A window of 0 is refused by the meta-schema (`JsonSchemaError`, 10101). A type that moderators may never delete from simply leaves `canBeDeletedByModerators` out. + +## How they combine + +| Who deletes | Allowed by | Refund to the owner | +|---|---|---| +| The document's owner | `canBeDeleted: true`, on a type that does not keep history | Yes, except on a type with a `ttl` | +| The contract's moderators | `canBeDeletedByModerators: true`, within `canBeDeletedByModeratorsFor` when set | No | +| The platform | `ttl`, once it has passed | No | + +A type that allows any of the three counts as deletable for references. Only a type that allows none of them can be the target of a `permanentDocument` or `listElement` reference. + +## See also + +- [Deleting Documents](../data-model/contract-moderation.md#deleting-documents) and [Restoring Documents](../data-model/contract-moderation.md#restoring-documents), for the moderation transition and the removal record +- [Time To Live](ttl.md), the third way a document leaves the state +- [History](history.md), for why a type that keeps history can never delete +- [Mutability](mutability.md) and [Creation, Transfers and Trading](ownership-and-trading.md) +- [References](refers-to.md), for `permanentDocument` and `deletableDocument` +- [Contract-Level Keys and config](contract-config.md), for `documentsCanBeDeletedContractDefault` and `moderation` diff --git a/book/src/contract-keywords/distinct-from.md b/book/src/contract-keywords/distinct-from.md new file mode 100644 index 00000000000..0c21e801c1b --- /dev/null +++ b/book/src/contract-keywords/distinct-from.md @@ -0,0 +1,82 @@ +# distinctFrom + +`distinctFrom` says that an identifier property must hold a different identity (or document) than another identifier of the same document, or than the document's owner. Reach for it when a document names two parties that must not be the same: a delegate who is not the delegator, a buyer who is not the seller, a team member who is not the team's leader. The check reads only the document being written, so it costs no state reads. + +| | | +|---|---| +| **Where** | An identifier property, at the top level or inside an object; or the `items` of a typed array of identifiers, where it binds every element | +| **Value** | `"$ownerId"`, or the dotted path of another identifier property of the same document type (`"meta.reviewerId"` for a nested one), 1 to 256 characters | +| **Default** | Absent: no rule | +| **Since** | protocol version 14 | +| **On update** | Fixed: adding, removing or changing it is refused (`IncompatibleDocumentTypeSchemaError`, 10246) | +| **Errors** | `DocumentPropertyNotDistinctError` (10419) on a document; at registration `JsonSchemaError` (10101) or `InvalidContractStructure` (10231) | + +An identifier here is a 32-byte id, declared as a byte array with the identifier `contentMediaType` (see [Property Schemas](property-schemas.md)). + +## Example + +```json +"delegation": { + "type": "object", + "properties": { + "delegateId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId", + "position": 0 + }, + "backupId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "delegateId", + "position": 1 + } + }, + "required": ["delegateId"], + "additionalProperties": false +} +``` + +An identity cannot delegate to itself: `delegateId` must differ from the document's owner. The optional `backupId` must differ from `delegateId`; a delegation without a backup passes, since there is nothing to compare. + +On a typed array the keyword goes on `items`, and every element must differ from the named value. The moderation charters system contract uses this for a team's members, none of whom may be the leader who writes the document: + +```json +"members": { + "type": "array", + "minItems": 0, + "maxItems": 15, + "uniqueItems": true, + "items": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId" + }, + "position": 2 +} +``` + +## How it works + +- **Create and replace.** After the document's properties pass the JSON schema, each declaring property is compared with what it names: the other property's value in the same document, or the writer's identity for `$ownerId`. Equal values refuse the transition with `DocumentPropertyNotDistinctError` (10419), which names the document type, the property and what it collided with. For a typed array each element is compared on its own. +- **Absent values pass.** When the declaring property or the property it names is left out of the document, there is nothing to compare, and the rule holds. Add the property to `required` when it must be there. +- **Transfer and purchase.** These change the owner and nothing else. The stored document is judged against its new owner, so a transfer to, or a purchase by, the identity held in a property that must differ from `$ownerId` is refused with the same error. A delegation, say, cannot be transferred to its own delegate. A price update changes neither owner nor data and is not judged. +- **Deletes** are never judged. + +The check reads the transition (or, for a transfer or purchase, the stored document) and never looks anything else up, and a type without declarations pays nothing for it. + +## Rules at registration + +- The keyword is allowed only on an identifier property or on the identifier `items` of a typed array. On any other property, the typed array itself included, the meta-schema refuses it (`JsonSchemaError`, 10101). +- The value must be `"$ownerId"` or name a property of the same document type. No other system property is accepted. +- A named property must exist, must itself be an identifier (an identifier that carries `refersTo` counts), must not be an object, and must not be the declaring property. + +The parser refuses a value that breaks the last two rules with `InvalidContractStructure` (10231). + +## See also + +- [Distinct Identifier Properties](../data-model/documents.md#distinct-identifier-properties), the deep dive +- [Typed Arrays](typed-arrays.md), for keywords on `items` +- [propertyConstraints](property-constraints.md): a rule such as `{ "notEqual": ["buyerId", "sellerId"] }` says the same, and can be combined with other conditions +- [Writer and Creator References](owner-refers-to.md), for rules that look the writer up in state +- [Contract Keywords overview](../contract-keywords.md), for the conventions of these tables diff --git a/book/src/contract-keywords/document-shape.md b/book/src/contract-keywords/document-shape.md new file mode 100644 index 00000000000..d0fb2ac160f --- /dev/null +++ b/book/src/contract-keywords/document-shape.md @@ -0,0 +1,169 @@ +# Document Shape + +A document type's schema is a JSON Schema object with some Platform keywords added. The keywords in this chapter give a document its outline: it is an object, it has these properties and no others, some of them must be present, and a few JSON Schema rules hold over the document as a whole. What a single property may hold is described in [Property Schemas](property-schemas.md); what may happen to a document (replace, delete, transfer) has chapters of its own. + +## Example + +The DashPay `profile` type, with its indexes left out: + +```json +"profile": { + "type": "object", + "properties": { + "avatarUrl": { "type": "string", "format": "uri", "minLength": 1, "maxLength": 2048, "position": 0 }, + "avatarHash": { "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, "position": 1 }, + "avatarFingerprint": { "type": "array", "byteArray": true, "minItems": 8, "maxItems": 8, "position": 2 }, + "publicMessage": { "type": "string", "minLength": 1, "maxLength": 140, "position": 3 }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 25, "position": 4 } + }, + "minProperties": 1, + "dependentRequired": { + "avatarUrl": ["avatarHash", "avatarFingerprint"], + "avatarHash": ["avatarUrl", "avatarFingerprint"], + "avatarFingerprint": ["avatarUrl", "avatarHash"] + }, + "required": ["$createdAt", "$updatedAt"], + "additionalProperties": false +} +``` + +A profile holds at least one of its five properties, and never any other. The three avatar properties come together or not at all. No property of its own is required, but the platform records the time each profile was created and last updated, because `required` lists `$createdAt` and `$updatedAt`. + +## `type` + +| | | +|---|---| +| **Where** | The top of a document type. Required. | +| **Value** | `"object"`, the only value allowed | +| **Since** | protocol version 1 | +| **On update** | Fixed (`IncompatibleDocumentTypeSchemaError`, 10246) | +| **Errors** | `JsonSchemaError` (10101) at registration | + +A document is always an object: a set of named properties. Properties inside a document have their own `type`, described in [Property Schemas](property-schemas.md#type). + +## `properties` + +| | | +|---|---| +| **Where** | The top of a document type. Required. | +| **Value** | An object mapping 1 to 100 property names to property schemas | +| **Since** | protocol version 1 | +| **On update** | Properties may be added, never removed (`IncompatibleDocumentTypeSchemaError`, 10246). An added property is optional, or required with [`requiredSince`](required-since.md). | +| **Errors** | `MissingPositionsInDocumentTypePropertiesError` (10411), `JsonSchemaError` (10101), both at registration | + +`properties` declares every property a document of the type may hold. Each value is the property's schema: its type, its bounds and the Platform keywords that apply to it (see [Property Schemas](property-schemas.md)). + +Rules at registration: + +- A document type has 1 to 100 properties. A nested object has the same limit. +- A property name is 1 to 64 letters, digits or underscores (`^[a-zA-Z0-9_]{1,64}$`). Protocol versions 1 to 13 also admitted `-`; from protocol version 14 it is refused. +- Every property has a [`position`](property-schemas.md#position), a number. The top-level positions must run 0, 1, 2 and so on, with no gap and no number used twice (`MissingPositionsInDocumentTypePropertiesError`, 10411). A document is stored with its properties in `position` order and without their names, so the numbers are what tie the stored bytes to the schema. + +On update, a new property takes the next free position. It is optional unless it is listed in `required` with `requiredSince` set to the version the update creates. A property that already exists can never be removed, since stored documents may hold it. + +## `additionalProperties` + +| | | +|---|---| +| **Where** | The top of a document type (required), and every property of type `object` | +| **Value** | `false`, the only value allowed | +| **Since** | protocol version 1 | +| **On update** | Fixed (10246) | +| **Errors** | `JsonSchemaError` (10101): a document holding a property its type does not declare | + +A document holds only the properties its type declares. Writing `additionalProperties: false` says so; Platform accepts no other value. + +## `required` + +| | | +|---|---| +| **Where** | The top of a document type. A property of type `object` has its own `required` for its members (see [Objects](property-schemas.md#objects)). | +| **Value** | An array of names, none repeated: properties of the type, and the system timestamps and block heights | +| **Default** | Absent: every property is optional and no timestamp is recorded | +| **Since** | protocol version 1 | +| **On update** | May gain only a property the same update adds, annotated with `requiredSince`; may lose nothing (`DataContractInvalidRequiredFieldsUpdateError`, 10276) | +| **Errors** | `JsonSchemaError` (10101): a created or replaced document leaves out a required property | + +`required` names two kinds of thing: + +- **The type's own properties.** Every created or replaced document must hold them. A required property is also stored without the presence byte an optional one carries (unless it is [transient](transient.md)), which is why the list is so hard to change later. +- **System timestamps and block heights**: `$createdAt`, `$updatedAt`, `$transferredAt`, and their `BlockHeight` and `CoreBlockHeight` forms. The writer does not supply these. Listing one tells the platform to record it on every document of the type; one that is not listed is never recorded. See [System Properties](system-properties.md). + +Some keywords only work with a timestamp in the list: [`ttl`](ttl.md) needs `$createdAt`, and a [time-range index](time-range.md) needs the timestamp it buckets. + +On a contract update, from protocol version 14: + +- A property the update adds may be required if it carries `requiredSince` equal to the contract version the update creates. See [requiredSince](required-since.md). +- An existing optional property may not become required. +- Nothing may be removed from the list. +- A system timestamp or block height may not be added. So the set of values a type records is fixed once the type exists. + +Each of these is refused with `DataContractInvalidRequiredFieldsUpdateError` (10276). The `required` list of a nested object is fixed (`IncompatibleDocumentTypeSchemaError`, 10246). + +## `minProperties` and `maxProperties` + +| | | +|---|---| +| **Where** | The top of a document type; also on properties of type `object` | +| **Value** | An integer, 0 or more | +| **Since** | protocol version 1 (declared in the meta-schema from 12) | +| **On update** | Fixed (`IncompatibleDocumentTypeSchemaError`, 10246) | +| **Errors** | `JsonSchemaError` (10101) | + +These are the JSON Schema keywords: a document must hold at least `minProperties` and at most `maxProperties` of its own properties. System properties are not counted. The DashPay `profile` above uses `minProperties: 1` so that an empty profile cannot be written. + +Meta-schema v0, which covered protocol versions 1 to 11, did not list these keywords at the top of a document type, but it did not refuse keys it did not know either. A contract of that time could use them, and its documents were validated against them: the DashPay contract, registered at protocol version 1, uses `minProperties` and `dependentRequired`. From protocol version 12 the meta-schema lists them and checks their values. + +## `dependentRequired` + +| | | +|---|---| +| **Where** | The top of a document type; also on properties of type `object` | +| **Value** | An object mapping a property name to an array of property names | +| **Since** | protocol version 1 (declared in the meta-schema from 12) | +| **On update** | Entries, and names within an entry, may be removed, and so may the whole keyword; nothing may be added (`IncompatibleDocumentTypeSchemaError`, 10246) | +| **Errors** | `JsonSchemaError` (10101) | + +The JSON Schema keyword: when a document holds the property named by a key, it must also hold every property in that key's array. In the example, a profile with an `avatarUrl` must also have an `avatarHash` and an `avatarFingerprint`. Removing an entry only lets more documents through, which is why removal is the only change allowed. + +## `$comment` and `description` + +| | | +|---|---| +| **Where** | The top of a document type; also on any property | +| **Value** | A string | +| **Since** | protocol version 1 | +| **On update** | Free: may be added, changed or removed | +| **Errors** | none | + +Notes for people reading the contract. Consensus does not act on them. + +## `$schema` and `$defs` + +| | | +|---|---| +| **Where** | Added by the platform; a document type does not write them | +| **Value** | `$schema`: the document meta-schema's URL. `$defs`: the contract's `schemaDefs`. | +| **Since** | protocol version 1 | +| **Errors** | `InvalidContractStructure` (10231): a document type that writes either key | + +When Platform reads a document type, it adds two keys before validating the schema: + +- `$schema`, the URL of the document meta-schema, so the schema is checked against it. +- `$defs`, holding the contract's `schemaDefs`: definitions shared by every document type of the contract. A property then refers to one with [`$ref`](property-schemas.md#ref), as `"$ref": "#/$defs/"`. + +A document type that writes either key itself is refused. Definitions live only at the contract level, in `schemaDefs`, and are checked as part of each document type: 1 to 100 of them, each named like a property and each a property schema. A contract update may add definitions but not remove them, and a change to a definition follows the update rules of the keywords it holds (`IncompatibleDataContractSchemaError`, 10213). See [Contract-Level Keys and config](contract-config.md). + +## Limits on the whole type + +- **Name.** A document type's name, its key in `documentSchemas`, is 1 to 64 letters, digits or underscores (`InvalidDocumentTypeNameError`, 10415). Up to protocol version 13 it could also hold `-`. +- **Nesting.** The schema, with the definitions it reaches through `$ref`, nests at most 256 levels of objects and arrays (`DataContractMaxDepthExceedError`, 10200). A `$ref` that does not resolve, or that leads back to itself, is refused (`InvalidJsonSchemaRefError`, 10207). +- **Known keys only.** From protocol version 12 a key the meta-schema does not know is refused at the top of a document type (`JsonSchemaError`, 10101). Before 12 such a key was accepted without being checked. The keys are listed in the [overview](../contract-keywords.md#every-keyword). + +## See also + +- [Property Schemas](property-schemas.md), for what goes inside `properties` +- [System Properties](system-properties.md), for the timestamps `required` can record +- [requiredSince](required-since.md), for adding a required property in an update +- [Evolving a Contract: Adding Required Fields](../data-model/data-contracts.md#evolving-a-contract-adding-required-fields) +- [Document Serialization](../serialization/document-serialization.md#user-defined-properties), for how `position` and `required` shape the stored bytes diff --git a/book/src/contract-keywords/encrypted-for.md b/book/src/contract-keywords/encrypted-for.md new file mode 100644 index 00000000000..945190a7ec2 --- /dev/null +++ b/book/src/contract-keywords/encrypted-for.md @@ -0,0 +1,115 @@ +# encryptedFor + +`encryptedFor` marks a byte array property as ciphertext that one identity can read, and writes the recipe into the contract: who the message is for, which identity keys were used, and which encryption scheme made the bytes. Wallets and SDKs read the recipe from the contract instead of from per-app documentation. Reach for it when a document carries a private message, a private note to self, or any other value only its recipient should read. Consensus cannot see inside the ciphertext: it checks only that the bytes have the length the scheme produces. + +| | | +|---|---| +| **Where** | A byte array property (`byteArray: true`) that is not an identifier, at the top level or inside an object. Not on the elements of a typed array | +| **Value** | An object with exactly four keys, all required: `recipient`, `recipientKey`, `senderKey`, `scheme` (below) | +| **Default** | Absent: the property is plain bytes | +| **Since** | protocol version 14 | +| **On update** | Fixed: adding, removing or changing it is refused (`IncompatibleDocumentTypeSchemaError`, 10246). Documents already written could not be read under another recipe | +| **Errors** | `InvalidEncryptedPropertyShapeError` (10420) on a document; at registration `JsonSchemaError` (10101) or `InvalidContractStructure` (10231) | + +The four keys: + +| Key | Value | +|---|---| +| `recipient` | The dotted path of an identifier property of the same document type, whose value is the recipient identity's id; or `"$ownerId"` for a message the writer encrypts to themself | +| `recipientKey` | The dotted path of an integer property of the same document type that carries the id of the recipient's identity key. Its schema must declare `minimum` of at least 0 and `maximum` of at most 4294967295 | +| `senderKey` | The same for the sender's identity key, a key of the document's owner (`$ownerId`) | +| `scheme` | `"ecdh-secp256k1-aes256-cbc"`, the only scheme today | + +The three paths are 1 to 256 characters each. + +## Example + +```json +"directMessage": { + "type": "object", + "documentsMutable": false, + "properties": { + "recipientId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "recipientKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295, "position": 1 }, + "senderKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295, "position": 2 }, + "encryptedMessage": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 1040, + "encryptedFor": { + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + }, + "position": 3 + } + }, + "required": ["recipientId", "recipientKeyId", "senderKeyId", "encryptedMessage"], + "additionalProperties": false +} +``` + +A message is written by its owner to the identity in `recipientId`. The owner used their key `senderKeyId` and the recipient's key `recipientKeyId`, and the ciphertext is 32 to 1040 bytes, room for a plaintext of up to 1023 bytes. + +## The scheme + +`ecdh-secp256k1-aes256-cbc` is the scheme the DashPay contact request already uses for its encrypted fields: + +1. The shared key is the secp256k1 ECDH of the sender's private key and the recipient's public key: the SHA-256 of the product point's parity byte and x coordinate. The recipient derives the same 32 bytes from their own private key and the sender's public key. +2. The writer draws a random 16-byte IV. +3. The stored value is the IV followed by the plaintext encrypted with AES-256-CBC under the shared key and that IV, with PKCS7 padding. + +A ciphertext is therefore `16 + 16 * ceil((plaintext length + 1) / 16)` bytes: at least 32, and always a multiple of 16. There is no authentication tag, so a reader with the wrong key usually fails the padding check, but about once in 256 attempts gets garbage instead. Readers should treat a value that does not decrypt as a bad message, not as a protocol error. + +The SDKs do this from the declaration. The Rust SDK's `dash_sdk::platform::encrypted_for` module has `encrypt_property` (which also fills in both key id properties) and `decrypt_property`; the JavaScript SDK has `sdk.encryptedFor.encrypt`, `decrypt` and `envelope`. + +## How it works + +- **Create and replace.** After the JSON schema validation, each property that declares `encryptedFor` and is present in the transition is checked for its shape: at least 32 bytes (the IV and one block) and a multiple of 16. A value that is not refuses the transition with `InvalidEncryptedPropertyShapeError` (10420), which names the property, the scheme and the lengths. The schema's own `minItems` and `maxItems` are checked first, so a value outside them gets the schema's error instead. +- **Nothing else is checkable on chain.** Consensus does not know whether the bytes decrypt, whether the key ids exist on the identities, or whether those keys have an encryption purpose. A writer can store any 32 bytes. +- **Checking the keys.** To have consensus check that the keys exist and are of the right kind, add [references](refers-to.md) of type `identityPublicKey` next to the declaration. The moderation charters system contract does this: the recipient's key must be a decryption key and the sender's an encryption key. + +```json +"recipientId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": "decryption" } + }, + "position": 0 +}, +"senderKeyId": { + "type": "integer", "minimum": 0, "maximum": 4294967295, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "keyRequirements": { "purpose": "encryption" } + }, + "position": 2 +} +``` + +`encryptedFor` neither requires nor duplicates these references: it describes the recipe, and the references hold the keys to it. + +## Rules at registration + +- The keyword is allowed only on a byte array that is not an identifier. On an identifier or any other property the meta-schema refuses it (`JsonSchemaError`, 10101), and so it does an unknown key, a missing key or another `scheme`. +- `recipient` must name an identifier property of the document type (an identifier that carries `refersTo` counts) or be `"$ownerId"`. No other `$` name is accepted. +- `recipientKey` and `senderKey` must name integer properties of the document type whose schemas declare `minimum` of at least 0 and `maximum` of at most 4294967295, the range of a key id. System properties are refused. +- None of the three named properties may be `transient` or sit inside a transient object: a transient value is never stored, so a stored ciphertext would lose its recipe. +- The byte array's `maxItems` must be at least 32, the shortest ciphertext the scheme produces. + +A registration refusal from the parser is `InvalidContractStructure` (10231). + +## See also + +- [Encrypted Properties](../data-model/documents.md#encrypted-properties-encryptedfor), the deep dive, with [the scheme's layout](../data-model/documents.md#the-ecdh-secp256k1-aes256-cbc-layout) and [what consensus checks, and what it cannot](../data-model/documents.md#what-consensus-checks-and-what-it-cannot) +- [References (refersTo)](refers-to.md), for `identityPublicKey` references +- [Signing and Keys](signing-keys.md), for encryption and decryption keys bound to a document type +- [transient](transient.md) +- [Contract Keywords overview](../contract-keywords.md), for the conventions of these tables diff --git a/book/src/contract-keywords/history.md b/book/src/contract-keywords/history.md new file mode 100644 index 00000000000..9613be9b572 --- /dev/null +++ b/book/src/contract-keywords/history.md @@ -0,0 +1,143 @@ +# History + +Platform can keep two kinds of history for a document type. `documentsKeepHistory` keeps every version of each document in Drive, under the document itself, so an application can read what a document said at any earlier time. The three `keeps*History` flags record events instead: each transfer, purchase or price update of a document becomes a record in the document history system contract, where it can be queried by document, by contract, by identity and by time. + +## `documentsKeepHistory` + +Keeps every version of every document of the type, not only the latest. + +| | | +|---|---| +| **Where** | document type | +| **Value** | boolean | +| **Default** | the contract config's `documentsKeepHistoryContractDefault`, which is `false` unless the contract says otherwise | +| **Since** | protocol version 1 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212). Adding or removing the key without changing its value is refused too, as a schema change (`IncompatibleDocumentTypeSchemaError`, 10246). | +| **Errors** | `InvalidDocumentTransitionActionError` (10404) for a delete of a document of the type, from protocol version 14 | + +### Example + +```json +"profile": { + "type": "object", + "documentsKeepHistory": true, + "canBeDeleted": false, + "properties": { + "displayName": { "type": "string", "maxLength": 25, "position": 0 }, + "bio": { "type": "string", "maxLength": 140, "position": 1 } + }, + "required": ["displayName"], + "additionalProperties": false +} +``` + +Every edit of a profile adds a version, and every earlier version stays readable. `canBeDeleted: false` has to be written out, since a type that keeps history can never delete and the default is `true`. + +### How it works + +- Each document is stored as a small tree of its own: every version the document has had, keyed by the block time it was written at, and a pointer to the current one. A query by id or through an index sees the current version. +- Every write that changes the document adds a version: a replace, and also a transfer, a price update or a purchase. +- Nothing is ever removed. Drive refuses to delete a document whose type keeps history, so its owner's delete is refused (10404 from protocol version 14; before it, the delete failed inside Drive as an internal error), and the type can have neither moderator deletion nor a `ttl`. +- The `getDocumentHistory` query returns a document's versions from a given time on, each with the block time it was written at, at most 10 per request, and with a proof when asked. +- Every version stays stored, paid for by the write that added it. +- A doctype-level sum or average (`documentsSummable`, `documentsAverageable`) counts only each document's current version. See [Counts, Sums and Averages](aggregates.md). + +### Rules at registration + +All refusals below are `InvalidContractStructure` (10231). + +- From protocol version 14, the type must set `canBeDeleted: false`. A contract registered earlier with both flags on stays readable, and its next update must turn `canBeDeleted` off on that type. See [Deletion](deletion.md). +- Refused together with `ttl`, with `canBeDeletedByModerators` and with `indexOnly`. + +## `keepsTransferHistory` + +Records every transfer of a document of the type in the document history contract. + +| | | +|---|---| +| **Where** | document type | +| **Value** | boolean | +| **Default** | `false` | +| **Since** | protocol version 13 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212) | +| **Errors** | none of its own | + +## `keepsPurchaseHistory` + +Records every purchase of a document of the type in the document history contract. + +| | | +|---|---| +| **Where** | document type | +| **Value** | boolean | +| **Default** | `false` | +| **Since** | protocol version 13 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212) | +| **Errors** | none of its own | + +## `keepsPricingHistory` + +Records every price update of a document of the type in the document history contract. + +| | | +|---|---| +| **Where** | document type | +| **Value** | boolean | +| **Default** | `false` | +| **Since** | protocol version 13 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212) | +| **Errors** | none of its own | + +## The document history contract + +### Example + +```json +"ticket": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "transferable": 1, + "tradeMode": 1, + "keepsTransferHistory": true, + "keepsPurchaseHistory": true, + "keepsPricingHistory": true, + "properties": { + "event": { "type": "string", "maxLength": 63, "position": 0 }, + "seat": { "type": "string", "maxLength": 10, "position": 1 } + }, + "required": ["event", "seat"], + "additionalProperties": false +} +``` + +Each time a ticket is given away, listed or sold, a record of it is written to the document history contract, so anyone can look up who held a ticket, what it was offered at and what it sold for. The DPNS `domain` type sets all three flags from protocol version 13. + +### How records work + +The document history contract is a system contract registered at protocol version 13, with the id `6voHRaoiPcfmMhbqCA9dixH98xcgPQ9UEcuaXjpVu3LD`. It has one document type per flag: + +| Flag | Record type | Written for | Properties | `$ownerId` of the record | +|---|---|---|---|---| +| `keepsTransferHistory` | `transfer` | each transfer | `dataContractId`, `documentTypeName`, `documentId`, `toIdentityId` | the sender | +| `keepsPurchaseHistory` | `purchase` | each purchase | `dataContractId`, `documentTypeName`, `documentId`, `sellerId`, `price` | the buyer | +| `keepsPricingHistory` | `priceUpdate` | each price update | `dataContractId`, `documentTypeName`, `documentId`, `price` | the owner who set the price | + +- The platform writes the record as part of the transition that made the change, so a record exists exactly when the transfer, purchase or price update succeeded. Each record also carries `$createdAt` and `$createdAtBlockHeight`, the block's time and height. +- The identity that signed the action owns the record, and writing it is part of that transition's fees. +- Records can never be changed or deleted, and nobody can create one directly: the record types set `documentsMutable: false`, `canBeDeleted: false` and `creationRestrictionMode: 2`. +- The record types are indexed for lookups by document (`byDocument`: contract, document, time) and by contract (`byContract`: contract, time). Transfers are also indexed by sender (`from`) and recipient (`to`), purchases by buyer (`buyer`), seller (`seller`) and price (`byPrice`). +- They also keep provable aggregates. `purchase` keeps a count, total and average of `price` over all its records, and per contract or per document over a time range. `priceUpdate` keeps a count of all its records, and a count, total and average of asking prices per contract or per document over a time range, where a document listed three times counts three times. `transfer` keeps a count of all its records, and per contract over a time range. See [Counts, Sums and Averages](aggregates.md). +- A flag only records the action the type allows. `keepsTransferHistory` on a type that is not transferable records nothing; no rule ties the flags to `transferable` or `tradeMode`. +- The flags are fixed on update, so an existing type cannot start or stop recording. A type added by an update may set them. + +### Rules at registration + +- An `indexOnly` type may keep no history of either kind. See [Index-Only Types](index-only.md). + +## See also + +- [Creation, Transfers and Trading](ownership-and-trading.md), for the actions the three flags record +- [Deletion](deletion.md) and [Time To Live](ttl.md), for why a type that keeps history can never lose a document +- [Counts, Sums and Averages](aggregates.md), for the aggregates of the history records +- [Contract-Level Keys and config](contract-config.md), for `documentsKeepHistoryContractDefault` and the contract's own `keepsHistory` diff --git a/book/src/contract-keywords/index-only.md b/book/src/contract-keywords/index-only.md new file mode 100644 index 00000000000..c2a6b14cc07 --- /dev/null +++ b/book/src/contract-keywords/index-only.md @@ -0,0 +1,184 @@ +# Index-Only Types + +Some documents are nothing but a position: a like says which post, which hashtag and which identity, and nothing else. Stored as an ordinary document, a like pays for a serialized body, a row in the primary tree and a reference in every index, for a fact its index entries already hold. An **index-only** type stores no body and no row: its index entries are its documents. That cuts the storage of a small document by more than half, and makes each index a uniqueness rule. In exchange, its documents can only be created and deleted, every property must live in an index or in the entry's value, and a query returns documents rebuilt from index entries rather than fetched by `$id`. + +Five keywords shape an index-only type: `indexOnly` and `entryPayload` on the document type, and `terminal`, `preallocated` and `skipIfAbsent` on its indexes. All of them arrived at protocol version 14 and are fixed once the type exists. + +## Example + +A `like` of a social contract whose `post` type cannot be deleted: + +```json +"like": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byHashtagPost", + "properties": [{ "hashtag": "asc" }, { "postId": "asc" }], + "countable": "countable", + "rangeCountable": true, + "rankedCountable": true, + "skipIfAbsent": true + }, + { + "name": "byPost", + "properties": [{ "postId": "asc" }], + "countable": "countable", + "rangeCountable": true, + "rankedCountable": true + }, + { "name": "byLiker", "properties": [{ "$ownerId": "asc" }], "terminal": "postId" } + ], + "properties": { + "hashtag": { "type": "string", "minLength": 1, "maxLength": 63, "position": 0 }, + "postId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "post", + "propertyAgreement": { "hashtag": "hashtag" } + }, + "position": 1 + } + }, + "required": ["postId"], + "additionalProperties": false +} +``` + +`byPost` holds one entry per post and liker (its terminal defaults to `$ownerId`), so an identity can like a post once, and it counts and ranks posts by likes. `byHashtagPost` ranks the posts under each hashtag, and only likes that carry a hashtag enter it. `byLiker` lists the posts one identity liked. The reference makes sure the post exists and that a like's hashtag is its post's. + +## `indexOnly` + +| | | +|---|---| +| **Where** | document type | +| **Value** | boolean | +| **Default** | `false` | +| **Since** | protocol version 14 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212) | +| **Errors** | `DuplicateUniqueIndexError` (40105), `DocumentNotFoundError` (40101), `InvalidDocumentTransitionActionError` (10404) | + +`true` stores the type's documents only as index entries. Each entry sits under the index's values, is keyed by the terminal's values in place of the document id, and holds a 32-byte **row commitment**: a hash over all of the document's values that ties its entries in the different indexes together as one document. + +**Create.** A create writes one entry into each index. If any of those entries already exists the create is refused with `DuplicateUniqueIndexError` (40105), so every index is a uniqueness rule over its properties and its terminal. `refersTo` and the other property checks run as on any document. + +**Delete.** A document has no id to delete by. It is deleted with an `indexOnlyDelete` transition that carries all of its values (and `$createdAt` when the type requires it); every entry those values produce must exist and carry the matching row commitment, or the delete is refused with `DocumentNotFoundError` (40101). Deleting by id on an index-only type, or with `indexOnlyDelete` on an ordinary type, is refused with `InvalidDocumentTransitionActionError` (10404). The owner can only ever reach its own entries, since every index holds `$ownerId`. `canBeDeleted: false` forbids deletes as on any type. + +**No other action.** A document cannot be replaced, transferred, sold or repriced. + +**Queries.** A query goes through one index and returns documents rebuilt from its entries: the index's properties, the terminal, and `$ownerId` and `$createdAt` where the index holds them. A query through an index that holds only some of the properties yields only those. The rebuilt `$id` is a hash of the entry's position and addresses nothing, so there is no fetch by `$id` and no `startAt` cursor; a query pages by the terminal instead (`postId > `, with a limit). The proof that a create or delete took effect is the presence or absence of its entry in the **proof index**, an index that involves no `$createdAt` and does not skip. + +Rules at registration: + +- `documentsMutable: false`; `transferable` `0`; `tradeMode` `0`; no `documentsKeepHistory` or `keeps*History`; no `transient` properties. +- None of the document type aggregate keywords (`documentsCountable` and the others): the type has no primary tree. The index keywords of [Counts, Sums and Averages](aggregates.md) and [Ranked Indexes](ranked.md) are allowed. +- At least one index. No index is `unique` or `contested`, and none sets `nullSearchable: false`. +- Every index holds `$ownerId`, as a property or in its terminal. +- The only system properties an index may list are `$ownerId` and `$createdAt`, and an indexed `$createdAt` must be in `required`. +- At least one index involves no `$createdAt` and does not set `skipIfAbsent`: the proof index. +- Every property is in `required`, except the first property of a `skipIfAbsent` index. An object holding an indexed property is required too. +- Every property appears in at least one index that does not skip, as a property or a terminal component, except the `entryPayload` properties and a skip index's first property. +- The type cannot also set [`ttl`](ttl.md) or `canBeDeletedByModerators`, and a `refersTo` lookup cannot target it. + +## `entryPayload` + +| | | +|---|---| +| **Where** | document type | +| **Value** | array of 1 to 16 distinct property names, each 1 to 64 characters | +| **Default** | absent | +| **Since** | protocol version 14 | +| **On update** | Fixed (40212). The list is read as a set, so reordering it is no change. | + +The properties stored in each entry's value, after the row commitment, instead of in a key. They are for data the application reads but never queries by, such as a public key or a ciphertext: they need not be indexed, and they come back with every query result. + +```json +"indices": [{ "name": "byRequest", "terminal": ["appEphemeralPubKeyHash", "$ownerId"] }], +"entryPayload": ["walletEphemeralPubKey", "encryptedPayload"] +``` + +With a flat index keyed by a request hash and the responder, this is a key-value table: a query on the hash returns every responder with its public key and ciphertext. + +Rules at registration: + +- Only on an `indexOnly` type. +- Each entry names a top-level property that is `required`, a scalar (not an object or an array of values), and bounded: `maxLength` on a string, `maxItems` on a byte array. +- A payload property appears in no index, neither as a property nor in a terminal. +- The largest size each payload property can take, plus two bytes each, adds up to at most 5120 bytes. With several indexes, the payload is repeated in every entry. + +## `terminal` + +| | | +|---|---| +| **Where** | index of an `indexOnly` type | +| **Value** | a property name, or an array of 1 to 10 distinct names | +| **Default** | `"$ownerId"` | +| **Since** | protocol version 14 | +| **On update** | Fixed, like every index (`DataContractInvalidIndexDefinitionUpdateError`, 10217) | + +Where an ordinary index keys each entry by the document id, an index-only index keys it by the terminal's values: the **member key**. There is one entry per index values and member key, so the terminal decides what the index makes unique. `byPost` above, with the default terminal, allows one like per post and owner; `byLiker`, with `postId` as terminal under `$ownerId`, holds the same pairs the other way round. + +An array is a composite terminal whose values are joined in order. An index with no `properties` at all is a **flat** index, keyed by its terminal alone, as `byRequest` above is. + +A query that fixes every property of the index can test one member key ("did I like this post") or walk the member keys in order, a page at a time. + +Rules at registration: + +- Only on an `indexOnly` type. +- Each component is `$ownerId` or a property of the type that could be indexed: not an object or an array of values, a string with `maxLength` of at most 63, a byte array with `maxItems` of at most 255. No other system property. +- Every component but the last has a fixed width: a byte array with `minItems` equal to `maxItems`, an identifier, an integer or a boolean. A string can only be last. +- The whole member key is at most 255 bytes. On a flat index, the level key, the component names each preceded by a zero byte, is at most 255 bytes as well. +- A component is not one of the index's `properties`, and not an optional property. +- A flat index takes no count, sum, ranking, `timeRange`, `skipIfAbsent` or `preallocated` keyword. + +## `preallocated` + +| | | +|---|---| +| **Where** | index of an `indexOnly` type | +| **Value** | boolean | +| **Default** | `false` | +| **Since** | protocol version 14 | +| **On update** | Fixed (10217) | + +The first entry under a new set of values pays for every tree on its path; later ones pay for one entry. When the whole path is decided by a referenced document, `preallocated: true` creates the trees when that document is created, paid by its creator, so every entry costs the same from the first one on. Deleting the last entry keeps the trees, so a post with no likes still shows in the rankings with a count of zero. + +In the example, `byHashtagPost` could be preallocated: `postId` is the reference and `hashtag` agrees with the post's. `byLiker` could not, since no post decides who likes it. + +Rules at registration: + +- Only on an `indexOnly` type. +- Every index property is either a property with a `permanentDocument` reference to a type of the same contract, or a key of that reference's `propertyAgreement`. A `deletableDocument` reference does not qualify, since the trees would outlive a deleted target. `$ownerId` may only be the terminal. +- Not with `timeRange`. + +## `skipIfAbsent` + +| | | +|---|---| +| **Where** | index of an `indexOnly` type | +| **Value** | boolean | +| **Default** | `false` | +| **Since** | protocol version 14 | +| **On update** | Fixed (10217) | + +A document that leaves out the index's first property writes no entry into this index, and its delete looks for none. The index then holds only the documents that carry the property, and its counts and rankings are "among the documents that have it". It is the one way a property of an index-only type can be optional: in the example, a like without a hashtag is not in `byHashtagPost` and pays nothing for it. A present but empty value is not absent and is indexed. + +A query only uses a skip index when it constrains or orders by the index's first property, so a query cannot silently miss the documents that lack it. + +Rules at registration: + +- Only on an `indexOnly` type. +- The first property is a top-level property of the type, not a system property, and not listed in `required`. +- Every index that involves an optional property sets `skipIfAbsent` and lists that property first; an optional property is never a terminal. + +## See also + +- [Index-Only Document Types](../drive/index-only-document-types.md) for the entry layout, the row commitment, the full constraint list and the query surface. +- [Indexes](indexes.md), [Counts, Sums and Averages](aggregates.md) and [Ranked Indexes](ranked.md) for the index keywords an index-only type uses. +- [References (refersTo)](refers-to.md) for `permanentDocument` references and `propertyAgreement`, which `preallocated` relies on. +- [Mutability](mutability.md), [Deletion](deletion.md) and [Creation, Transfers and Trading](ownership-and-trading.md) for the flags an index-only type must set. diff --git a/book/src/contract-keywords/indexes.md b/book/src/contract-keywords/indexes.md new file mode 100644 index 00000000000..918eb584d12 --- /dev/null +++ b/book/src/contract-keywords/indexes.md @@ -0,0 +1,176 @@ +# Indexes (indices) + +A document type's `indices` list says which queries its documents can answer and which values must be unique. Without an index, a query can only address a document by its `$id`. With one, Drive keeps the documents sorted by the index's properties, so a query that fixes those properties reaches the matching documents directly instead of reading the whole type. Every index costs storage and processing on each write, and an index can never be added, removed or changed once the document type exists, so a contract author decides them before the document type is registered. + +This chapter covers the four keywords every index uses: `name`, `properties`, `unique` and `nullSearchable`. The other index keywords, for contests, counts, sums, rankings, time windows and index-only types, have their own chapters (see [More index keywords](#more-index-keywords)). + +## Example + +```json +"post": { + "type": "object", + "indices": [ + { "name": "byOwnerTime", "properties": [{ "$ownerId": "asc" }, { "$createdAt": "asc" }] }, + { "name": "bySlug", "properties": [{ "$ownerId": "asc" }, { "slug": "asc" }], "unique": true }, + { "name": "byTopic", "properties": [{ "meta.topic": "asc" }], "nullSearchable": false } + ], + "properties": { + "slug": { "type": "string", "minLength": 1, "maxLength": 63, "position": 0 }, + "text": { "type": "string", "maxLength": 280, "position": 1 }, + "meta": { + "type": "object", + "properties": { + "topic": { "type": "string", "minLength": 1, "maxLength": 32, "position": 0 } + }, + "additionalProperties": false, + "position": 2 + } + }, + "required": ["$createdAt", "slug", "text"], + "additionalProperties": false +} +``` + +`byOwnerTime` lists one author's posts in the order they were written. `bySlug` lets each author use a slug once: a second post by the same owner with the same slug is refused. `byTopic` finds posts by a property nested inside `meta`, and leaves out posts that have no topic. + +## `indices` + +| | | +|---|---| +| **Where** | document type | +| **Value** | array of 1 to 10 index objects | +| **Default** | absent: the type has no index, and its documents can only be addressed by `$id` | +| **Since** | protocol version 1 | +| **On update** | Fixed: an index may not be added, removed or changed (`DataContractInvalidIndexDefinitionUpdateError`, 10217) | +| **Errors** | `DuplicateUniqueIndexError` (40105) | + +Each entry of `indices` is one index. Drive builds a tree for it when the contract is registered and maintains it on every create, replace, transfer, purchase, price update and delete of a document of the type. + +A query uses an index when its `where` clauses fix the index's leading properties, in order, and it orders by the properties that follow. An index on `[a, b]` answers `a == x`, `a == x AND b == y`, and `a == x` ordered by `b`, but not `b == y` alone. The query picker and the tree layout are described in [Indexes](../drive/indexes.md#query-traversal). + +Rules at registration: + +- At most 10 indexes, and at least one when the key is present (an empty `indices` array is refused by the meta-schema). +- No two indexes may have the same properties in the same order (`DuplicateIndexError`, 10201). +- At most one index may be contested, and a type with a contested index may have no other unique index (`ContestedUniqueIndexWithUniqueIndexError`, 10249). See [Contested Indexes](contested.md). + +## `name` + +| | | +|---|---| +| **Where** | index | +| **Value** | string, 1 to 32 characters | +| **Default** | none: required | +| **Since** | protocol version 1 | +| **On update** | Fixed: indexes are compared by name, so renaming an index is a removal plus an addition (10217) | +| **Errors** | `DuplicateIndexNameError` (10211) at registration | + +The name identifies the index in queries that name one, in error messages and in the contract update rule. Two indexes of one document type may not share a name (`DuplicateIndexNameError`, 10211). + +## `properties` + +| | | +|---|---| +| **Where** | index | +| **Value** | array of 1 to 10 objects, each `{ "": "asc" }` with exactly one key | +| **Since** | protocol version 1 | +| **On update** | Fixed (10217) | +| **Errors** | `UndefinedIndexPropertyError` (10209), `SystemPropertyIndexAlreadyPresentError` (10208), `InvalidIndexPropertyTypeError` (10206), `InvalidIndexedPropertyConstraintError` (10205), all at registration | + +The indexed properties, in order. The order matters: a query uses the index through a prefix of the list. The only sort order the meta-schema accepts is `"asc"`; a query may still walk an index in descending order. + +What may be indexed: + +- **A top-level property** of the type, by its name. +- **A property inside an object**, by its dotted path. DPNS indexes `records.identity`, the `identity` property of a domain's `records` object. +- **System properties**: `$ownerId`, `$createdAt`, `$updatedAt`, `$transferredAt`, their `*BlockHeight` and `*CoreBlockHeight` variants, and `$creatorId` on a type that records it (see [System Properties](system-properties.md)). A timestamp or block height is only recorded when the type lists it in `required`; an index on one that is not required holds every document under null. +- **Not `$id`**, which the document type's primary tree already indexes (`SystemPropertyIndexAlreadyPresentError`, 10208). + +What each indexed property must be, because its value becomes a GroveDB key of at most 255 bytes: + +- A property the type defines (`UndefinedIndexPropertyError`, 10209). +- Not an object, an array of values or a typed array (`InvalidIndexPropertyTypeError`, 10206). A byte array, including an identifier, is fine. +- A string must declare `maxLength` of at most 63, since a character can take four bytes. A byte array must declare `maxItems` of at most 255. A missing or larger bound is refused (`InvalidIndexedPropertyConstraintError`, 10205). An index with a ranking has tighter bounds (see [Ranked Indexes](ranked.md)). +- From protocol version 14, not a transient property, nor one inside a transient object: its value is never stored, so the index would never hold it. See [transient](transient.md). + +## `unique` + +| | | +|---|---| +| **Where** | index | +| **Value** | boolean | +| **Default** | `false` | +| **Since** | protocol version 1 | +| **On update** | Fixed (10217) | +| **Errors** | `DuplicateUniqueIndexError` (40105) | + +On a unique index no two documents may hold the same values for all of the index's properties. A create, replace, transfer, purchase or price update that would make a second document with the same values is refused with `DuplicateUniqueIndexError` (40105); so is a moderator's restore of a deleted document whose values have been taken since. A replace that leaves the indexed values as they were is not held against the document itself. + +A document that leaves any of the indexed properties out is not held to the index: uniqueness cannot be decided on a missing value, so two such documents may coexist. Make the properties `required` when every document must be unique. + +Uniqueness is per index. `bySlug` above is unique over the pair (`$ownerId`, `slug`), so two authors may use the same slug; a unique index on `slug` alone would make each slug global. + +A unique index with a [`timeRange`](time-range.md) treats two documents in the same window as equal on the bucketed property. A unique index cannot carry a ranking. + +## `nullSearchable` + +| | | +|---|---| +| **Where** | index | +| **Value** | boolean | +| **Default** | `true` | +| **Since** | protocol version 1 | +| **On update** | Fixed (10217) | + +With the default, a document whose indexed properties are all missing is still entered in the index, under null, so a query for the null value finds it. `false` leaves such a document out of this index: it exists, and other indexes and `$id` still reach it, but this index does not. A document with only some of the properties missing is entered either way. + +DPNS sets `"nullSearchable": false` on its `records.identity` index, so domains that point at no identity take no room in it. + +`nullSearchable: false` is refused on an index with a ranking or a `timeRange`, and on an index of an index-only type. + +## Null handling + +A property is null in an index when the document leaves it out. Putting the rules above together: + +| The document's indexed values | Entered in the index? | Held to `unique`? | +|---|---|---| +| all present | yes | yes | +| some missing | yes, under null for the missing ones | no | +| all missing | only if `nullSearchable` is `true` | no | + +The storage shape of each case is in [Null Handling](../drive/indexes.md#null-handling). + +## Changing indexes + +Drive builds an index's trees when the document type is registered and never backfills them, so an index added later would miss every existing document, and a removed or changed one would leave orphaned trees. A contract update may therefore not add, remove or change any index of an existing document type. From protocol version 14 the update is refused with `DataContractInvalidIndexDefinitionUpdateError` (10217), naming the first index that differs. Indexes are compared by name: reordering the `indices` array is no change, and renaming an index is a removal plus an addition. Earlier protocol versions refused every such change as well, though not always with this error. + +A document type that the update adds may declare any indexes, as a new contract may. + +## Limits + +| Limit | Value | +|---|---| +| Indexes per document type | 10 | +| Properties per index | 10 | +| Characters in an index name | 32 | +| Characters in an index property path | 256 | +| `maxLength` of an indexed string | 63 (lower on a ranked index) | +| `maxItems` of an indexed byte array | 255 (lower on a ranked index) | +| Contested indexes per document type | 1 | + +## More index keywords + +An index entry may carry more keywords, each with its own chapter: + +- [Contested Indexes](contested.md): `contested` turns a unique index into a scarce resource that masternodes award by vote, the way DPNS gives out names. +- [Counts, Sums and Averages](aggregates.md): `countable`, `summable`, `averageable` and their `range*` forms keep totals per indexed value, so counts, sums and averages are read without walking the documents. +- [Ranked Indexes](ranked.md): `rankedCountable`, `rankedSummable` and `rankedAverageable` order the indexed values by those totals, for "top 10" queries with proofs. +- [Time-Range Indexes](time-range.md): `timeRange` groups documents into time windows, for "trending this hour" queries. +- [Index-Only Types](index-only.md): `terminal`, `preallocated` and `skipIfAbsent` shape the indexes of a type whose documents live only in their indexes. + +## See also + +- [Indexes](../drive/indexes.md) in the Drive part: the index trie, the GroveDB layout and the query picker. +- [Contested Indexes](contested.md), [Counts, Sums and Averages](aggregates.md), [Ranked Indexes](ranked.md), [Time-Range Indexes](time-range.md), [Index-Only Types](index-only.md). +- [System Properties](system-properties.md) for what `$ownerId`, `$createdAt` and the others hold. +- [Contract Keywords](../contract-keywords.md) for the conventions of these chapters. diff --git a/book/src/contract-keywords/max-bytes.md b/book/src/contract-keywords/max-bytes.md new file mode 100644 index 00000000000..fcc6b54fcbc --- /dev/null +++ b/book/src/contract-keywords/max-bytes.md @@ -0,0 +1,61 @@ +# maxBytes + +`maxBytes` caps how many bytes a string may take when it is encoded as UTF-8, which is how Platform stores it. JSON Schema's `maxLength` counts characters, and one character takes from one to four bytes, so `maxLength` alone does not bound the stored size. Reach for `maxBytes` when the size of a document matters: to keep storage fees predictable, or to stay under the 5120 bytes any one stored value may take. + +| | | +|---|---| +| **Where** | A string property, at the top level or inside an object; or the `items` of a typed array of strings, where it bounds every element | +| **Value** | An integer from 1 to 65535, no lower than the property's `minLength` | +| **Default** | Absent: only `maxLength` and the 5120-byte cap on every value apply | +| **Since** | protocol version 14 | +| **On update** | May be raised or removed; adding it or lowering it is refused (`IncompatibleDocumentTypeSchemaError`, 10246) | +| **Errors** | `DocumentPropertyMaxBytesExceededError` (10421) on a document; at registration `JsonSchemaError` (10101) or `InvalidContractStructure` (10231) | + +## Example + +```json +"description": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "maxBytes": 4096, + "position": 1 +} +``` + +This is the `description` of a proposal in the moderation charters system contract. `maxLength` admits 4096 characters, which could take up to 16384 bytes (more than the 5120-byte cap on a stored value); `maxBytes` holds the stored text to 4096 bytes, so a description in plain ASCII can use every character and one written only in four-byte characters, such as most emoji, a quarter of them. + +On a typed array the keyword goes on `items`: + +```json +"tags": { + "type": "array", + "maxItems": 8, + "items": { "type": "string", "minLength": 1, "maxLength": 32, "maxBytes": 64 }, + "position": 2 +} +``` + +Each of up to 8 tags is at most 32 characters and at most 64 bytes. + +## How it works + +- The check runs wherever a document's properties are validated: every create and replace in consensus, and every client that validates a document before sending it. +- It runs after the JSON schema validation. A value the schema refuses (too many characters, the wrong type) is reported with the schema's error, not this one. +- Every string the document holds for a property that declares `maxBytes` is measured in UTF-8 bytes. One that is longer refuses the transition with `DocumentPropertyMaxBytesExceededError` (10421), which names the property and both lengths. For a typed array the error names the element, as in `tags[2]`. +- A property the document leaves out is not checked. + +The cap bounds the value only; `maxLength` and `minLength` still apply in characters. Setting both is normal: `maxLength` says what a user may type, `maxBytes` what the platform stores. + +## Rules at registration + +- The keyword is allowed only on a string property or on the string `items` of a typed array. On any other property, the typed array itself included, the meta-schema refuses it (`JsonSchemaError`, 10101). +- The value is an integer from 1 to 65535 (10101). +- It may not be lower than `minLength`: a string of `minLength` characters takes at least that many bytes, so a lower cap would refuse every value (`InvalidContractStructure`, 10231). + +## See also + +- [Byte Caps on Strings](../data-model/documents.md#byte-caps-on-strings-maxbytes), the deep dive +- [Property Schemas](property-schemas.md), for `maxLength` and `minLength` +- [Typed Arrays](typed-arrays.md), for keywords on `items` +- [Contract Keywords overview](../contract-keywords.md), for the 5120-byte value limit and the conventions of these tables diff --git a/book/src/contract-keywords/mutability.md b/book/src/contract-keywords/mutability.md new file mode 100644 index 00000000000..ff8350f2aca --- /dev/null +++ b/book/src/contract-keywords/mutability.md @@ -0,0 +1,155 @@ +# Mutability + +These three keywords decide what a replace may change once a document exists. A replace is the transition an owner sends to overwrite a document with a new version of it. `documentsMutable` turns replaces on or off for the whole document type. `immutable` freezes chosen properties while the rest of the document stays editable, and `immutableAllowSetting` lets some of those frozen properties be filled in once, later, when they were left empty at creation. + +None of the three governs deletion, transfers or trading: see [Deletion](deletion.md) and [Creation, Transfers and Trading](ownership-and-trading.md). + +## `documentsMutable` + +Whether the owner of a document may replace it. Set it to `false` for records that must never change after they are written: votes, receipts, name registrations. + +| | | +|---|---| +| **Where** | document type | +| **Value** | boolean | +| **Default** | the contract config's `documentsMutableContractDefault`, which is `true` unless the contract says otherwise | +| **Since** | protocol version 1 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212). Adding or removing the key without changing its value is refused too, as a schema change (`IncompatibleDocumentTypeSchemaError`, 10246). | +| **Errors** | `InvalidDocumentTransitionActionError` (10404) for a replace of a type set to `false` | + +### Example + +```json +"vote": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "proposalId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "choice": { "type": "string", "enum": ["yes", "no", "abstain"], "position": 1 } + }, + "required": ["proposalId", "choice", "$createdAt"], + "additionalProperties": false +} +``` + +A vote is cast once and stays as cast: no replace is accepted, and with `canBeDeleted: false` its owner cannot take it back either. + +### How it works + +- With `true`, the document's owner may replace it. The replace carries the whole new document, which is validated against the schema as a create is, and a `$revision` one higher than the stored one (`InvalidDocumentRevisionError`, 40106). Anyone other than the owner is refused (`DocumentOwnerIdMismatchError`, 40102). When the type lists `$updatedAt` in `required`, the replace sets it to the block's time, and likewise `$updatedAtBlockHeight` and `$updatedAtCoreBlockHeight` to the block heights. +- With `false`, every replace is refused (`InvalidDocumentTransitionActionError`, 10404). +- A type whose documents cannot be replaced may still let them be transferred or sold (`transferable`, `tradeMode`). Such a document changes owner and price, but its owner can never edit its properties. The DPNS `domain` type works this way. +- A document stores a `$revision` when its type allows a replace, a transfer or trading. A type that allows none of them stores none. See [System Properties](system-properties.md). + +### Rules at registration + +- A contested index needs a type whose documents cannot be replaced (`ContestedUniqueIndexOnMutableDocumentTypeError`, 10248). See [Contested Indexes](contested.md). +- An `indexOnly` type must set `documentsMutable: false`. See [Index-Only Types](index-only.md). +- `immutable` and `immutableAllowSetting` are only accepted when the type's documents are mutable (`InvalidContractStructure`, 10231). +- `canBeDeletedByModeratorsFor` on a mutable type needs `$updatedAt` in `required`. See [Deletion](deletion.md). + +## `immutable` + +The top-level properties that are frozen when a document is created, on a type whose documents can otherwise be replaced. Reach for it when most of a document is editable but some of it is a commitment: the shop an order was placed with, the author of a post, the item that was ordered. + +| | | +|---|---| +| **Where** | document type, on a type with `documentsMutable: true` | +| **Value** | array of top-level property names, no repeats | +| **Default** | empty: every property may change | +| **Since** | protocol version 14 | +| **On update** | May gain entries, never lose one (`DocumentTypeUpdateError`, 40212) | +| **Errors** | `DocumentImmutablePropertyChangedError` (40128) for a replace that changes, adds or removes a listed property | + +### Example + +```json +"order": { + "type": "object", + "documentsMutable": true, + "properties": { + "shop": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "item": { "type": "string", "maxLength": 100, "position": 1 }, + "status": { "type": "string", "enum": ["open", "paid", "shipped"], "position": 2 }, + "trackingCode": { "type": "string", "maxLength": 40, "position": 3 } + }, + "required": ["shop", "item", "status"], + "immutable": ["shop", "item", "trackingCode"], + "immutableAllowSetting": ["trackingCode"], + "additionalProperties": false +} +``` + +The buyer can move `status` along as often as needed, but never change which shop or which item the order is for. `trackingCode` is left out when the order is placed, may be filled in by one later replace, and is frozen from then on. + +### How it works + +- On every replace, each listed property of the new document is compared with the stored one. A property that differs is refused with `DocumentImmutablePropertyChangedError` (40128). "Differs" covers a changed value, a value the stored document did not have (unless `immutableAllowSetting` allows it, below), and a value the replace leaves out. +- Values are compared by their data, not their bytes: the order of an object's members and the width an integer is stored in do not count as changes. +- Freezing an object freezes everything inside it. +- One change is always allowed: a replace may clear a listed `deletableDocument` reference by id once the document it points to has been deleted. Every replace checks such a reference again, so without this the document could never be replaced again. See [References](refers-to.md). +- Transfers, price updates and purchases carry no property values, so the list does not affect them. + +### Rules at registration + +All refusals below are `InvalidContractStructure` (10231). + +- Only on a type whose documents are mutable. On a type with `documentsMutable: false` every property is already frozen. +- Every entry names a declared top-level property. System properties (`$ownerId`, `$createdAt` and the rest) are refused, since the platform manages them. Nested paths such as `meta.author` are refused: list the object that contains them. +- No entry may be a `transient` property: it is never stored, so every replace that supplies it would count as a change. +- An immutable property may not hold a `deletableDocument` reference that a replace could not clear once its target is gone: a typed array of them, one inside an object, or one found through a `lookup`. A single reference by id held directly by the property is allowed. +- On a type whose documents can be transferred or traded, an immutable property may not hold a `contract` reference whose `contractRequirements` has an `owner` requirement: after a change of owner the new owner could neither meet it nor repoint it. + +### On update + +The list may grow: an update may freeze a property that was editable, and documents already stored keep the values they have. It may never shrink, since documents were written on the promise that those properties would not change. The comparison is of the parsed lists, so reordering them is no change. + +## `immutableAllowSetting` + +The `immutable` properties that a replace may still set while the stored document has no value for them. It is for optional values that are not known when the document is created, like a tracking code or a closing date, and must not change once they are known. + +| | | +|---|---| +| **Where** | document type, next to `immutable` | +| **Value** | array of property names, each also listed in `immutable`, no repeats | +| **Default** | empty | +| **Since** | protocol version 14 | +| **On update** | May lose entries at any time. May gain an entry only for a property that becomes immutable in the same update (`DocumentTypeUpdateError`, 40212). | +| **Errors** | `DocumentImmutablePropertyChangedError` (40128) for a replace that changes or removes the property once it holds a value | + +### How it works + +- While the stored document has no value for the property, a replace may set it. That first value is then frozen like the rest of the `immutable` list: it can neither change nor be removed. +- It only means something for an optional property. A required one always has a value from creation. + +### Rules at registration + +- Every entry must also be in `immutable` (`InvalidContractStructure`, 10231). +- An entry may not be a `deletableDocument` reference by id (`InvalidContractStructure`, 10231). Such a reference may be cleared once its target is deleted, and the next replace could then set it again to a different document. + +### On update + +Dropping an entry tightens the rule and is always allowed. Adding one to a property that was already immutable would let documents change what they were promised to keep, so it is only allowed together with making the property immutable in the same update. + +## See also + +- [Immutable Properties on Mutable Document Types](../data-model/documents.md#immutable-properties-on-mutable-document-types), for how the replace compares values +- [Deletion](deletion.md), [Creation, Transfers and Trading](ownership-and-trading.md) and [History](history.md), the other keywords on what may happen to a document +- [System Properties](system-properties.md), for `$revision` and `$updatedAt` +- [transient](transient.md) and [References](refers-to.md), for the properties `immutable` refuses +- [Contract Keywords](../contract-keywords.md#reading-the-chapters), for how the summary tables read diff --git a/book/src/contract-keywords/owner-refers-to.md b/book/src/contract-keywords/owner-refers-to.md new file mode 100644 index 00000000000..4cd3b39b94a --- /dev/null +++ b/book/src/contract-keywords/owner-refers-to.md @@ -0,0 +1,123 @@ +# Writer and Creator References + +A property's [`refersTo`](refers-to.md) judges a value the writer chose. `ownerRefersTo` and `creatorRefersTo` judge an identity of the document instead: its writer (`$ownerId`) or its creator (`$creatorId`). Each takes the same declaration a property's `refersTo` takes, limited to the targets an identity's id can be. Reach for them to say who may write a document of a type: "only a member of this team", "only someone with a profile". A type declares at most one of the two: `ownerRefersTo` when its documents stay with their owner, `creatorRefersTo` when they can be transferred or traded. + +Both are checked the way a property's reference is: the same targets, keys, errors and replace rules, with the identity as the value. They count as one reference each (times the leaves of an expression) against the [reference budget](refers-to.md#the-reference-budget), and the check runs before the properties' references. + +## `ownerRefersTo` + +| | | +|---|---| +| **Where** | The document type, at the top level of its schema. Only on a type whose documents can be neither transferred nor traded. | +| **Value** | A `refersTo` declaration whose value is the writer: `identity`, a `permanentDocument` or `deletableDocument` with a [`lookup`](refers-to-lookup.md), a [`listElement`](refers-to-list-element.md), or an [`anyOf` or `allOf`](refers-to-expressions.md) whose leaves are all of these. | +| **Since** | protocol version 14 | +| **On update** | Fixed: adding, removing or changing it is refused (`IncompatibleDocumentTypeSchemaError`, 10246). | +| **Errors** | The error the target reports for a property, at the path `$ownerId`: `ReferencedEntityNotFoundError` (40120) when a lookup finds nothing or the writer is not in the list, `ReferencedDocumentPropertyMismatchError` (40127) for an agreement pair. | + +### Example + +```json +"post": { + "type": "object", + "ownerRefersTo": { + "type": "deletableDocument", + "contractId": "Bwr4WHCPz5rFVAD87RqTs3izo4zpzwsEdKPWUT1NS1C7", + "documentType": "profile", + "lookup": { "index": "ownerId", "keys": { "$ownerId": "." } } + }, + "properties": { + "text": { "type": "string", "minLength": 1, "maxLength": 280, "position": 0 } + }, + "required": ["text"], + "additionalProperties": false +} +``` + +Only an identity with a DashPay profile may create a post. In the lookup, `"."` is the writer: DashPay's unique `ownerId` index must find a `profile` owned by them. Profiles can be deleted, so the target is a `deletableDocument` one and every replace asks again: a writer whose profile is gone can no longer edit their posts, though they can still delete them. + +The moderation charters contract's `resignationRequest` combines two targets: its writer must be one of the elected `members` of the charter the request names, or have an `addedModerator` document for it that exists now. The declaration is shown under [`anyOf`](refers-to-expressions.md#anyof). + +### How it works + +- **On create**, the writer is checked against the target exactly as a property's value would be. An `identity` target always holds and reads nothing: the transition has already proved that the writer exists. +- **In a lookup**, `"."` is the writer, and so is a `"$ownerId"` key part. In a `propertyAgreement`, a pair keyed by `$ownerId` names the same writer. +- **On replace**, the declaration is checked again when a property its lookup or an agreement pair reads changed, and on every replace when it has a pair keyed by `$ownerId` or a `deletableDocument` lookup (alone or as a leaf). Otherwise nothing is read: the writer is always the owner, a permanent target is never deleted and its key never moves. +- **Transfers and purchases** cannot happen on such a type, so the owner of every document is a writer that was checked. +- A refusal names `$ownerId` as its path. Registration errors name the declaration `.$ownerId`. + +### Rules at registration + +- The document type's documents can be neither transferred nor traded (`transferable` and `tradeMode` absent or `0`). Otherwise a transfer or a purchase, which is not a write, would hand a document to an owner the declaration never checked; declare `creatorRefersTo` instead. +- The target is one the writer's id can be. `contract`, `token` and a document by id are refused, since an identity's id is never one of those ids, and so is `identityPublicKey`, which pairs the value with a key id the writer does not carry. The same holds for every leaf of an expression. +- Every other rule is a property reference's: those of the [lookup](refers-to-lookup.md#rules-at-registration), the [list](refers-to-list-element.md#rules-at-registration) and each [`propertyAgreement`](refers-to.md#propertyagreement), checked against another contract's stored type where the declaration names one. + +A malformed declaration is refused by the meta-schema (`JsonSchemaError`, 10101) or the parser (`InvalidContractStructure`, 10231); one that cannot hold, with the reference errors of its target (see [Errors](refers-to.md#errors)). + +## `creatorRefersTo` + +| | | +|---|---| +| **Where** | The document type, at the top level of its schema. Only on a type that records creator ids: a transferable or tradeable type of a format-1 contract (see [System Properties](system-properties.md)). | +| **Value** | A `refersTo` declaration whose value is the creator: `identity`, a `permanentDocument` with a [`lookup`](refers-to-lookup.md), a [`listElement`](refers-to-list-element.md), or an [`anyOf` or `allOf`](refers-to-expressions.md) whose leaves are all of these. | +| **Since** | protocol version 14 | +| **On update** | Fixed: adding, removing or changing it is refused (`IncompatibleDocumentTypeSchemaError`, 10246). | +| **Errors** | The error the target reports for a property, at the path `$creatorId`: `ReferencedEntityNotFoundError` (40120), `ReferencedDocumentPropertyMismatchError` (40127). | + +### Example + +```json +"moderatorBadge": { + "type": "object", + "transferable": 1, + "creatorRefersTo": { + "type": "listElement", + "contractId": "EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88", + "documentType": "electedCharter", + "propertyAgreement": { "electedCharterId": "$id" }, + "inList": "members" + }, + "properties": { + "electedCharterId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + } + }, + "required": ["electedCharterId"], + "additionalProperties": false +} +``` + +Only an elected member of the charter `electedCharterId` names, in the moderation charters contract, may mint a badge. Once minted, the badge can be transferred to anyone (`transferable: 1`), and it keeps its creator. + +### How it works + +- **On create**, the creator is the writer, and is checked as `ownerRefersTo` checks the writer. An `identity` target reads nothing. +- **On replace**, the value is the stored creator, whoever writes. The declaration is checked again when a property its lookup or an agreement pair reads changed, and on every replace when it has a pair keyed by `$ownerId`. Such a pair still names the writer, not the creator. +- **Transfers and purchases** need no check: they do not change the creator. +- **In a lookup**, `"."` is the creator. +- A refusal names `$creatorId` as its path. Registration errors name the declaration `.$creatorId`. + +### Rules at registration + +- The document type records creator ids. Such a type's documents can be transferred or traded, so `ownerRefersTo` is refused on it, and a type declares at most one of the two. +- The targets are those of `ownerRefersTo` except `deletableDocument`: the creator never changes, and a document a transfer handed on could not be replaced by its new owner once the document the lookup found was deleted. +- A lookup may not read `"$ownerId"`: on a type whose documents can change owner, a transfer or a purchase would move that key part without a write. +- It can only be declared on a type when the type is created, since an update may not add it. So every document of the type records its creator. +- Every other rule is as for `ownerRefersTo`. + +## Choosing between them + +| The type's documents | Declare | Judges | +|---|---|---| +| stay with their owner (`transferable` and `tradeMode` absent or `0`) | `ownerRefersTo` | whoever writes the document, who is always its owner | +| can be transferred or traded | `creatorRefersTo` | the identity that created the document, whoever writes it later | + +For a rule about the writer and a document one of its own properties already names, a [`propertyAgreement`](refers-to.md#propertyagreement) pair keyed by `$ownerId` on that property's reference is enough: `{ "$ownerId": "$ownerId" }` requires the writer to own the referenced document. The type-level keywords are for rules no property carries, such as "the writer has a profile" or "the writer is on this list". + +## See also + +- [References (refersTo)](refers-to.md) for the targets, keys and errors. +- [Lookups](refers-to-lookup.md), [List Elements](refers-to-list-element.md) and [Expressions](refers-to-expressions.md), the targets a writer or creator reference usually takes. +- [On the writer or the creator](../data-model/documents.md#on-the-writer-or-the-creator-ownerrefersto-creatorrefersto) in the Documents chapter, with the internals. +- [System Properties](system-properties.md) for `$ownerId` and `$creatorId`, and [Creation, Transfers and Trading](ownership-and-trading.md) for `transferable` and `tradeMode`. diff --git a/book/src/contract-keywords/ownership-and-trading.md b/book/src/contract-keywords/ownership-and-trading.md new file mode 100644 index 00000000000..b14a3e9a572 --- /dev/null +++ b/book/src/contract-keywords/ownership-and-trading.md @@ -0,0 +1,119 @@ +# Creation, Transfers and Trading + +A document belongs to the identity in its `$ownerId`: at first the one that created it. These three keywords decide who may create documents of a type, and whether a document may later change hands. `creationRestrictionMode` limits who creates. `transferable` lets an owner give a document away. `tradeMode` lets an owner put a price on a document and anyone else buy it at that price. + +The example below is used throughout the chapter: + +```json +"ticket": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "creationRestrictionMode": 1, + "transferable": 1, + "tradeMode": 1, + "properties": { + "event": { "type": "string", "maxLength": 63, "position": 0 }, + "seat": { "type": "string", "maxLength": 10, "position": 1 } + }, + "required": ["event", "seat", "$createdAt"], + "additionalProperties": false +} +``` + +Only the organiser, the identity that owns the contract, can issue tickets. A ticket's holder may give it to a friend, or list it for sale; anyone may then buy it at the listed price, with no approval from the seller. Nobody can edit a ticket or delete it. + +## `creationRestrictionMode` + +Who may create documents of the type. + +| | | +|---|---| +| **Where** | document type | +| **Value** | `0` anyone, `1` the contract owner only, `2` nobody | +| **Default** | `0` | +| **Since** | protocol version 1 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212). Adding or removing the key without changing its value is refused too, as a schema change (`IncompatibleDocumentTypeSchemaError`, 10246). | +| **Errors** | `DocumentCreationNotAllowedError` (10416) | + +### How it works + +- `0`: any identity may create documents of the type. +- `1`: only the identity that owns the contract may. A create signed by anyone else is refused (`DocumentCreationNotAllowedError`, 10416). Once created, a document may still pass to other identities if the type is transferable or tradeable. +- `2`: no create transition is ever accepted. This is for system contracts whose documents only the platform writes, such as the document history contract (see [History](history.md)) and the keyword search contract. On a user's contract the type would stay empty for good. +- The mode rules creation only. Replaces, deletes, transfers and sales are decided by each document's own owner and by the other keywords. + +### Rules at registration + +- A type with mode `1` or `2` may not carry `canBeDeletedByModerators` (`InvalidContractStructure`, 10231): its documents belong to the contract owner or the platform, and no moderator may delete those. See [Deletion](deletion.md). + +## `transferable` + +Whether an owner may give a document to another identity. + +| | | +|---|---| +| **Where** | document type | +| **Value** | `0` never, `1` always | +| **Default** | `0` | +| **Since** | protocol version 1 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212). Adding or removing the key without changing its value is refused too, as a schema change (10246). | +| **Errors** | `InvalidDocumentTransitionActionError` (10404) for a transfer of a type set to `0` | + +### How it works + +- The owner sends a transfer transition naming the document, the recipient and a `$revision` one higher than the stored one (`InvalidDocumentRevisionError`, 40106). Only the owner may transfer (`DocumentOwnerIdMismatchError`, 40102). The recipient does not have to agree. +- The document gets the recipient as its `$ownerId` and a new `$revision`. A price it was listed at is removed, so a transferred document is no longer for sale. When the type lists `$transferredAt` in `required`, it is set to the block's time, and likewise `$transferredAtBlockHeight` and `$transferredAtCoreBlockHeight` to the block heights. +- A transfer carries no property values, and `$creatorId` keeps naming the identity that created the document. The one change the platform makes itself: from protocol version 13, a DPNS `domain` that is transferred or sold has its `records.identity` pointed at the new owner. +- The document is checked as it will be stored, with its new owner: against the type's unique indexes (`DuplicateUniqueIndexError`, 40105), against a `distinctFrom: "$ownerId"` property (`DocumentPropertyNotDistinctError`, 10419), and against `propertyConstraints` rules that read `$ownerId` (`DocumentPropertyConstraintViolatedError`, 10422). +- On a moderated contract, a transfer to a banned or suspended identity is refused (`ContractModerationCounterpartyBarredError`, 41114). +- A transfer of a document past its `ttl` expiry is refused (`DocumentExpiredError`, 40140). +- With `keepsTransferHistory: true`, each transfer is also recorded in the document history contract. See [History](history.md). + +## `tradeMode` + +Whether documents of the type can be sold through the platform's built-in marketplace. With `1`, direct purchase, an owner sets a price and any other identity may buy the document at that price, with no approval. + +| | | +|---|---| +| **Where** | document type | +| **Value** | `0` none, `1` direct purchase | +| **Default** | `0` | +| **Since** | protocol version 1 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212). Adding or removing the key without changing its value is refused too, as a schema change (10246). | +| **Errors** | `InvalidDocumentTransitionActionError` (10404) for a price update or a purchase on a type set to `0`, and for a purchase by the document's own owner; `DocumentNotForSaleError` (40108); `DocumentIncorrectPurchasePriceError` (40109) | + +### How it works + +- **Listing.** The owner sends a price update transition with a price in credits and the next `$revision`. Only the owner may set the price (40102). The price is stored with the document, the revision goes up, and `$updatedAt` is set when the type lists it in `required`. +- **Buying.** Any identity other than the owner sends a purchase transition naming the document, the next `$revision` and the price. A document with no price set is not for sale (`DocumentNotForSaleError`, 40108), and the price in the transition must equal the listed price exactly (`DocumentIncorrectPurchasePriceError`, 40109). An owner cannot buy its own document (10404). +- **What a purchase does.** The price moves from the buyer's credit balance to the seller's. The document gets the buyer as its `$ownerId` and a new `$revision`, the listing is removed, and `$transferredAt` is set when the type lists it in `required`. The buyer's credit balance must cover the price. +- The new owner is checked exactly as for a transfer: unique indexes (40105), `distinctFrom: "$ownerId"` (10419) and `propertyConstraints` rules that read `$ownerId` (10422). +- On a moderated contract, a banned or suspended buyer is refused like any barred writer, and so is a purchase from a banned or suspended seller (`ContractModerationCounterpartyBarredError`, 41114). +- A price update or a purchase of a document past its `ttl` expiry is refused (`DocumentExpiredError`, 40140). +- With `keepsPricingHistory` and `keepsPurchaseHistory`, each price update and each purchase is also recorded in the document history contract. See [History](history.md). + +## How they combine + +- `transferable` and `tradeMode` are independent. The DPNS `domain` type sets both, so a name can be given away or sold. A type may allow sales without gifts, or gifts without sales. +- Neither needs `documentsMutable`. A document that cannot be replaced can still change owner and price, as the ticket above does. +- A type that is transferable or tradeable stores a `$revision` on each document even when its documents cannot be replaced, and, from protocol version 10 on a format-1 contract whose config is version 1 or later, records each document's creator in `$creatorId`. See [System Properties](system-properties.md). +- Every action has its own optional token cost and action fee: `transfer`, `update_price` and `purchase`, besides `create`. See [Token Costs](token-cost.md) and [Action Fees](action-fees.md). +- `signatureSecurityLevelRequirement` applies to all of these actions, so a buyer signs a purchase with a key at the level the type requires. See [Signing and Keys](signing-keys.md). + +## Rules at registration + +All refusals below are `InvalidContractStructure` (10231). + +- `ownerRefersTo` is refused on a type whose documents can be transferred or traded: a document could end up with an owner the declaration never checked. Such a type uses `creatorRefersTo`, which checks the creator, who never changes. `creatorRefersTo` is only accepted on such a type. See [Writer and Creator References](owner-refers-to.md). +- An `indexOnly` type can be neither transferable nor tradeable. See [Index-Only Types](index-only.md). +- On a transferable or tradeable type, an `immutable` property may not hold a `contract` reference with an `owner` requirement. See [Mutability](mutability.md). +- `creationRestrictionMode` `1` or `2` is refused together with `canBeDeletedByModerators`. + +## See also + +- [History](history.md), for recording transfers, purchases and price updates +- [Mutability](mutability.md) and [Deletion](deletion.md), the other keywords on what may happen to a document +- [System Properties](system-properties.md), for `$ownerId`, `$creatorId`, `$revision` and `$transferredAt` +- [distinctFrom](distinct-from.md) and [propertyConstraints](property-constraints.md), which also judge a new owner +- [Contract Moderation](../data-model/contract-moderation.md), for barred counterparties diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md new file mode 100644 index 00000000000..ff68febfe7c --- /dev/null +++ b/book/src/contract-keywords/property-constraints.md @@ -0,0 +1,300 @@ +# propertyConstraints + +`propertyConstraints` holds named rules that every created or replaced document of a type must meet. JSON Schema bounds one property at a time; these rules relate properties to each other: a deposit that covers price times quantity, percentages that add up to 100, a closed order that carries its closing time, a second party who is not the owner. Each rule is a small tree of comparisons, arithmetic and logic that consensus evaluates against the document, without reading any state. + +| | | +|---|---| +| **Where** | Document type | +| **Value** | An object of rules, at least one. Each key is the rule's name (1 to 64 letters, digits or underscores); each value is a condition (see [Conditions](#conditions)) | +| **Default** | Absent: no rules | +| **Since** | protocol version 14 | +| **On update** | Fixed: adding, removing or changing a rule is refused (`IncompatibleDocumentTypeSchemaError`, 10246). Stored documents were judged against the rules as they were | +| **Errors** | `DocumentPropertyConstraintViolatedError` (10422) on a document; at registration `JsonSchemaError` (10101) or `InvalidContractStructure` (10231) | + +## Example + +```json +"order": { + "type": "object", + "properties": { + "price": { "type": "integer", "minimum": 0, "maximum": 1000000000, "position": 0 }, + "fee": { "type": "integer", "minimum": 0, "maximum": 1000000, "position": 1 }, + "quantity": { "type": "integer", "minimum": 1, "maximum": 10000, "position": 2 }, + "deposit": { "type": "integer", "minimum": 0, "position": 3 }, + "status": { "type": "string", "enum": ["open", "pending", "closed"], "position": 4 }, + "closedAt": { "type": "integer", "minimum": 0, "position": 5 } + }, + "required": ["price", "quantity", "deposit", "status"], + "propertyConstraints": { + "depositCoversOrder": { + "lessThanOrEqual": [ + { "multiply": [{ "add": ["price", "fee"] }, "quantity"] }, + "deposit" + ] + }, + "feeWaivedOrAtLeastTen": { + "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] + }, + "closedNeedsClosedAt": { + "anyOf": [{ "notEqual": ["status", { "const": "closed" }] }, { "present": "closedAt" }] + } + }, + "additionalProperties": false +} +``` + +`depositCoversOrder` reads `(price + fee) * quantity <= deposit`. `feeWaivedOrAtLeastTen` reads `fee == 0 || fee >= 10`; an order that leaves `fee` out passes, since a missing integer reads as 0. `closedNeedsClosedAt` says an order whose status is `closed` carries a `closedAt`. + +## How it works + +- **Create and replace.** The rules run after the JSON schema validation of the document's properties (and after [maxBytes](max-bytes.md)), so every value a rule reads has passed its property's schema. A replace is judged on the whole new document, not only on what changed. +- **Name order, first failure.** Rules are checked in the order of their names, and the first rule the document breaks refuses the transition with `DocumentPropertyConstraintViolatedError` (10422). The error names the document type, the rule, and why it failed (below). +- **Transfer and purchase.** These change only the owner. Rules that read `$ownerId` are judged again, against the stored document and its new owner; other rules are not, since nothing they read changed. A transfer or purchase that would break an owner rule is refused with 10422. +- **Price updates and deletes** are not judged, with one exception: a delete of an [index-only](index-only.md) document carries the row's values, which are validated like a create's, rules included. The delete does not carry the owner, which is why an index-only type may not have a rule reading `$ownerId`. +- **No state, no fee.** A rule reads only the document and its owner. It changes nothing stored and adds no fee; the limits below bound its cost. SDKs that validate a document before sending it apply the same rules. + +Why a rule fails, as the error reports it: + +| Reason | When | +|---|---| +| does not hold | The rule evaluates without a fault and comes out false | +| overflow | A value it reads, or a result it computes on the way, does not fit a 128-bit signed integer | +| division by zero | A `divide` or `modulo` whose divisor evaluates to 0 | +| negative exponent | A `power` whose exponent evaluates to a negative number | +| not an integer | A value it reads for an integer property is a float with no fractional part, such as `5.0`, which the schema's `integer` type admits but an integer property cannot store | + +## Conditions + +A rule is a condition: a JSON object with exactly one key. + +| Condition | Form | Holds when | +|---|---|---| +| `equal`, `notEqual` | `[left, right]` | The two sides are equal, or differ. The sides are two integer expressions, or a string property and a string constant or another string property, or an identifier property and an identifier constant, another identifier property or `$ownerId` | +| `lessThan`, `lessThanOrEqual`, `greaterThan`, `greaterThanOrEqual` | `[left, right]` | The left integer expression compares with the right one this way. Integers only | +| `in` | `[expression, [v1, v2, ...]]` | The expression takes one of the listed values: two or more, no two alike, all integers or all strings. With strings, the expression is a string property, or an identifier property or `$ownerId` with the strings as base58 identifiers | +| `present` | `"path"` | The document holds the property, with a value other than null | +| `absent` | `"path"` | The document leaves the property out, or sets it to null | +| `anyOf` | `[c1, c2, ...]` | At least one of two or more conditions holds | +| `allOf` | `[c1, c2, ...]` | Every one of two or more conditions holds | +| `not` | `condition` | Its one condition does not hold | + +Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan": ["quantity", 10] }] } }` refuses a free order of more than 10. An `anyOf` or `allOf` may not list the same condition twice, nor hold one of its own kind directly (it says what one flat list says), and a `not` may not hold a `not` directly. + +An `in` says what an `anyOf` of `equal` comparisons says, in far fewer nodes: `{ "in": ["fee", [0, 10, 25, 50]] }` is 6 nodes where the `anyOf` is 13. + +## Expressions + +An integer expression is one of: + +| Expression | Form | Value | +|---|---|---| +| integer | `100` | Itself. A number written `100.0` reads as 100 | +| path | `"price"`, `"meta.total"` | The value of an integer or boolean property of the document type, 1 for true and 0 for false. A property the document leaves out, or sets to null, reads as 0 | +| `ifAbsent` | `{ "ifAbsent": ["quantity", 1] }` | The property's value, or the given integer when the document leaves it out | +| `add`, `multiply` | `{ "add": [a, b, ...] }` | The sum or product of two or more operands | +| `subtract` | `{ "subtract": [a, b] }` | `a - b` | +| `divide` | `{ "divide": [a, b] }` | The Euclidean quotient of `a` by `b` | +| `modulo` | `{ "modulo": [a, b] }` | The Euclidean remainder of `a` by `b`, never negative | +| `power` | `{ "power": [a, b] }` | `a` to the power `b` | + +Two more forms appear only in string and identifier comparisons, never inside arithmetic: + +| Form | Meaning | +|---|---| +| `{ "const": "closed" }` | A string constant, or, compared with an identifier property or `$ownerId`, a base58 identifier | +| `{ "ifAbsent": ["status", "open"] }` | A string property, read as the given string when the document leaves it out | + +A bare JSON string is always a path and a bare JSON number always a value, so a constant string needs `{ "const": ... }`. The values an `in` lists are literals and need no wrapper. A path is a property name, or names joined by dots for a nested property (`"rewardSplit.leader"`); the only `$` name a rule accepts is `$ownerId`. + +A `number` property (a float) cannot be read by a rule, which keeps every result exact. + +## Strings + +A string property is compared for equality only, never ordered and never used in arithmetic: + +- `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with the constant on either side; +- `{ "notEqual": ["fromCurrency", "toCurrency"] }`, two bare paths that both name string properties, which compares their strings; +- `{ "in": ["status", ["open", "pending"]] }`, whose values are two or more distinct strings. + +A string property the document leaves out equals no constant and no other string property, not even one also left out. So `notEqual` holds for it, and `equal` and `in` do not. `{ "ifAbsent": ["status", "open"] }` gives it a default instead: it may stand wherever the bare path stands, and the property then reads as that string when it is left out. `present` and `absent` test it directly. + +When the property declares an `enum`, every constant compared with it, and every `ifAbsent` default given to it, must be one of the enum's values. A misspelled constant is refused at registration instead of making the rule quietly never hold. + +## Identifiers and `$ownerId` + +An identifier property compares in the same three ways: `{ "equal": ["paymentToken", { "const": "" }] }` or `notEqual`, `{ "notEqual": ["buyerId", "sellerId"] }`, and `{ "in": ["paymentToken", ["", ""]] }`. Constants are base58 identifiers of 32 bytes, checked at registration and compared by their bytes, whatever form the document gives the identifier in. An identifier property the document leaves out equals no identifier, not even another one left out. Identifiers take no `ifAbsent` default and are never ordered. + +`$ownerId`, the document's owner, is an identifier operand too: + +- `{ "equal": ["authorId", "$ownerId"] }` holds the `authorId` property to the owner. +- `{ "in": ["$ownerId", ["", ""]] }` lets only the listed identities own a document of the type. + +It is not a property: `present`, `absent` and integer expressions refuse it, and comparing it with itself is refused. On create and replace it is the writer. A transfer or purchase is judged with the new owner, as described in [How it works](#how-it-works). An [index-only](index-only.md) type may not declare a rule that reads it. + +## Evaluation order and short-circuiting + +Conditions are checked in declared order and no further than the outcome needs. A comparison evaluates its left side, then its right. `anyOf` stops at the first condition that holds, `allOf` at the first that fails. Operands are evaluated left to right. + +A fault (an overflow, a division by zero, a negative exponent, a value that is not an integer) in a condition that is evaluated breaks the rule, whatever the other conditions would say, and `not` does not turn a fault into a pass. String comparisons, `present` and `absent` never fault. So an earlier condition can guard a later one: + +```json +{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] } +``` + +holds for a `b` of 0 without dividing by it. The same two conditions the other way round divide by zero and break the rule. + +## Arithmetic + +- Integers are exact over 128-bit signed integers. Every intermediate result must fit, and one that does not breaks the rule instead of wrapping. `add` and `multiply` fold their operands from the left, so an overflow on the way is a fault even when a later operand would bring the total back in range. +- `divide` and `modulo` are Euclidean: the remainder is never negative, and the quotient is the one that goes with it. `-7` divided by `2` is `-4`, remainder `1`. For operands that are not negative this is ordinary integer division. +- A divisor that evaluates to 0 breaks the rule. A negative exponent breaks it too, since it has no integer result. `0` to the power `0` is `1`. +- There are no floats. + +## Rules at registration + +The meta-schema checks the shape (`JsonSchemaError`, 10101): + +- the keyword is an object of one or more rules, named with 1 to 64 letters, digits or underscores; +- every condition and every operator object has exactly one key; +- a comparison, `subtract`, `divide`, `modulo` and `power` take exactly two operands; `add` and `multiply` two or more; `anyOf` and `allOf` two or more conditions, no two alike; an `in` two or more distinct values, all integers or all strings; +- no `anyOf` or `allOf` holds its own kind directly, and no `not` holds a `not`; +- a path matches `$ownerId` or dotted names of 1 to 64 letters, digits or underscores, so `$createdAt` and other system properties are refused. + +The parser then checks the rules against the document type (`InvalidContractStructure`, 10231): + +- every path an integer expression reads names an integer or boolean property; every path compared with a string names a string property; every path compared with an identifier names an identifier property; every path `present` or `absent` tests names a property of any type, an object included; +- no rule reads a property that is `transient` or inside a transient object, since a stored document could never be held to it; +- every comparison and `in` reads at least one property: a comparison of constants would hold for every document or for none; +- strings and identifiers are compared only with `equal`, `notEqual` and `in`; a string is never compared with an identifier; a property is never compared with itself; +- string constants and `ifAbsent` defaults are in the property's `enum` when it has one; identifier constants are base58 identifiers of 32 bytes; +- no literal divisor is 0 and no literal exponent is negative; +- `present` and `absent` do not name `$ownerId`, and an index-only type has no rule reading it; +- no `anyOf` or `allOf` lists two conditions that parse alike, such as `1` and `1.0`, or two `in` conditions listing the same values in another order; +- no condition or operand nests more than 64 levels deep. + +Two limits come from the protocol version 14 `SystemLimits`, and a rule over one is refused the same way: + +- at most 16 rules per document type (`max_property_constraints`); +- at most 32 nodes per rule (`max_property_constraint_nodes`). + +A rule within 32 nodes is never deep enough to reach the 64-level bound. Nodes are counted like this: + +| Part of a rule | Nodes | +|---|---| +| A comparison of integers | 1, plus its two sides | +| An `equal` or `notEqual` of strings or identifiers | 3: the comparison and its two sides | +| An `in` over integers | 1, plus its expression, plus 1 per value | +| An `in` over strings or identifiers | 2, plus 1 per value | +| `present`, `absent` | 1 | +| `anyOf`, `allOf` | 1, plus their conditions | +| `not` | 1, plus its condition | +| An integer, a path or an `ifAbsent` | 1 | +| `add`, `multiply`, `subtract`, `divide`, `modulo`, `power` | 1, plus their operands | + +`depositCoversOrder` above is 7 nodes (the comparison, `multiply`, `add` and four paths), and `closedNeedsClosedAt` is 5. An `in` fits up to 30 values in 32 nodes. + +## Worked examples + +**Percentages that add up.** The moderation charters system contract requires a proposal's reward split to be whole. The paths name members of the `rewardSplit` object, each an integer from 0 to 100: + +```json +"propertyConstraints": { + "rewardSplitIsWhole": { + "equal": [ + { "add": ["rewardSplit.leader", "rewardSplit.equal", "rewardSplit.actions"] }, + 100 + ] + } +} +``` + +Six nodes: the comparison, `add`, three paths and `100`. + +**One of two, or both or neither.** A contact card must give an email or a phone; a shipping block gives a street and a city together or not at all: + +```json +"propertyConstraints": { + "reachable": { "anyOf": [{ "present": "email" }, { "present": "phone" }] }, + "addressComplete": { + "anyOf": [ + { "allOf": [{ "present": "street" }, { "present": "city" }] }, + { "allOf": [{ "absent": "street" }, { "absent": "city" }] } + ] + } +} +``` + +`present` and `absent` work on properties of any type, strings and objects included. On an integer they are also the only way to tell "not given" from "given as 0", since a missing integer reads as 0 in an expression. + +**A time window.** An event ends after it starts, and lasts at most a week (`startsAt` and `endsAt` are required integer timestamps in milliseconds): + +```json +"propertyConstraints": { + "endsAfterStart": { "lessThan": ["startsAt", "endsAt"] }, + "atMostAWeek": { "lessThanOrEqual": [{ "subtract": ["endsAt", "startsAt"] }, 604800000] } +} +``` + +**A status workflow.** On a `ticket` type, `status` is optional and means `open` when it is left out. A closed ticket names who closed it; an open or pending one does not: + +```json +"propertyConstraints": { + "closedNamesCloser": { + "anyOf": [{ "notEqual": ["status", { "const": "closed" }] }, { "present": "closedBy" }] + }, + "activeHasNoCloser": { + "anyOf": [ + { "not": { "in": [{ "ifAbsent": ["status", "open"] }, ["open", "pending"]] } }, + { "absent": "closedBy" } + ] + } +} +``` + +Without the `ifAbsent`, a ticket with no status would equal none of the listed strings, the `in` would not hold, and `activeHasNoCloser` would let it carry a `closedBy`. If `status` declares an `enum`, `"closed"`, `"open"` and `"pending"` must all be in it. + +**Who may own a badge.** On a transferable `badge` type, only two identities may ever hold one: + +```json +"propertyConstraints": { + "knownHolder": { + "in": [ + "$ownerId", + ["HJtU46rVEkKJgevQhiVt2YhdHtDS3xtGzzJqit8en5mb", "GfRNCXeyuB3th33a6nkJKQJecKMdRzuBiPsgvSCyUTvK"] + ] + } +} +``` + +A create by anyone else is refused, and so is a transfer or sale of a badge to anyone else: the rule reads `$ownerId`, so it is judged again with the new owner. + +**A guarded division.** The average unit price of a batch is at most 100, and a batch may be empty: + +```json +"propertyConstraints": { + "unitPriceCapped": { + "anyOf": [ + { "equal": ["quantity", 0] }, + { "lessThanOrEqual": [{ "divide": ["total", "quantity"] }, 100] } + ] + } +} +``` + +The `equal` comes first, so an empty batch never reaches the division. Written the other way round, an empty batch breaks the rule with a division by zero. + +**A flag in arithmetic.** A boolean reads as 1 or 0, so a waived fee must be 0: + +```json +"propertyConstraints": { + "waivedMeansFree": { "equal": [{ "multiply": ["waiveFee", "fee"] }, 0] } +} +``` + +## See also + +- [Property Constraints](../data-model/documents.md#property-constraints-propertyconstraints), the deep dive +- [distinctFrom](distinct-from.md), a single-keyword way to keep two identifiers apart +- [Property Schemas](property-schemas.md), for the one-property bounds JSON Schema gives +- [transient](transient.md), [Index-Only Types](index-only.md) +- [Contract Keywords overview](../contract-keywords.md), for the limits and the conventions of these tables diff --git a/book/src/contract-keywords/property-schemas.md b/book/src/contract-keywords/property-schemas.md new file mode 100644 index 00000000000..1f89c6f3ef2 --- /dev/null +++ b/book/src/contract-keywords/property-schemas.md @@ -0,0 +1,300 @@ +# Property Schemas + +Each entry of a document type's `properties` is a property schema: JSON Schema (draft 2020-12), limited to the keywords in this chapter, plus three Platform keywords that say how a value is stored (`position`, `byteArray` and `contentMediaType`). The schema is checked when the contract is registered, and every created or replaced document is validated against it. Platform keywords with more to them, such as [`maxBytes`](max-bytes.md), [`refersTo`](refers-to.md), [`distinctFrom`](distinct-from.md), [`encryptedFor`](encrypted-for.md), [`requiredSince`](required-since.md) and a typed array's [`items`](typed-arrays.md), have chapters of their own. + +| Keyword | Applies to | On update | +|---|---|---| +| [`type`](#type) | every property | Fixed | +| [`position`](#position) | every property | Fixed | +| [`minLength`, `maxLength`, `pattern`, `format`](#strings) | strings | Loosened or removed only | +| [`minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`](#numbers) | integers and numbers | Loosened or removed only, keeping an integer's width; `multipleOf` fixed | +| [`enum`, `const`](#enum-and-const) | any | `enum` may gain values; `const` may be removed | +| [`byteArray`, `contentMediaType`](#byte-arrays-and-identifiers) | byte arrays | Fixed | +| [`minItems`, `maxItems`, `uniqueItems`, `contains`](#arrays) | arrays | Loosened or removed only; `contains` fixed | +| [`properties`, `required`, `additionalProperties`, `minProperties`, `maxProperties`, `dependentRequired`](#objects) | objects | Members may be added; the rest fixed, except `dependentRequired` may lose entries | +| [`$ref`](#ref) | any | Fixed | +| [`$id`, `$comment`, `description`, `examples`](#annotations) | any | Free (`$id` may only be added) | + +Each section gives the error a contract update gets for breaking its rules. Most are refused with `IncompatibleDocumentTypeSchemaError` (10246), from the comparison of the old and new schemas; a change to how a value is stored is refused with `DocumentTypeUpdateError` (40212). + +## How property schemas are checked + +When a contract is registered or updated: + +- The schema is checked against the document meta-schema. A keyword the meta-schema does not allow where it is written, or a value of the wrong shape, is refused with `JsonSchemaError` (10101). +- The parser reads each property's type and bounds, and refuses what the meta-schema cannot express (`InvalidContractStructure`, 10231). +- The schema is compiled for validating documents. A `pattern` that is not a valid regular expression, or a `format` the validator does not know, is refused here (`JsonSchemaError`, 10101). + +When a document is created or replaced: + +1. Every string and byte array value is at most 5120 bytes, whatever the schema allows (`DocumentFieldMaxSizeExceededError`, 10417). From protocol version 14 a value nested more than 256 levels deep is refused as well (`ValueError`, 10103). +2. The document's properties are validated against the schema. Each failure is reported as a `JsonSchemaError` (10101) naming the keyword and the property. +3. Platform's own checks, which JSON Schema cannot express, come next: [`maxBytes`](max-bytes.md) and [`propertyConstraints`](property-constraints.md) among them. + +A transfer, a price update and a purchase carry no property values, so they are not validated against the schema again. + +The schema also decides how each value is stored, since a stored document holds no property names or type tags: + +| Property | Stored as | +|---|---| +| `integer` | 1, 2, 4 or 8 bytes, chosen by its bounds (see [Numbers](#numbers)) | +| `number` | 8 bytes, a 64-bit floating point number | +| `boolean` | 1 byte | +| `string` | a length prefix, then the UTF-8 bytes | +| byte array with `minItems` equal to `maxItems` | the bytes, with no prefix | +| any other byte array | a length prefix, then the bytes | +| identifier | 32 bytes | +| `object` | a length prefix, then its members | +| typed array | an element count, then the elements (see [Typed Arrays](typed-arrays.md)) | + +An optional property adds one byte in front that says whether it is present. See [Document Serialization](../serialization/document-serialization.md#value-encoding-by-type) for the exact encoding. + +## `type` + +| | | +|---|---| +| **Where** | Every property; also the elements of a typed array | +| **Value** | One of `"string"`, `"integer"`, `"number"`, `"boolean"`, `"object"`, `"array"` | +| **Since** | protocol version 1 | +| **On update** | Fixed (`IncompatibleDocumentTypeSchemaError`, 10246) | +| **Errors** | `JsonSchemaError` (10101): a document value of another type | + +`type` is a single name. A list of types, and `"null"`, are refused at registration: every stored value needs one known encoding. + +An `array` is one of two things: + +- a **byte array**, with `byteArray: true`: a string of bytes, such as a hash or an [identifier](#byte-arrays-and-identifiers); +- from protocol version 14, a **typed array**, with an `items` schema: a list of values of one scalar type. See [Typed Arrays](typed-arrays.md). + +An array that is neither is refused. + +## `position` + +| | | +|---|---| +| **Where** | Every property, at every level. Not on the elements of a typed array. | +| **Value** | An integer, 0 or more | +| **Since** | protocol version 1 | +| **On update** | Fixed (10246) | +| **Errors** | `MissingPositionsInDocumentTypePropertiesError` (10411) at registration | + +A document is stored with its values one after another and no property names. `position` is the property's place in that sequence. + +- The top-level properties of a document type must use the positions 0, 1, 2 and so on, with no gap and no number used twice (10411). A top-level property without a `position` is refused. +- The members of an object need a `position` too. Number them from 0 within the object; only the top level is checked for gaps. +- A property that takes its schema from a [`$ref`](#ref) writes its `position` next to the `$ref`. +- A property added by a contract update takes the next free position. An existing position can never change, or stored documents would be read in the wrong order. + +## Strings + +| | | +|---|---| +| **Keywords** | `minLength`, `maxLength`, `pattern`, `format` | +| **Where** | Properties of type `string`, and the string elements of a typed array | +| **Value** | `minLength`, `maxLength`: an integer, 0 or more, counting characters. `pattern`: a regular expression. `format`: the name of a JSON Schema format, such as `"uri"` | +| **Since** | protocol version 1 | +| **On update** | `maxLength` may be raised or removed, and `minLength` lowered or removed. `pattern` and `format` may be removed. None of them may be added, and no other change is allowed (10246). | +| **Errors** | `JsonSchemaError` (10101) | + +```json +"username": { + "type": "string", + "minLength": 3, + "maxLength": 63, + "pattern": "^[a-zA-Z0-9_]+$", + "position": 0 +} +``` + +A `username` is 3 to 63 letters, digits or underscores. + +- `minLength` and `maxLength` count characters, and a character takes 1 to 4 bytes in UTF-8. To cap the stored size, add [`maxBytes`](max-bytes.md). Whatever `maxLength` says, no single string may exceed 5120 bytes (10417). +- `pattern` is written in the syntax of Rust's `regex` crate, which has no lookaround and no backreferences. A pattern that does not compile is refused at registration (10101). +- `format` is checked on every document. The validator knows `date-time`, `date`, `time`, `email`, `idn-email`, `hostname`, `ipv4`, `ipv6`, `uri` and `regex`. Any other format, `uuid` and `uri-reference` included, is refused at registration (10101). +- A string with `pattern` or `format` must declare a `maxLength` of at most 50000, so that matching stays cheap. +- A string used in an index needs a `maxLength` of at most 63 (`InvalidIndexedPropertyConstraintError`, 10205). See [Indexes](indexes.md). + +## Numbers + +| | | +|---|---| +| **Keywords** | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` | +| **Where** | Properties of type `integer` or `number`, and such elements of a typed array | +| **Value** | A number. On an integer element of a typed array, `minimum` and `maximum` are integers. | +| **Since** | protocol version 1 | +| **On update** | `maximum` and `exclusiveMaximum` may be raised or removed, and `minimum` and `exclusiveMinimum` lowered or removed, unless that changes how an integer is stored (`DocumentTypeUpdateError`, 40212). None may be added, and `multipleOf` is fixed (10246). | +| **Errors** | `JsonSchemaError` (10101) | + +```json +"rating": { "type": "integer", "minimum": 1, "maximum": 5, "position": 2 } +``` + +A `rating` is a whole number from 1 to 5, and is stored in one byte. + +A `number` is always stored in 8 bytes. An `integer` is stored in the smallest width its `minimum` and `maximum` allow, when the contract's config has `sizedIntegerTypes` on (the default; see [Contract-Level Keys and config](contract-config.md)): + +| `minimum` | `maximum` | Stored as | +|---|---|---| +| 0 or more | up to 255 | 1 byte, unsigned | +| 0 or more | up to 65535 | 2 bytes, unsigned | +| 0 or more | up to 4294967295 | 4 bytes, unsigned | +| 0 or more | higher | 8 bytes, unsigned | +| below 0 | both bounds within -128 to 127 | 1 byte, signed | +| below 0 | both bounds within -32768 to 32767 | 2 bytes, signed | +| below 0 | both bounds within -2147483648 to 2147483647 | 4 bytes, signed | +| below 0 | otherwise | 8 bytes, signed | + +With only a `minimum`, the integer takes 8 bytes, unsigned when the minimum is 0 or more. With only a `maximum`, it takes the unsigned width the maximum gives, so an integer that may be negative needs a `minimum` too. With neither, an `enum` of integers picks the width from its smallest and largest members; without one, the integer takes 8 bytes, signed. `exclusiveMinimum` and `exclusiveMaximum` do not affect the width. With `sizedIntegerTypes` off, every integer takes 8 bytes, signed. + +Stored documents and index entries hold each integer at its width, so a contract update may not change the width or the sign. Raising `maximum` past the width, lowering `minimum` below 0, removing a bound, or adding an `enum` value outside the width is refused with `DocumentTypeUpdateError` (40212); so is turning `sizedIntegerTypes` on when it would change an existing integer's width. A change that keeps the width is accepted. + +## `enum` and `const` + +| | | +|---|---| +| **Where** | Any property. `enum` also on the string, integer, number and boolean elements of a typed array; `const` never on elements. | +| **Value** | `enum`: an array of one or more values, none repeated. `const`: one value. | +| **Since** | protocol version 1 | +| **On update** | `enum` may gain values but not lose one; the keyword may be removed, not added. `const` may be removed, not added or changed (10246). An `enum` value that changes an integer's width is refused (`DocumentTypeUpdateError`, 40212). | +| **Errors** | `JsonSchemaError` (10101) | + +```json +"status": { "type": "string", "enum": ["open", "closed", "archived"], "position": 3 } +``` + +`enum` lists the values a property may take and `const` the one value it must take. Both only narrow what documents may hold, so an update may widen them (more `enum` values, or no keyword at all) but never narrow them, which could leave stored documents invalid. On an integer without bounds, `enum` also sets the stored width (see [Numbers](#numbers)). + +## Byte arrays and identifiers + +| | | +|---|---| +| **Keywords** | `byteArray`, `contentMediaType` | +| **Where** | Properties of type `array`, and the array elements of a typed array | +| **Value** | `byteArray`: `true`, the only value. `contentMediaType`: `"application/x.dash.dpp.identifier"` makes the byte array an identifier. | +| **Since** | protocol version 1 | +| **On update** | Fixed (10246). The length bounds follow the rules of [Arrays](#arrays), but a byte array may not switch between a fixed and a variable length, or change its fixed length (`DocumentTypeUpdateError`, 40212). | +| **Errors** | `JsonSchemaError` (10101) | + +```json +"authorId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1 +} +``` + +`authorId` is an identifier: the 32-byte id of an identity, a document, a contract or a token. + +- `byteArray: true` makes an array a string of bytes. Its `minItems` and `maxItems` count bytes. When the two are equal the bytes are stored as they are; otherwise they carry a length prefix. +- `contentMediaType: "application/x.dash.dpp.identifier"` makes a byte array an identifier. It must come with `byteArray: true`, `minItems: 32` and `maxItems: 32`, and it may not carry `uniqueItems`. An identifier is shown in base58, and it is the kind of property [`distinctFrom`](distinct-from.md) and most [`refersTo`](refers-to.md) targets are declared on. +- A byte array used in an index needs a `maxItems` of at most 255 (`InvalidIndexedPropertyConstraintError`, 10205). +- On a typed array, `contentMediaType` belongs on the `items`, not on the array. + +## Arrays + +| | | +|---|---| +| **Keywords** | `minItems`, `maxItems`, `uniqueItems`, `contains` | +| **Where** | Properties of type `array`. `minItems` and `maxItems` also on byte array elements of a typed array. | +| **Value** | `minItems`, `maxItems`: an integer, 0 or more. `uniqueItems`: a boolean. `contains`: a schema. | +| **Since** | protocol version 1 | +| **On update** | `maxItems` may be raised or removed and `minItems` lowered or removed (10246), within the byte array rule above (40212); a typed array keeps its `maxItems`. `uniqueItems` may be removed or set to `false`, not added (10246). `contains` is fixed (10246). | +| **Errors** | `JsonSchemaError` (10101) | + +- `minItems` and `maxItems` count bytes on a byte array and elements on a typed array. A typed array must declare `maxItems`, at most 1024. +- `uniqueItems: true` on a typed array refuses a document that repeats an element. On a plain byte array it refuses a repeated byte. It is refused on an identifier, and on the elements of a typed array. +- `contains` is the JSON Schema keyword: at least one element must match the schema it holds. + +## Objects + +| | | +|---|---| +| **Keywords** | `properties`, `required`, `additionalProperties`, `minProperties`, `maxProperties`, `dependentRequired` | +| **Where** | Properties of type `object` | +| **Value** | The same as at the top of a document type: see [Document Shape](document-shape.md) | +| **Since** | protocol version 1 | +| **On update** | Members may be added, never removed; `required` and `additionalProperties` are fixed; `dependentRequired` may lose entries, not gain them (10246). `minProperties` and `maxProperties` are fixed (10246). | +| **Errors** | `JsonSchemaError` (10101) | + +```json +"records": { + "type": "object", + "properties": { + "identity": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + } + }, + "minProperties": 1, + "additionalProperties": false, + "position": 5 +} +``` + +An object groups members under one property, as DPNS groups a name's records. + +- An object declares `properties`, 1 to 100 members each with a `position`, and `additionalProperties: false`, unless it takes its schema from a [`$ref`](#ref). +- Its `required` names the members every value of the object must hold. It cannot change after the type exists, so a member added by an update is optional. +- An object is stored with its members inline. An object cannot be indexed, but a member can: an index names it by its dotted path, such as `records.identity`. + +## `$ref` + +| | | +|---|---| +| **Where** | Any property, except the elements of a typed array | +| **Value** | `"#/$defs/"`: a definition in the contract's `schemaDefs` | +| **Since** | protocol version 1 | +| **On update** | Fixed (10246) | +| **Errors** | `InvalidJsonSchemaRefError` (10207) at registration | + +`$ref` lets several document types share one schema, written once in the contract's `schemaDefs`: + +```json +"schemaDefs": { + "address": { + "type": "object", + "properties": { + "street": { "type": "string", "maxLength": 100, "position": 0 }, + "city": { "type": "string", "maxLength": 50, "position": 1 } + }, + "required": ["street", "city"], + "additionalProperties": false + } +} +``` + +A property of any document type of the contract then reads `"shippingAddress": { "$ref": "#/$defs/address", "position": 2 }`. + +- Only local references, starting with `#`, are allowed. The platform places `schemaDefs` under `$defs` in every document type (see [`$schema` and `$defs`](document-shape.md#schema-and-defs)). +- A reference that does not resolve, or that leads back to itself, is refused (10207). +- The property keeps its own `position`; the definition supplies everything else. +- The `$ref` itself cannot change on update. The definition it points at can, under the rules of the keywords it holds (`IncompatibleDataContractSchemaError`, 10213). + +## Annotations + +| | | +|---|---| +| **Keywords** | `$id`, `$comment`, `description`, `examples` | +| **Where** | Any property. `$comment` and `description` also on the elements of a typed array. | +| **Value** | `$id`: a string starting with `#`. `$comment`, `description`: a string. `examples`: an array of values. | +| **Since** | protocol version 1 | +| **On update** | `$comment`, `description` and `examples`: free. `$id`: may be added, not removed or changed (10246). | +| **Errors** | none | + +Notes for people and tools reading the contract. They do not change what a document may hold. + +## See also + +- [Document Shape](document-shape.md), for the keywords at the top of a document type +- [Typed Arrays](typed-arrays.md), for arrays of values +- [maxBytes](max-bytes.md), for capping a string's size in bytes +- [Document Serialization](../serialization/document-serialization.md), for the stored form of every type +- [Indexes](indexes.md), for the limits on indexed properties +- [Error Codes](../error-handling/error-codes.md) diff --git a/book/src/contract-keywords/ranked.md b/book/src/contract-keywords/ranked.md new file mode 100644 index 00000000000..b79ddf392e2 --- /dev/null +++ b/book/src/contract-keywords/ranked.md @@ -0,0 +1,129 @@ +# Ranked Indexes + +A ranked index answers "which values score highest": the five restaurants with the best average grade, the ten hashtags with the most posts, the three sellers with the largest sales. The range aggregates of [Counts, Sums and Averages](aggregates.md) keep a count or a sum for every value of an index, but in the order of the values, so finding the top five means reading every value. A ranking adds a second, ordered view of the same totals, and a "top K" query reads K entries from one end of it, with a proof that grows with K rather than with the number of values. Each ranking costs one more tree that every write under the index updates, so a contract declares only the rankings it will query. + +The index's **groups** are the distinct values of its last property. The three keywords rank the groups by document count, by the sum of the summed property, or by its average. + +## Example + +```json +"review": { + "type": "object", + "indices": [ + { + "name": "byRestaurant", + "properties": [{ "restaurantId": "asc" }], + "averageable": "grade", + "rangeAverageable": true, + "rankedAverageable": true, + "rankedCountable": true + } + ], + "properties": { + "restaurantId": { "type": "string", "minLength": 1, "maxLength": 32, "position": 0 }, + "grade": { "type": "integer", "minimum": 0, "maximum": 100, "position": 1 } + }, + "required": ["restaurantId", "grade"], + "additionalProperties": false +} +``` + +`averageable` and `rangeAverageable` keep the count and the sum of `grade` per restaurant; `rankedAverageable` orders the restaurants by average grade and `rankedCountable` by number of reviews. The query + +```sql +SELECT avg(grade) FROM review GROUP BY restaurantId ORDER BY avg(grade) DESC LIMIT 3 +``` + +returns the three best-rated restaurants with their counts and sums, proved. + +## `rankedCountable` + +| | | +|---|---| +| **Where** | index | +| **Value** | boolean, or `{ "at": }` | +| **Default** | `false` | +| **Since** | protocol version 14 | +| **On update** | Fixed, like every index (`DataContractInvalidIndexDefinitionUpdateError`, 10217) | + +Ranks groups by how many documents they hold. Needs `rangeCountable: true` on the index, or `rangeAverageable: true`, which implies it. + +`true` ranks the values of the index's last property. On a compound index, the ranking is kept separately for each value of the properties before it: on `[city, restaurantId]` each city has its own ranking of restaurants, and a query names the city. + +The object form `{ "at": ... }` places the ranking at another level of the index. Naming an earlier property ranks that property's values by the number of documents beneath them, whatever the later properties hold: + +```json +{ + "name": "byHashtagPost", + "properties": [{ "hashtag": "asc" }, { "postId": "asc" }], + "countable": "countable", + "rangeCountable": true, + "rankedCountable": { "at": ["hashtag", "postId"] } +} +``` + +`"hashtag"` in `at` ranks hashtags by their total posts across all post ids; `"postId"`, the last property, is the same as `true` and ranks the posts under one hashtag. An array declares several rankings on one index; each level named costs one more ordered tree to maintain on every write beneath it. A query addresses a ranking by the property it groups by, with every property before that one fixed. + +`at` names only the index's own properties, each once. The object form cannot be combined with `rankedSummable` or `rankedAverageable`: a ranking at an earlier level is fed by a chain of counts that cannot also carry a sum. + +## `rankedSummable` + +| | | +|---|---| +| **Where** | index | +| **Value** | boolean | +| **Default** | `false` | +| **Since** | protocol version 14 | +| **On update** | Fixed (10217) | + +Ranks the groups of the index's last property by the sum of the index's `summable` property: the recipients who received the most, the products that sold the most units. Needs `rangeSummable: true`, or `rangeAverageable: true`. + +## `rankedAverageable` + +| | | +|---|---| +| **Where** | index | +| **Value** | boolean | +| **Default** | `false` | +| **Since** | protocol version 14 | +| **On update** | Fixed (10217) | + +Ranks the groups of the index's last property by the average of the `averageable` property. Needs both range totals: `rangeAverageable: true`, or `rangeCountable: true` with `rangeSummable: true`. + +The three keywords are independent. `rankedAverageable` does not imply `rankedCountable` or `rankedSummable`, unlike `averageable`, which is shorthand for a count and a sum. Declare each ranking the application will query, and no other. + +## Queries + +A ranked query names one aggregate, groups by the ranked property, orders by the aggregate and takes a limit, with an optional offset. On a compound index such as `[city, restaurantId]`: + +```sql +SELECT count(*) FROM review WHERE city == "London" GROUP BY restaurantId ORDER BY count(*) DESC LIMIT 5 +``` + +Every property before the grouped one must be fixed with an equality; at most one of them may instead be an `in` of 2 to 10 values, whose rankings are merged. There is no ranking across different values of those properties. A ranked index still answers every range query the same `range*` flags answer: ranking never changes what they return. + +The request shape, ties, offsets and proofs are described in [Ranked Index Examples](../drive/ranked-index-examples.md). + +## Rules at registration + +- **Its range totals.** Each ranking needs the range flags listed above, checked both by the meta-schema and by the parser. A written-out `false` needs nothing. +- **A non-unique index.** Every group of a unique index holds at most one document, so a ranking on one is refused, and a contested index, which is unique, cannot have one either. +- **`nullSearchable` left at `true`.** With `false`, documents missing the property would still leave an empty group in the ranking. +- **A shorter key.** The ranked property's value becomes part of the ordered tree's key, behind an 8-byte sort key (16 bytes when `rankedAverageable` is declared), so its worst case must fit in what is left of the 255-byte limit. A ranked string takes `maxLength` of at most 61, or 59 when the index declares `rankedAverageable`; a ranked byte array takes `maxItems` of at most 247, or 239. A larger bound is refused (`InvalidIndexedPropertyConstraintError`, 10205). Identifiers, integers and other fixed-width values always fit. The bound applies to each ranked property: the last one when a ranking is declared with `true`, and each property named in `at`. +- **Time windows.** On a [`timeRange`](time-range.md) index, a ranking must sit below the bucketed timestamp, which gives one ranking per window. A single-property time-range index cannot be ranked, and `at` cannot name the bucketed property. +- **Other indexes of the type.** A compound ranked index `[p1, ..., pn]` is refused when another countable or summable index ends at exactly `[p1, ..., pn-1]`. A ranking at an earlier level (`at`) also restricts the other indexes that reach that level; the full table is in [Shape Restrictions](../drive/document-ranked-trees.md#shape-restrictions). +- **One index per property list.** Two indexes with the same properties are a `DuplicateIndexError` (10201), so the rankings of one property list go on one index. +- **Protocol version 14.** Earlier versions do not know the keywords and refuse them. + +A broken rule other than the key length is refused as `InvalidContractStructure` (10231), or by the meta-schema as `JsonSchemaError` (10101). + +## Costs + +Each ranking is one ordered tree, rewritten whenever a document under it is created, changed or deleted, on top of the range totals it is built from. Two rankings on one index cost two rewrites per write; a fully ranked `at` array costs one per ranked level. A ranking at the index's first property gets its tree when the contract is registered; a ranking at a deeper level gets one tree per value above it, as documents arrive. + +## See also + +- [Document Ranked Trees](../drive/document-ranked-trees.md) for the tree variants, prefix-level rankings and how the rankings are maintained. +- [Ranked Index Examples](../drive/ranked-index-examples.md) for worked queries and proofs. +- [Counts, Sums and Averages](aggregates.md) for the range totals a ranking builds on. +- [Time-Range Indexes](time-range.md) for rankings per time window. diff --git a/book/src/contract-keywords/refers-to-expressions.md b/book/src/contract-keywords/refers-to-expressions.md new file mode 100644 index 00000000000..9250094abcf --- /dev/null +++ b/book/src/contract-keywords/refers-to-expressions.md @@ -0,0 +1,130 @@ +# Expressions + +A [`refersTo`](refers-to.md) declaration may combine several targets instead of naming one. `anyOf` holds when at least one of its operands holds, and `allOf` when every operand holds for the same value. Reach for an expression when a value may point at one of several kinds of thing ("an elected member or an added one"), or must satisfy several references at once ("a member of the team who also has a profile"). Both combinators take the same operands, follow the same limits and are checked the same way; they differ only in when they stop. + +## `anyOf` + +| | | +|---|---| +| **Where** | In place of a single target: in `refersTo` on an identifier property or on the `items` of a typed array of identifiers, in `ownerRefersTo` and `creatorRefersTo`, and as an operand of an `allOf`. Not on a key id property. | +| **Value** | `{ "anyOf": [ ... ] }`, the declaration's only key: 2 to 4 distinct [operands](#operands). | +| **Since** | protocol version 14 | +| **On update** | Fixed (`IncompatibleDocumentTypeSchemaError`, 10246), including a change of operand order. | +| **Errors** | None of its own: when no operand holds, the write is refused with the last operand's error, for example `ReferencedEntityNotFoundError` (40120). | + +The operands are checked in the order they are listed, and the first that holds decides: the rest are not read. When none holds, the write is refused with the error of the last one. + +The moderation charters contract lets a member of a seated team resign with a `resignationRequest`. Its writer must be on the team, either elected or added later: + +```json +"ownerRefersTo": { + "anyOf": [ + { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { "electedCharterId": "$id" }, + "inList": "members" + }, + { + "type": "deletableDocument", + "documentType": "addedModerator", + "lookup": { + "index": "byElectedCharterMember", + "keys": { "electedCharterId": "electedCharterId", "memberId": "." } + } + } + ] +} +``` + +The writer must be one of the `members` of the `electedCharter` this document's `electedCharterId` names, or have an `addedModerator` document for that charter that exists now. Here the expression is the writer's reference (see [Writer and Creator References](owner-refers-to.md)); the same declaration works on an identifier property, where it judges the property's value. + +## `allOf` + +| | | +|---|---| +| **Where** | In place of a single target: in `refersTo` on an identifier property or on the `items` of a typed array of identifiers, in `ownerRefersTo` and `creatorRefersTo`, and as an operand of an `anyOf`. Not on a key id property. | +| **Value** | `{ "allOf": [ ... ] }`, the declaration's only key: 2 to 4 distinct [operands](#operands). | +| **Since** | protocol version 14 | +| **On update** | Fixed (`IncompatibleDocumentTypeSchemaError`, 10246), including a change of operand order. | +| **Errors** | None of its own: the write is refused with the first failing operand's error, for example `ReferencedEntityNotFoundError` (40120). | + +The operands are checked in the order they are listed, and the first that fails decides: the rest are not read, and the write is refused with that operand's error. + +```json +"memberId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "allOf": [ + { + "type": "listElement", + "contractId": "EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88", + "documentType": "electedCharter", + "propertyAgreement": { "electedCharterId": "$id" }, + "inList": "members" + }, + { + "type": "deletableDocument", + "contractId": "Bwr4WHCPz5rFVAD87RqTs3izo4zpzwsEdKPWUT1NS1C7", + "documentType": "profile", + "lookup": { "index": "ownerId", "keys": { "$ownerId": "." } } + } + ] + }, + "position": 1 +} +``` + +This property of a hypothetical moderator directory, next to an `electedCharterId` identifier property, must be one of the elected members of that charter in the moderation charters contract, and must have a DashPay profile when the document is written. The list check comes first, so a value that is not a member is refused without the profile lookup being read. + +## Operands + +An operand is a leaf or a nested expression. + +**Leaves.** A leaf is an ordinary target declaration with its own keys, one of: + +- `identity`; +- `permanentDocument`, by id or with a [`lookup`](refers-to-lookup.md); +- [`listElement`](refers-to-list-element.md); +- `deletableDocument` with a `lookup`. + +The first three are existence checks against things that are never deleted, so an expression made only of them holds for good once it holds. A deletable lookup may find nothing later, which is why an expression holding one is checked on every replace. + +The other targets are refused as operands, since they do not compose with other operands: + +- `deletableDocument` by id: once its document is deleted a replace may clear the property, which assumes the property refers to that one target. +- `identityPublicKey`, in either form: it pairs the value with a key id that no other operand reads. +- `contract`: its requirements are gates judged against the block time and the writer, not an existence check, and a contract id is never also an identity or document id. +- `token`: a token id is never also an identity or document id. + +**Nesting.** An operand may be an expression of the other combinator: an `allOf` inside an `anyOf`, or an `anyOf` inside an `allOf`. An `anyOf` directly inside an `anyOf`, or an `allOf` inside an `allOf`, is refused, since it says what one flat list says. + +A [`propertyAgreement`](refers-to.md#propertyagreement) or a `lookup` belongs to its leaf, inside it: the expression itself holds nothing but its combinator. + +## How it works + +- **Order decides the error.** A refusal is always the error a leaf declared alone would give, naming the property (or the element) as a single reference would. So the author's order decides which error a writer sees: put the most general operand of an `anyOf` last, and the cheapest or most telling operand of an `allOf` first. +- **Every read is billed.** A value the second operand of an `anyOf` holds for also pays for the first operand's read. An `allOf` whose first operand fails reads nothing more. +- **Agreements are per leaf.** A `propertyAgreement` is checked only against its own leaf's document. A value whose first leaf fails its agreement can still be accepted through a second leaf that has none. +- **Typed arrays.** On the `items` of a typed array, each element meets the expression on its own. +- **Replace.** An expression is checked again when any of its leaves would be checked again alone (see [On replace](refers-to.md#on-replace)), and then it is evaluated whole, since which operands hold may have changed. An expression holding a `deletableDocument` lookup is therefore checked on every replace. +- **Budget.** Every leaf counts against the [reference budget](refers-to.md#the-reference-budget), since each may be read for each value. An `anyOf` of two leaves on a typed array of `maxItems` 15 counts 30. +- **Queries.** An expression cannot be the join property of a chained query or of a composite join by id, and a `preallocated` index is never bound through one. + +## Rules at registration + +- The combinator is the declaration's only key, and lists at least two operands: a single one is declared on its own. No two operands of one list may be alike; a leaf naming the declaring contract's id in `contractId` is the same as one leaving it out. +- A list holds at most **4** operands, and any path from the declaration to a leaf passes through at most **4** combinators. The `anyOf` holding an `allOf` is 2 deep. +- Every leaf is one of the four admitted targets, and is checked exactly as the same target declared alone: its document type, the type's deletability, its `propertyAgreement` and its `lookup` or list. Every leaf must pass, since each has to be a declaration that could hold. The errors name the leaf by where it sits: `refersTo anyOf[1].allOf[1] lookup: ...` from the parser, `resignation.memberId.anyOf[1].allOf[1]` from the registration check. +- An `immutable` property may not hold an expression with a `deletableDocument` lookup leaf (`InvalidContractStructure`, 10231). +- Every leaf counts against the reference budget of 256. + +A malformed expression is refused by the meta-schema (`JsonSchemaError`, 10101) or the parser (`InvalidContractStructure`, 10231); a leaf that cannot hold, with the reference error it would get alone (see [Errors](refers-to.md#errors)). On an update, any change is refused (10246): an operand added, removed, changed or moved, `anyOf` swapped for `allOf`, or a single target turned into an expression or back. + +## See also + +- [References (refersTo)](refers-to.md) for the targets and the replace rules. +- [Lookups](refers-to-lookup.md), [List Elements](refers-to-list-element.md) and [Writer and Creator References](owner-refers-to.md), whose declarations are the usual leaves and holders of an expression. +- [Reference expressions](../data-model/documents.md#reference-expressions-anyof-allof) in the Documents chapter, with the internals. +- [propertyConstraints](property-constraints.md), whose rules also combine with `anyOf` and `allOf` but compare the document's own values instead of reading other state. diff --git a/book/src/contract-keywords/refers-to-list-element.md b/book/src/contract-keywords/refers-to-list-element.md new file mode 100644 index 00000000000..df4cad192bc --- /dev/null +++ b/book/src/contract-keywords/refers-to-list-element.md @@ -0,0 +1,87 @@ +# List Elements + +A `listElement` reference says the value must be one of the identifiers a list on another document holds. `inList` names the list, and the `$id` pair of `propertyAgreement` names the document holding it. Reach for it when membership is written down once as a list on a document that never changes, such as the members elected with a charter, and other documents must name one of them. + +| | | +|---|---| +| **Where** | `refersTo` on an identifier property, or on the `items` of a typed array of identifiers, where every element must be listed; an operand of an [expression](refers-to-expressions.md); or [`ownerRefersTo` and `creatorRefersTo`](owner-refers-to.md), where the writer or the creator must be listed. | +| **Value** | `"type": "listElement"` with `documentType` (the type holding the list), `propertyAgreement` (exactly one pair with `$id` on its referenced side, and up to nine other pairs), `inList` (the path of a typed array of identifiers on that type), and optionally `contractId`. | +| **Since** | protocol version 14 | +| **On update** | Fixed, like the rest of `refersTo` (`IncompatibleDocumentTypeSchemaError`, 10246). | +| **Errors** | `ReferencedEntityNotFoundError` (40120) when the value is not in the list or the list's document is not found; `ReferencedDocumentPropertyMismatchError` (40127) for another pair; at registration `ReferencedDocumentListInvalidError` (40138) for a list of another contract. | + +## Example + +The moderation charters contract lets the leader of a seated team take an elected member off it with a `removedModerator` document: + +```json +"removedModerator": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "properties": { + "electedCharterId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter", + "propertyAgreement": { "$ownerId": "$ownerId" } + }, + "position": 0 + }, + "memberId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId", + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { "electedCharterId": "$id" }, + "inList": "members" + }, + "position": 1 + } + }, + "required": ["$createdAt", "electedCharterId", "memberId"], + "additionalProperties": false +} +``` + +`electedCharterId` must name an elected charter the writer owns, so only the team's leader can write one. `memberId` must be one of the `members` of the `electedCharter` document whose `$id` this document's `electedCharterId` holds, so only an elected member can be removed. `electedCharter` is immutable and can never be deleted, so its `members` never change. + +## How it works + +When the referring document is created or replaced: + +1. The document holding the list is the one whose `$id` equals the value of the `$id` pair's referring property. It is fetched by id, once per write: in the example the `electedCharterId` reference and the list reference share one fetch. The list is collected once, so checking many values against it costs one read. +2. The other `propertyAgreement` pairs are checked against that document, as for any document reference (`ReferencedDocumentPropertyMismatchError`, 40127). +3. The value must be in the list. On a typed array every element must be; on the writer's or creator's reference, the writer or creator must be. + +A value not in the list, a list document that does not exist, or a value set while the `$id` property is not, refuses the write with `ReferencedEntityNotFoundError` (40120), naming the property, or an element by its list path (`memberIds[1]` for the second): + +```text +electedCharter 7kX...: members [Alice, Bob] +removedModerator { electedCharterId: 7kX..., memberId: Alice } -> accepted +removedModerator { electedCharterId: 7kX..., memberId: Carol } -> refused, 40120: + referenced list element (members of the electedCharter document electedCharterId names) + not found for path memberId +``` + +A replace checks the reference again when its value changed, or when the referring side of any of its pairs changed: the `$id` property among them, since it may now name another document, whose list is then checked against every value. A pair keyed by `$ownerId` is checked on every replace. When only a typed array of values changed, the elements the stored list already held are not checked again. Nothing else can make a validated value unlisted: the list's document is never deleted and its list never changes. + +## Rules at registration + +- `propertyAgreement` holds exactly one pair with `$id` on the referenced side. Its referring side is an identifier property of the referring document type: not `$ownerId` (no document has the writer's id) and not a typed array (one document holds the list). It must be stored, so it and every object around it are not `transient`, and a reader can tell from the stored document which list the value was checked against. It may be optional. It needs no `refersTo` of its own; if it has one, that must be a reference by id to `documentType` in the list's contract, or the pair could never hold. Refused with `InvalidContractStructure` (10231) otherwise. +- The other pairs follow the rules of every [`propertyAgreement`](refers-to.md#propertyagreement) (`ReferencedDocumentPropertyAgreementInvalidError`, 40126). +- `documentType` must exist (`ReferencedDocumentTypeNotFoundError`, 40121), and its documents must never disappear: `canBeDeleted: false`, no `canBeDeletedByModerators` and no `ttl`. +- `inList` is a property path (a dotted one for a nested list, never a `$` name) of a stored typed array of identifiers on `documentType`, and the list must be fixed once a document is written: the type is immutable (`documentsMutable: false`), or the list's top-level property is listed under `immutable`. A list that is also under `immutableAllowSetting` qualifies, since it can only be set once on a document that had none, and an absent list accepted no value. +- The checks on `documentType` and `inList` are refused with `InvalidContractStructure` (10231) for a document type of the declaring contract. For one of another contract, a deletable type is refused with `ReferencedDocumentTypeDeletableError` (40122) and a list that does not qualify with `ReferencedDocumentListInvalidError` (40138). +- Each value counts one against the [reference budget](refers-to.md#the-reference-budget), `maxItems` for a typed array. + +## See also + +- [References (refersTo)](refers-to.md) for `documentType`, `contractId`, `propertyAgreement` and the replace rules. +- [Expressions](refers-to-expressions.md) and [Writer and Creator References](owner-refers-to.md), where a list element is a common operand or target. +- [An element of a list](../data-model/documents.md#an-element-of-a-list-listelement) in the Documents chapter, with the internals. +- [Typed Arrays](typed-arrays.md) and [Mutability](mutability.md) for the list and the rules that keep it fixed. diff --git a/book/src/contract-keywords/refers-to-lookup.md b/book/src/contract-keywords/refers-to-lookup.md new file mode 100644 index 00000000000..3223a77dd7f --- /dev/null +++ b/book/src/contract-keywords/refers-to-lookup.md @@ -0,0 +1,124 @@ +# Lookups + +A `lookup` lets a document reference find its target through a unique index of the referenced document type instead of by id. The property's value is then one part of the index key, the rest comes from the referring document or its writer, and the reference holds if the index finds a document. Reach for it when the natural value is an identity (a member, an author, a recipient) and the rule is "this identity has a document of that kind": with an id reference the writer would have to find the document's id first, and the id would say nothing about who it belongs to. + +| | | +|---|---| +| **Where** | Inside a `permanentDocument` or `deletableDocument` [`refersTo`](refers-to.md) declaration: on an identifier property, on the `items` of a typed array of identifiers, as an operand of an [expression](refers-to-expressions.md), or in [`ownerRefersTo` and `creatorRefersTo`](owner-refers-to.md). | +| **Value** | `{ "index": ..., "keys": { ... } }`. `index` is the name of a unique index of `documentType`, 1 to 32 characters. `keys` maps every property of that index (1 to 10) to where its value comes from: `"."` (the reference's own value), `"$ownerId"` (the writer) or a property path of the referring document type. | +| **Since** | protocol version 14 | +| **On update** | Fixed, like the rest of `refersTo`: adding, removing or changing a lookup is refused (`IncompatibleDocumentTypeSchemaError`, 10246). | +| **Errors** | `ReferencedEntityNotFoundError` (40120) when the index finds no document; at registration `ReferencedDocumentLookupInvalidError` (40137) for an index of another contract, `InvalidContractStructure` (10231) for one of the same contract. | + +## Example + +The moderation charters contract's `joinRequest` type, which is immutable and can never be deleted, has this unique index: one join request per proposal and owner. + +```json +"indices": [ + { + "name": "bySubmittedCharter", + "properties": [{ "submittedCharterId": "asc" }, { "$ownerId": "asc" }], + "unique": true + } +] +``` + +The same contract's `electedCharter` lists its team in `members`, each of whom must have asked to join: + +```json +"members": { + "type": "array", "minItems": 0, "maxItems": 15, "uniqueItems": true, + "items": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId", + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": "submittedCharterId", "$ownerId": "." } + } + } + }, + "position": 2 +} +``` + +Every member must be the owner of a `joinRequest` whose `submittedCharterId` equals this elected charter's `submittedCharterId`. For each element the key is `submittedCharterId` read from the elected charter and `$ownerId` filled with the element itself (`"."`). The same form works on a single identifier property, where `"."` is the property's value. + +## How it works + +### Assembling the key + +When the referring document is created or replaced, a key is assembled for each value (each element of a typed array), one part per entry of `keys`: + +- `"."`: the value being checked, the property's value or the element. +- `"$ownerId"`: the referring document's owner, the writer. +- a property path (`"submittedCharterId"`, `"meta.charterId"`): the referring document's value at that path. + +The index is queried for at most one document, billed as a document fetch. If it finds one, the reference holds, and any [`propertyAgreement`](refers-to.md#propertyagreement) pairs beside the `lookup` are checked against that document (`ReferencedDocumentPropertyMismatchError`, 40127). If it finds none, the write is refused with `ReferencedEntityNotFoundError` (40120) naming the property, or the element by its list path (`members[1]`); the error's target reads "found through unique index" and the index name. + +### Permanent and deletable lookups + +A `permanentDocument` lookup never dangles. Its referenced documents are never deleted, and registration makes sure the key of every one of them is fixed once written (see [below](#rules-at-registration)), so the document a key found stays there. A replace checks it again only: + +- when the property itself changed (for a typed array, only the elements the stored list did not hold); +- when a property a key reads changed, and then every value, every element included; +- when an agreement's referring property changed, or on every replace for a pair keyed by `$ownerId`. + +A key part read from `"$ownerId"` never triggers a check: only a type whose documents keep their writer may read it. + +A `deletableDocument` lookup promises less. Once the document a key found is deleted, a new document filed later under the same key makes the reference hold again, with different content, where an id reference to a deleted document stays dead. So a deletable lookup means "a document with this key exists now", which is what a membership gate needs. Every replace checks it again, whether or not the replace touched it, and no `immutable` property may hold one. The moderation charters contract's `resignationRequest` uses one to accept a writer who has an `addedModerator` document for the charter at the time of writing, as the alternative to being an elected member (see [Writer and Creator References](owner-refers-to.md)). + +A lookup can also reach into another contract. This `recipientId` must be an identity that has a DashPay profile when the document is written: + +```json +"recipientId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "deletableDocument", + "contractId": "Bwr4WHCPz5rFVAD87RqTs3izo4zpzwsEdKPWUT1NS1C7", + "documentType": "profile", + "lookup": { "index": "ownerId", "keys": { "$ownerId": "." } } + }, + "position": 0 +} +``` + +DashPay's `profile` type has a unique index `ownerId` on `$ownerId`, and profiles can be deleted, so the reference is a `deletableDocument` one. + +### What a lookup cannot do + +The value of a lookup reference is a key part, not a document id. So a lookup reference cannot be the join property of a chained query or of a composite join by id, and a `preallocated` index is never bound through one (see [Index-Only Types](index-only.md)). A lookup counts as one reference per value against the [reference budget](refers-to.md#the-reference-budget), like any other. + +## Rules at registration + +**On the referring side**, checked for every contract by the parser (`InvalidContractStructure`, 10231): + +- `lookup` is only allowed on `permanentDocument` and `deletableDocument` references. +- `"."` fills exactly one key part. Without it every value would find the same document. +- Every other source is `"$ownerId"` or a property path. No other `$` name is accepted, and a path may not name the reference property itself: write `"."` for that. +- A property a key reads must exist on the referring type, be required (and so must every object around it), not be `transient` or inside a transient object, and hold a single value, not an object or an array. A lookup never runs with a missing key part, and a reader can assemble the same key from the stored document. +- A `"$ownerId"` key part needs a referring type whose documents can be neither transferred nor traded: a transfer or a purchase would move the writer part of the key without a write. +- A `deletableDocument` lookup may not sit under an `immutable` property, alone or as an operand of an expression: every replace checks it again, so once its document is deleted the property would have to change. + +**On the referenced side**, refused with `InvalidContractStructure` (10231) for a document type of the declaring contract and with `ReferencedDocumentLookupInvalidError` (40137) for one of another contract: + +- The index exists and is `unique`, so a key finds at most one document. It does not bucket its first property by a `timeRange`, the referenced type is not `indexOnly`, and no index property is `transient`. +- `keys` maps every property of the index exactly once, by its name on the referenced side (system properties such as `$ownerId` included), in any order, and nothing else. +- Each source holds the same type of value as the index property it fills. `"."` and `"$ownerId"` are identifiers. +- The key stays with the document it found. Every schema property of the index must be fixed once written: the referenced type is immutable (`documentsMutable: false`), or the property's top-level property is listed under `immutable`. `$ownerId` may be a key part only where the referenced documents can be neither transferred nor traded. `$updatedAt` and its block height forms may be one only where they can be neither replaced, transferred nor traded, and `$transferredAt` and its forms only where they can be neither transferred nor traded. `$id`, `$creatorId`, `$createdAt` and its forms never change. + +**The referenced type** must exist (`ReferencedDocumentTypeNotFoundError`, 40121). A `permanentDocument` lookup into a type whose documents can disappear is refused with `ReferencedDocumentTypeDeletableError` (40122), and a `deletableDocument` lookup into one whose documents cannot with `ReferencedDocumentTypeNotDeletableError` (40131). + +Index definitions and the flags these rules read cannot change on a contract update, so a lookup that registered keeps resolving. + +## See also + +- [References (refersTo)](refers-to.md) for the targets, `propertyAgreement` and the replace rules. +- [Expressions](refers-to-expressions.md), where a lookup may be an operand, and [Writer and Creator References](owner-refers-to.md), where `"."` is the writer or the creator. +- [Resolved through a unique index](../data-model/documents.md#resolved-through-a-unique-index-lookup) in the Documents chapter, with the internals. +- [Indexes (indices)](indexes.md), [Time-Range Indexes](time-range.md), [Mutability](mutability.md) and [transient](transient.md) for the keywords the rules read. diff --git a/book/src/contract-keywords/refers-to.md b/book/src/contract-keywords/refers-to.md new file mode 100644 index 00000000000..6390eaee15d --- /dev/null +++ b/book/src/contract-keywords/refers-to.md @@ -0,0 +1,324 @@ +# References (refersTo) + +An identifier (a 32-byte id) can hold any value. `refersTo` says what it points at, and Platform then checks, whenever a document is created or replaced, that the thing it names exists: an identity, a data contract, a token, a document, or one key of an identity. Reach for it when a document only makes sense next to something else: a reply needs its post, a join request needs the proposal it joins, an encrypted message needs the key it was encrypted to. The check runs when the document is written. Nothing checks the reference again when its target changes later, and nothing resolves it for a reader. + +| | | +|---|---| +| **Where** | An identifier property, at the top level or inside an object; the `items` of a typed array of identifiers, where every element is checked; and, for one form of `identityPublicKey`, an integer key id property. `ownerRefersTo` and `creatorRefersTo` carry the same declaration at the document type level. | +| **Value** | An object: `type`, naming one [target](#targets), with the [keys](#keys) that target takes; or an object holding only `anyOf` or only `allOf` (see [Expressions](refers-to-expressions.md)). | +| **Default** | Absent: the identifier is not checked against anything. | +| **Since** | protocol version 14 | +| **On update** | Fixed: adding, removing or changing any part of a declaration is refused (`IncompatibleDocumentTypeSchemaError`, 10246). | +| **Errors** | `ReferencedEntityNotFoundError` (40120) when the target does not exist; the full list is under [Errors](#errors). | + +## Example + +```json +"reply": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "postId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "deletableDocument", "documentType": "post" }, + "position": 0 + }, + "mentions": { + "type": "array", "minItems": 0, "maxItems": 5, "uniqueItems": true, + "items": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "identity" } + }, + "position": 1 + }, + "text": { "type": "string", "minLength": 1, "maxLength": 280, "position": 2 } + }, + "required": ["postId", "text"], + "additionalProperties": false +} +``` + +`postId` must be the id of a `post` document of this contract that exists when the reply is written. Posts can be deleted, so the reference is a `deletableDocument` one. Each of the up to five `mentions` must be the id of an existing identity. The type carries six references at most: one for `postId` and five for `mentions`. + +## Targets + +`type` names what the value points at. + +| `type` | The value must be | Keys it takes | +|---|---|---| +| `identity` | the id of an existing identity | none | +| `contract` | the id of an existing data contract | `contractRequirements` | +| `token` | the id of an existing token | none | +| `permanentDocument` | the id of an existing document of a type whose documents can never disappear | `documentType` (required), `contractId`, `propertyAgreement`, `lookup` | +| `deletableDocument` | the id of an existing document of a type whose documents can disappear | `documentType` (required), `contractId`, `propertyAgreement`, `lookup` | +| `identityPublicKey` | an identity key that exists and is not disabled | `keyIdProperty` or `identityProperty` (one of them, required), `keyRequirements` | +| `listElement` | one of the identifiers a list on another document holds | `documentType`, `propertyAgreement` and `inList` (all required), `contractId` | + +A key that belongs to another target is refused when the contract is registered. + +### `identity` + +The value is the id of an identity that exists. The check reads the identity's revision. Identities are never removed, so a validated reference never dangles. + +### `contract` + +The value is the id of a data contract that exists. [`contractRequirements`](#contractrequirements) can ask more of it: that it declares elected moderation, has reached a certain age, belongs to the writer, and so on. Contracts are never deleted. + +### `token` + +The value is the id of a token. The check reads the token's record, which is written when its contract is registered and never removed. + +### `permanentDocument` + +The value is the id of a document of `documentType`, in this contract or in the one `contractId` names. The referenced type must be one whose documents can never disappear: `canBeDeleted: false`, no `canBeDeletedByModerators` and no `ttl`. None of those can change on a contract update and document types are never removed, so a validated permanent reference never dangles. + +```json +"reasons": { + "type": "array", "minItems": 0, "maxItems": 64, "uniqueItems": true, + "items": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "permanentDocument", "documentType": "reason" } + }, + "position": 2 +} +``` + +This is the moderation charters contract's `submittedCharter.reasons`: every element must be the id of a `reason` document, a type that is immutable and can never be deleted. With a [`lookup`](refers-to-lookup.md) the value is instead one part of a unique index key that finds the document. + +### `deletableDocument` + +The same for a type whose documents can disappear: deleted by their owner (`canBeDeleted`), removed by the contract's moderators (`canBeDeletedByModerators`), or removed by the platform when their `ttl` passes. Any one of the three makes a type deletable for references. The document must exist when the referring document is written, and may be deleted afterwards. + +Because the target may be gone, every replace of the referring document checks the reference again, whether or not the replace touched it. Once the target is deleted, the replace has to point the property at a document that exists or remove it. A required property cannot be removed, so a document whose required reference has lost its target can be replaced only after it is repointed; it can still be deleted. A single `deletableDocument` reference held by an `immutable` top-level property may be removed by a replace once its target is gone, an exception to the immutability rule. + +### `identityPublicKey` + +One key of one identity, which must exist and not be disabled. Identity keys can be disabled but never removed, so a validated key reference never dangles, while a disabled key refuses new writes. The declaration comes in two forms, which differ in which property carries it: + +- **On the identity property.** The identifier holds the identity's id and [`keyIdProperty`](#keyidproperty-and-identityproperty) names the sibling integer property holding the key id. The moderation charters contract's `joinRequest.recipientId`: + + ```json + "recipientId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": "decryption", "boundTo": "submittedCharter" } + }, + "position": 1 + } + ``` + +- **On the key id property.** The integer holds the key id and [`identityProperty`](#keyidproperty-and-identityproperty) names whose key it is. The property must declare exactly the range of a key id, `"minimum": 0` and `"maximum": 4294967295`. The same contract's `joinRequest.senderKeyId`, a key of the writer: + + ```json + "senderKeyId": { + "type": "integer", "minimum": 0, "maximum": 4294967295, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "keyRequirements": { "purpose": "encryption", "boundTo": "joinRequest" } + }, + "position": 3 + } + ``` + +A key reference pairs the value with one key id, so it is refused on the elements of a typed array, as an operand of an expression and in `ownerRefersTo` or `creatorRefersTo`. + +### `listElement` + +The value must be one of the identifiers a typed array on another document holds. See [List Elements](refers-to-list-element.md). + +## Keys + +### `documentType` + +The name of the referenced document type, 1 to 64 letters, digits or underscores. Required on `permanentDocument`, `deletableDocument` and `listElement`, and refused on the other targets. For `permanentDocument` and `listElement` the type's documents must never disappear; for `deletableDocument` they must be able to. + +### `contractId` + +The contract holding `documentType`, as a base58 string or an array of 32 bytes. Absent means the declaring contract; naming the declaring contract's own id means the same. Only on the three document targets. A reference into another contract costs a billed fetch of that contract when a document is written, once per declaration: the elements of a typed array and the operands of an expression share it. + +### `propertyAgreement` + +Binds the referring document to the document it references: each pair `{ "": "" }` must hold as an equality when the referring document is written. It takes 1 to 10 pairs, on `permanentDocument`, `deletableDocument` and `listElement` references. + +- **The referring side** (the key) is a property of the declaring document type, a dotted path for a nested one, or `$ownerId`, the writer. A pair keyed by `$ownerId` is a write gate: only an identity whose id equals the referenced side may create or replace the document. +- **The referenced side** (the value) is a property of the referenced document type, or one of the referenced document's own identifiers: `$ownerId` (its current owner, which follows it through transfers), `$creatorId` (its creator, which never changes, on types that record it, see [System Properties](system-properties.md)) or `$id` (its id). A system name needs an identifier on the referring side. +- **Absence counts.** Both sides absent agree; one side absent is a mismatch, as a different value is. +- The comparison reads the document already fetched for the existence check, so it costs nothing more. A pair that does not hold refuses the write with `ReferencedDocumentPropertyMismatchError` (40127). + +```json +"submittedCharterId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "submittedCharter", + "propertyAgreement": { + "$ownerId": "$ownerId", + "targetContractId": "targetContractId" + } + }, + "position": 1 +} +``` + +This is the moderation charters contract's `electedCharter.submittedCharterId`: the proposal it names must be owned by the writer, and must be for the same `targetContractId` as the elected charter. Other patterns: `{ "authorId": "$ownerId" }` makes a like carry its post's owner, and `{ "$ownerId": "$creatorId" }` lets only the referenced document's creator write. + +At registration both sides must exist and hold the same type of value (for integers, the same stored integer type). Neither side may be an object or a typed array, the referring side may not be the reference property itself, and the referenced side may not be `transient` (no stored document carries it; the referring side may be). A pair breaking one of these is refused with `ReferencedDocumentPropertyAgreementInvalidError` (40126). + +### `contractRequirements` + +What a `contract` reference requires of the referenced contract beyond existing. It holds at least one of these keys: + +| Key | Value | Met when | +|---|---|---| +| `moderation` | `"elected"` | the contract declares an elected moderation team | +| | `"electionOpen"` | the contract declares an elected team, and its own `electionDelay`, counted from the contract's creation, has passed at the block time of the write (or it declares no delay) | +| `minimumAgeSeconds` | integer, 1 to 4294967295 | the contract's recorded creation time is at least that many seconds before the block time of the write | +| `minimumSecondsSinceUpdate` | integer, 1 to 4294967295 | the same, counted from the later of its creation and its last update | +| `owner` | `"self"` | the contract is owned by the writer of the referring document | +| | `"other"` | the contract is owned by anyone else | +| `readonly` | `true` | the contract's config is `readonly`: it can never be updated again | +| `keepsHistory` | `true` | the contract's config keeps history | +| `ownerProtected` | `true` or `false` | the contract's elected moderation protects, or does not protect, its owner from the team; a contract without elected moderation meets neither | + +A contract with no recorded creation time never meets `minimumAgeSeconds` or `minimumSecondsSinceUpdate`. The requirements are judged against the contract already fetched for the existence check, the writer and the block time, so they cost no further read. The first unmet one refuses the write with `ReferencedContractRequirementNotMetError` (40135); a contract that does not exist is still `ReferencedEntityNotFoundError` (40120). + +The moderation charters contract uses both moderation values: a proposal (`submittedCharter.targetContractId`) needs a target that is `elected`, so teams can form while the target's election delay runs, and the charter that opens the election (`electedCharter.targetContractId`) needs it `electionOpen`: + +```json +"refersTo": { "type": "contract", "contractRequirements": { "moderation": "electionOpen" } } +``` + +`owner` is the one requirement judged against the writer, and a transfer or a purchase changes the owner without a write. On a type whose documents can be transferred or traded, a reference carrying `owner` is therefore checked again on every replace, so a new owner has to repoint it at a contract that meets the requirement for them, or remove it where it is optional. Such a reference may not sit under an `immutable` property of such a type, which could never be repointed. See [Elected Moderation](../data-model/contract-moderation.md#elected-moderation) for the moderation values. + +### `keyIdProperty` and `identityProperty` + +The two forms of `identityPublicKey`. A declaration takes exactly one of them. + +- **`keyIdProperty`**, on the identity property: the path of the integer property of the same document type holding the key id. It must exist, be an integer, and not carry a key reference of its own. If the identity is set and the key id is not, the write is refused with `ReferencedKeyIdPropertyInvalidError` (40125). +- **`identityProperty`**, on the key id property: whose key the value is. + - `"$ownerId"`: the writer. The writer's existence is already proven, so the key fetch is the only read. + - `"$creatorId"`: the document's creator, only on a type that records creator ids. A document written before its type recorded them has none, and setting the key id on it is refused (40125). + - The path of an identifier property of the same type, which must exist, be an identifier and not carry an `identityPublicKey` reference of its own. A key id set while that property is not set is refused (40125). + +A stored key id may not be paired with a `transient` identity, since the key id alone names no key. Each rule is checked at registration and refused with `ReferencedKeyIdPropertyInvalidError` (40125). + +### `keyRequirements` + +What an `identityPublicKey` reference requires of the key beyond existing and not being disabled. It holds at least one of: + +- `purpose`: the key's purpose, one of `authentication`, `encryption`, `decryption`, `transfer`, `voting` or `owner`. +- `boundTo`: a document type of the declaring contract. The key must be bound to exactly this contract and that document type; a key bound to the whole contract or to a contract group does not meet it. + +At registration `boundTo` must name a document type of the contract, and one a key of the required purpose can be bound to: only authentication, encryption and decryption keys carry a document type bound, an encryption key only where the type declares `requiresIdentityEncryptionBoundedKey`, and a decryption key only where it declares `requiresIdentityDecryptionBoundedKey` (see [Signing and Keys](signing-keys.md)). The requirements are judged against the key already fetched, and the first unmet one refuses the write with `ReferencedIdentityKeyRequirementNotMetError` (40136). A missing key is still 40123 and a disabled one 40124. + +### `lookup`, `inList`, `anyOf` and `allOf` + +- [`lookup`](refers-to-lookup.md) finds a `permanentDocument` or `deletableDocument` through a unique index of its type, with the value as one part of the key. +- [`inList`](refers-to-list-element.md) names the list a `listElement` value must be in. +- [`anyOf` and `allOf`](refers-to-expressions.md) combine several targets in one declaration. + +## How it works + +### On create + +When a document is created, every reference is checked against the current state: the `ownerRefersTo` or `creatorRefersTo` declaration first, then each property's. The first check that fails refuses the write with that check's error, naming the property by its path (`postId`, `meta.charterId`), an element by its list path (`mentions[2]` for the third) and the writer or creator reference as `$ownerId` or `$creatorId`. A refused write is still charged for the reads it made. + +- A reference property the document leaves out is not checked. Whether it may be left out is up to `required`. +- Each check is a billed read: the identity, the contract (none for the declaring contract, which is already loaded), the token's record, the document by id or through its lookup, or the key. One write fetches a given document by id once, however many references name it. +- On a typed array each element is checked in list order as a single reference would be. An element repeating an earlier one is not checked twice. + +### On replace + +A replace checks a reference again only when its outcome could have changed: + +| Declaration | Checked again on a replace when | +|---|---| +| `identity`, `token`, `contract` | the value changed | +| `contract` with an `owner` requirement, on a type whose documents can be transferred or traded | every replace | +| `permanentDocument`, `listElement` | the value changed, or the referring property of an agreement pair changed | +| any document reference with a pair keyed by `$ownerId` | every replace | +| `permanentDocument` with a `lookup` | also when a property a key reads changed | +| `deletableDocument`, by id or with a `lookup` | every replace | +| `identityPublicKey` with `keyIdProperty` | the identity or the key id changed | +| key id with `identityProperty: "$ownerId"` | every replace | +| key id with `identityProperty: "$creatorId"` | the key id changed | +| key id with an identity property path | the key id or that property changed | +| `anyOf` or `allOf` | one of its operands would be checked again; the whole expression is then checked | + +A value changed when the replace set it differently, added it or removed it. Changes are tracked per top-level property, so a change anywhere in an object checks again every reference inside that object. When a typed array changed, only the elements the stored list did not hold are checked, unless a rule above that applies to every element does (an agreement's referring property changed, a `$ownerId` pair, an `owner` requirement on such a type, or a `deletableDocument` target); then every element is checked. The rules for `ownerRefersTo` and `creatorRefersTo` are in [Writer and Creator References](owner-refers-to.md). + +The "every replace" rows exist because something the reference depends on can change without a write to the referring document: the writer after a transfer or purchase, or the target's existence for a deletable one. + +### Transfers, purchases, deletes and restores + +- A transfer or a purchase checks no reference. A reference governs writing, not holding: a new owner meets the writer gates (a `$ownerId` pair, `identityProperty: "$ownerId"`, an `owner` requirement) on their first replace. +- Deleting a referring document checks nothing. Deleting a referenced document does not look for documents referring to it: a `permanentDocument` target cannot be deleted at all, and a `deletableDocument` reference meets its missing target on the referring document's next replace. +- A document a moderator removed and later restores comes back as it was, without its references being checked again (see [Restoring Documents](../data-model/contract-moderation.md#restoring-documents)). + +## The reference budget + +Every reference is a billed read when a document is written, so a document type may carry at most **256** references per document. Registration counts: + +- one for each property declaring `refersTo`, whether an identifier or a key id; +- `maxItems` for each typed array whose elements declare it; +- one for the type's `ownerRefersTo` or `creatorRefersTo`; +- each of these multiplied by the number of leaves when the declaration is an expression. + +The `reply` above counts 6. A typed array of `maxItems` 15 whose elements declare an `anyOf` of two targets counts 30. A type over the budget is refused at registration with `InvalidContractStructure` (10231). + +## Rules at registration + +A contract's declarations are checked when it is registered, and again for the whole contract on every update. A declaration that is malformed is refused by the meta-schema (`JsonSchemaError`, 10101) or by the parser (`InvalidContractStructure`, 10231). A declaration that is well formed but cannot hold is refused with the reference errors below: those are judged against the contract itself for its own document types, and against the stored contract for another contract's. + +- `refersTo` sits on an identifier property or on the `items` of a typed array of identifiers. On the array itself it is refused: the declaration belongs on its `items`. The one exception is the key id form of `identityPublicKey`, on an integer property with exactly `"minimum": 0` and `"maximum": 4294967295`. +- A declaration holds `type` and the keys its target takes, or a single `anyOf` or `allOf`. +- A referenced `documentType` must exist (`ReferencedDocumentTypeNotFoundError`, 40121). Its documents must never disappear for `permanentDocument` and `listElement` (`ReferencedDocumentTypeDeletableError`, 40122) and must be able to for `deletableDocument` (`ReferencedDocumentTypeNotDeletableError`, 40131). A `listElement` whose list is in the declaring contract is the exception: the parser checks its type and reports a deletable one as `InvalidContractStructure` (10231). +- Every `propertyAgreement` pair must be one that can hold (40126), every key reference must fit the document type (40125), every `boundTo` must name a type a key can be bound to (10231), and every [lookup](refers-to-lookup.md#rules-at-registration) and [list](refers-to-list-element.md#rules-at-registration) must resolve. +- An `immutable` property may not hold a `deletableDocument` reference that a replace could not remove: one inside an object, a typed array of them, or any `deletableDocument` found through a lookup. A single `deletableDocument` reference by id that is itself an immutable top-level property is allowed, but not also under `immutableAllowSetting`, which would let a replace set it to another document once it was cleared. A `contract` reference with an `owner` requirement may not sit under an immutable property of a type whose documents can be transferred or traded. All refused with `InvalidContractStructure` (10231); see [Mutability](mutability.md). +- The type stays within the [reference budget](#the-reference-budget). +- On an update, every existing declaration must be unchanged (10246). A document type the update adds may declare any reference. + +## Errors + +| Error | Code | When | +|---|---|---| +| `ReferencedEntityNotFoundError` | 40120 | Write: the identity, contract, token or document does not exist, a lookup finds no document, or a value is not in its list. | +| `ReferencedDocumentTypeNotFoundError` | 40121 | Registration: `documentType` does not exist, or `contractId` names no contract. | +| `ReferencedDocumentTypeDeletableError` | 40122 | Registration: a `permanentDocument` or `listElement` reference names a type whose documents can disappear. | +| `ReferencedIdentityKeyNotFoundError` | 40123 | Write: the identity has no key with that id, or the identity does not exist. | +| `ReferencedIdentityKeyDisabledError` | 40124 | Write: the key is disabled. | +| `ReferencedKeyIdPropertyInvalidError` | 40125 | Registration: `keyIdProperty` or `identityProperty` names a property that does not fit, `$creatorId` on a type that records no creator ids, or a stored key id paired with a transient identity. Write: a key id without its identity, or an identity without its key id. | +| `ReferencedDocumentPropertyAgreementInvalidError` | 40126 | Registration: a `propertyAgreement` pair names a missing, transient, object or typed array property, or two properties of different types, or `$creatorId` on a type that does not record it. | +| `ReferencedDocumentPropertyMismatchError` | 40127 | Write: a `propertyAgreement` pair does not hold. | +| `ReferencedDocumentTypeNotDeletableError` | 40131 | Registration: a `deletableDocument` reference names a type whose documents can never disappear. | +| `ReferencedContractRequirementNotMetError` | 40135 | Write: the referenced contract exists but does not meet a `contractRequirements` entry. | +| `ReferencedIdentityKeyRequirementNotMetError` | 40136 | Write: the key exists and is enabled but does not meet a `keyRequirements` entry. | +| `ReferencedDocumentLookupInvalidError` | 40137 | Registration: a `lookup` into another contract's document type cannot resolve. | +| `ReferencedDocumentListInvalidError` | 40138 | Registration: an `inList` list on another contract's document type does not qualify. | + +The registration errors name the declaration as `.`, `.[]` for typed array elements, `.$ownerId` or `.$creatorId` for the writer and creator references, and add the operand for a leaf of an expression (`resignation.memberId.anyOf[1]`). When a document is written, the referenced document type is looked up and its deletability checked again as a safeguard (40121, 40122, 40131), but a registered contract cannot fail those checks later: contracts and document types are never removed, and the deletion flags cannot change. The other codes in the range (40128 to 40130, 40132 to 40134) belong to other keywords. See [Error Codes](../error-handling/error-codes.md). + +## More forms of reference + +- [Lookups](refers-to-lookup.md): `lookup`, a `permanentDocument` or `deletableDocument` found through a unique index of its type, with the value as one part of the key. The value can be an identity (a member, an author) instead of a document id. +- [Expressions](refers-to-expressions.md): `anyOf` and `allOf`, several targets combined in one declaration. +- [List Elements](refers-to-list-element.md): `listElement` and `inList`, a value that must be one of the identifiers a list on another document holds. +- [Writer and Creator References](owner-refers-to.md): `ownerRefersTo` and `creatorRefersTo`, the same declaration applied to the document's writer or creator instead of a property. +- [References on the elements](../data-model/documents.md#references-on-the-elements) of a typed array: a declaration on `items`, checked for every element. + +## See also + +- [Document References](../data-model/documents.md#document-references-refersto) in the Documents chapter, with the internals. +- [Elected Moderation](../data-model/contract-moderation.md#elected-moderation) for the moderation charters contract, the first user of most reference forms. +- [Typed Arrays](typed-arrays.md), [System Properties](system-properties.md), [Mutability](mutability.md), [Deletion](deletion.md), [Time To Live (ttl)](ttl.md) and [Creation, Transfers and Trading](ownership-and-trading.md) for the keywords references read. +- [distinctFrom](distinct-from.md), which requires an identifier to differ from another, and [encryptedFor](encrypted-for.md), which names the key references an encrypted property was made with. +- [Contract Keywords](../contract-keywords.md) for the conventions these pages use. diff --git a/book/src/contract-keywords/required-since.md b/book/src/contract-keywords/required-since.md new file mode 100644 index 00000000000..883717d75a6 --- /dev/null +++ b/book/src/contract-keywords/required-since.md @@ -0,0 +1,61 @@ +# requiredSince + +`requiredSince` lets a contract update add a property that every new document must hold. Documents already stored were written without it and stay valid: the property is required only of documents written under the contract version the annotation names, or a later one. Reach for it when an app needs a new mandatory field and the contract is already in use. + +| | | +|---|---| +| **Where** | A top-level property listed in the document type's `required` | +| **Value** | A contract version: an integer from 1 to 4294967295 | +| **Default** | Absent: a property listed in `required` is required of every document | +| **Since** | protocol version 14 | +| **On update** | May only appear on a property the update adds, equal to the contract version the update creates (`DataContractInvalidRequiredFieldsUpdateError`, 10276). An existing annotation may not be added, changed or removed (`IncompatibleDocumentTypeSchemaError`, 10246). | +| **Errors** | `DataContractInvalidRequiredFieldsUpdateError` (10276), `InvalidContractStructure` (10231), `IncompatibleDocumentTypeSchemaError` (10246), all at registration; `JsonSchemaError` (10101) for a new document without the property | + +## Example + +Version 2 of a contract has a `post` type with one property, `text`. The update to version 3 adds a required `language`: + +```json +"post": { + "type": "object", + "properties": { + "text": { "type": "string", "maxLength": 280, "position": 0 }, + "language": { "type": "string", "minLength": 2, "maxLength": 8, "requiredSince": 3, "position": 1 } + }, + "required": ["text", "language"], + "additionalProperties": false +} +``` + +Every post created or replaced under version 3 or later must have a `language`. Posts written under versions 1 and 2 have none, and remain valid as they are. + +## How it works + +From protocol version 14, every create and replace stamps the document with the version of the contract it was written under. A transfer or a purchase keeps the stamp the document had, since it does not rewrite the document's properties. See [the contract version stamp](../serialization/document-serialization.md#the-contract-version-stamp-v3). + +- **New writes are held to the new schema.** A create must include the property. A replace sends the whole document again, so replacing a document written before the update must add the property too; the document is then stamped with the current version. Documents catch up one at a time, as they are replaced. +- **Older documents are left alone.** A document stamped below the property's `requiredSince` may lack it. It can still be read, transferred, sold and deleted. A document last written before protocol version 14 has no stamp, and counts as older than every annotation. +- **Storage follows the stamp.** A required property is stored without the presence byte an optional one carries. A property with `requiredSince` is stored as required in documents stamped at or above its version, and as optional in older ones. The latest contract alone therefore tells how to read every stored document. +- **Readers must expect gaps.** An app that reads the type should handle documents without the property: every document stamped below the annotation may lack it. +- **No index on it.** A contract update cannot add an index to an existing document type (see [Indexes](indexes.md)), so a property added this way cannot be indexed on that type. + +## Rules at registration + +- `requiredSince` sits only on a top-level property, and only on one listed in `required`. A nested property, or one that is not required, is refused (`InvalidContractStructure`, 10231). It cannot go on the elements of a typed array. +- The value is never higher than the contract's own version (10276): a property cannot be scheduled to become required later. +- On a new contract, which is version 1, the value may only be 1 (10276). + +On an update to an existing document type, which always creates the version one above the current one: + +- A property the update adds may be required only if it carries `requiredSince` equal to that new version. Without the annotation, or with any other value, the update is refused (10276). +- An existing property may not become required, with or without the annotation (10276). +- No property may leave `required` (10276). +- A property's existing `requiredSince` may not be changed or removed, and one may not be added to an existing property (`IncompatibleDocumentTypeSchemaError`, 10246). + +A document type that the update adds has no older documents. Every `requiredSince` in it must still equal the version the update creates (10276). + +## See also + +- [Evolving a Contract: Adding Required Fields](../data-model/data-contracts.md#evolving-a-contract-adding-required-fields) +- [The contract version stamp](../serialization/document-serialization.md#the-contract-version-stamp-v3), for how the stamp decides each property's layout +- [Document Shape](document-shape.md#required), for the other rules of `required` diff --git a/book/src/contract-keywords/signing-keys.md b/book/src/contract-keywords/signing-keys.md new file mode 100644 index 00000000000..7c39c1257d6 --- /dev/null +++ b/book/src/contract-keywords/signing-keys.md @@ -0,0 +1,147 @@ +# Signing and Keys + +These keywords tie a document type to identity keys. `signatureSecurityLevelRequirement` sets how strong a key must be to sign transitions on documents of the type. `requiresIdentityEncryptionBoundedKey` and `requiresIdentityDecryptionBoundedKey` let identities register encryption and decryption keys bound to the type, for applications that encrypt data between users, and say how many such keys an identity may hold. + +Every identity key has a purpose (authentication, encryption, decryption and others) and a security level. The levels are, from strongest to weakest, `MASTER` (0), `CRITICAL` (1), `HIGH` (2) and `MEDIUM` (3): a lower number is a stronger key. See [Identity Keys](../sdk/identity-keys.md#security-level). + +## `signatureSecurityLevelRequirement` + +The weakest key security level that may sign a transition on documents of the type. Raise it to `1` for documents whose forgery would be costly, so that a weaker everyday key cannot write them. + +| | | +|---|---| +| **Where** | document type | +| **Value** | `1` critical, `2` high, `3` medium | +| **Default** | `2` (high) | +| **Since** | protocol version 1 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212). Adding or removing the key without changing its value is refused too, as a schema change (`IncompatibleDocumentTypeSchemaError`, 10246). | +| **Errors** | `InvalidSignaturePublicKeySecurityLevelError` (20004) | + +### Example + +```json +"payout": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "signatureSecurityLevelRequirement": 1, + "properties": { + "recipient": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "amount": { "type": "integer", "minimum": 1, "position": 1 } + }, + "required": ["recipient", "amount"], + "additionalProperties": false +} +``` + +Only a critical key may sign a transition that creates a payout. A high or medium key of the same identity is refused. + +### How it works + +The requirement admits the level it names and every stronger level except `MASTER`: + +| Value | Keys that may sign | +|---|---| +| `1` critical | critical | +| `2` high (the default) | critical, high | +| `3` medium | critical, high, medium | + +- It applies to every document transition on the type: create, replace, delete, transfer, price update and purchase. A purchase is signed by the buyer, so the buyer needs a key at that level. +- A batch transition signs all its transitions with one key. When it holds transitions on several document types, the key must satisfy the strictest of their requirements. A batch that also holds a token transition needs a critical key. +- The key must be an authentication key. A master key never signs a document batch, whatever the requirement: the batch is refused at the signature check (20004). +- A key whose level the requirement does not admit is refused with `InvalidSignaturePublicKeySecurityLevelError` (20004) after the signature has been verified. The failure is paid: the identity's nonce for the contract is bumped and the fees are charged. + +### Rules at registration + +- The meta-schema admits only `1`, `2` and `3` (`JsonSchemaError`, 10101 otherwise). `0`, master, cannot be required. + +## `requiresIdentityEncryptionBoundedKey` + +Lets identities add encryption keys bound to this document type, and says how they are kept. Use it when documents of the type carry data encrypted to their readers, and each identity publishes the key others encrypt to. + +| | | +|---|---| +| **Where** | document type | +| **Value** | `0` unique, `1` multiple, `2` multiple with a pointer to the latest | +| **Default** | absent: no encryption key may be bound to the type | +| **Since** | protocol version 1 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212) | +| **Errors** | `DataContractBoundsNotPresentError` (10515) for an encryption key bound to a type that does not declare it; `IdentityPublicKeyAlreadyExistsForUniqueContractBoundsError` (40211) for a second key under `0` | + +## `requiresIdentityDecryptionBoundedKey` + +The same for decryption keys. + +| | | +|---|---| +| **Where** | document type | +| **Value** | `0` unique, `1` multiple, `2` multiple with a pointer to the latest | +| **Default** | absent: no decryption key may be bound to the type | +| **Since** | protocol version 1 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212) | +| **Errors** | `DataContractBoundsNotPresentError` (10515) for a decryption key bound to a type that does not declare it; `IdentityPublicKeyAlreadyExistsForUniqueContractBoundsError` (40211) for a second key under `0` | + +### Example + +Trimmed from the DashPay contract's `contactRequest`: + +```json +"contactRequest": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "requiresIdentityEncryptionBoundedKey": 2, + "requiresIdentityDecryptionBoundedKey": 2, + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "encryptedPublicKey": { + "type": "array", + "byteArray": true, + "minItems": 96, + "maxItems": 96, + "position": 1 + }, + "senderKeyIndex": { "type": "integer", "minimum": 0, "position": 2 }, + "recipientKeyIndex": { "type": "integer", "minimum": 0, "position": 3 } + }, + "required": ["toUserId", "encryptedPublicKey", "senderKeyIndex", "recipientKeyIndex"], + "additionalProperties": false +} +``` + +An identity may bind any number of encryption and decryption keys to `contactRequest`, and the platform keeps a pointer to the newest of each. The request records the ids of the sender's and the recipient's keys in `senderKeyIndex` and `recipientKeyIndex`. + +### How it works + +- An identity key may carry contract bounds: a contract, or one document type of a contract. An encryption or decryption key bound to a document type is only accepted when the type declares the matching keyword; otherwise it is refused with `DataContractBoundsNotPresentError` (10515). The check runs when an identity is created with such a key, or updated to add one. The bound contract and document type must exist (`DataContractNotPresentError`, 10400; `InvalidDocumentTypeError`, 10406). +- The value says how the identity's keys of that purpose, bound to the type, are kept: + - `0`, unique: the identity holds at most one, and it can never be replaced. A second is refused (`IdentityPublicKeyAlreadyExistsForUniqueContractBoundsError`, 40211). + - `1`, multiple: the identity may hold any number. + - `2`, multiple with a pointer to the latest: any number, and the platform keeps a pointer to the most recently added one, so a client reads the current key in one step. +- Clients read keys bound to a contract or a document type with the `getIdentitiesContractKeys` query, which takes the identities, the contract, an optional document type name and the purposes. +- Consensus reads these keywords only when keys are added. Nothing requires the writer of a document to hold such a key, and nothing checks which key encrypted a property. See [encryptedFor](encrypted-for.md) for what consensus does check about encrypted values. +- Encryption and decryption keys are `MEDIUM` keys. See [Identity Keys](../sdk/identity-keys.md#what-security-level-controls). +- Keys bound to the whole contract, rather than one document type, are governed by the contract config keys of the same names. See [Contract-Level Keys and config](contract-config.md). +- Before protocol version 12, a decryption key bound to a document type was checked against `requiresIdentityEncryptionBoundedKey` by mistake. From 12 each purpose reads its own keyword. +- From protocol version 14 an authentication key may also be bound to a contract or a document type, to limit what it may sign. That needs neither keyword. See [Contract Bounds](../sdk/identity-keys.md#contract-bounds). + +## See also + +- [Identity Keys Deep Dive](../sdk/identity-keys.md), for purposes, security levels and contract bounds +- [encryptedFor](encrypted-for.md), for declaring how an encrypted property was made +- [Creation, Transfers and Trading](ownership-and-trading.md), for the actions a key signs +- [Contract-Level Keys and config](contract-config.md), for the contract-wide key requirements diff --git a/book/src/contract-keywords/system-properties.md b/book/src/contract-keywords/system-properties.md new file mode 100644 index 00000000000..731ff368cbf --- /dev/null +++ b/book/src/contract-keywords/system-properties.md @@ -0,0 +1,142 @@ +# System Properties + +Every document carries a few values the platform manages rather than the writer: its id, its owner, and, on some types, its revision, its creator and the times it was created, updated and transferred. Their names start with `$`. A document type does not declare them in `properties`. It names them where it wants to use them: in `required`, to have a timestamp recorded, in `indices`, to query by them, and in the keywords that accept one, such as a reference's `propertyAgreement`. + +| Property | Holds | Recorded | +|---|---|---| +| [`$id`](#id) | the document's id | always | +| [`$ownerId`](#ownerid) | the identity that owns the document now | always | +| [`$revision`](#revision) | how many times the document has changed, plus one | on types whose documents can be replaced, transferred or sold | +| [`$createdAt`, `$updatedAt`, `$transferredAt`](#timestamps) | block times of the creation, last update and last transfer | when listed in `required` | +| [`$createdAtBlockHeight` and the other heights](#block-heights) | Platform and Core block heights of the same events | when listed in `required` | +| [`$creatorId`](#creatorid) | the identity that created the document | on types whose documents can be transferred or sold | + +## Example + +```json +"listing": { + "type": "object", + "documentsMutable": true, + "transferable": 1, + "tradeMode": 1, + "indices": [ + { "name": "byCreator", "properties": [{ "$creatorId": "asc" }, { "$createdAt": "asc" }] }, + { "name": "byOwner", "properties": [{ "$ownerId": "asc" }, { "$updatedAt": "asc" }] } + ], + "properties": { + "title": { "type": "string", "minLength": 1, "maxLength": 100, "position": 0 } + }, + "required": ["$createdAt", "$updatedAt", "$transferredAt", "title"], + "additionalProperties": false +} +``` + +Every listing records when it was created, last updated and last transferred, because `required` lists the three times. Listings can be replaced, transferred and sold, so each one also carries a revision and the id of its creator, and the two indexes find them by who made them and by who holds them now. + +## `$id` + +| | | +|---|---| +| **Where** | Every document | +| **Value** | An identifier: 32 bytes | +| **Recorded** | Always | +| **Since** | protocol version 1 | +| **Errors** | `InvalidDocumentTransitionIdError` (10405): a create whose id is not the one derived for it. `SystemPropertyIndexAlreadyPresentError` (10208): an index that names `$id`. | + +A document's id is derived from the contract id, the owner's id, the document type's name, entropy chosen by the client and, from protocol version 14, the identity contract nonce of the create transition. Consensus derives it again for every create and refuses a transition that carries another. The id never changes. See [Document ID Generation](../data-model/documents.md#document-id-generation). + +Documents are already stored by id, so an index may not name `$id`. A reference's `propertyAgreement` may name it on the referenced side (see [References](refers-to.md)). + +## `$ownerId` + +| | | +|---|---| +| **Where** | Every document | +| **Value** | An identifier: the id of an identity | +| **Recorded** | Always | +| **Since** | protocol version 1 | +| **Errors** | `DocumentOwnerIdMismatchError` (40102): a replace, transfer, price update or delete signed by an identity that does not own the document | + +The owner is the identity that created the document, until a transfer or a purchase hands it to another. Only the owner may replace, transfer, reprice or delete it with a document transition. A document can also leave by other paths, a moderators' deletion or an expired [`ttl`](ttl.md); see [Deletion](deletion.md). + +`$ownerId` may be indexed, and several keywords read it, where it usually stands for the writer of the document: + +- [`distinctFrom`](distinct-from.md): `"$ownerId"` makes a property differ from the owner. +- [`propertyConstraints`](property-constraints.md): a rule may compare an identifier property with `$ownerId`. +- [References](refers-to.md): a `propertyAgreement` pair, a lookup key and `identityProperty` may name it, and [`ownerRefersTo`](owner-refers-to.md) checks the owner itself. +- [`encryptedFor`](encrypted-for.md): `"recipient": "$ownerId"` marks a message the writer encrypts to themself. + +## `$revision` + +| | | +|---|---| +| **Where** | Documents of a type whose documents can be replaced (`documentsMutable`, true by default), transferred (`transferable: 1`) or sold (`tradeMode: 1`) | +| **Value** | An integer, from 1 | +| **Recorded** | On those types; absent on every other | +| **Since** | protocol version 1 | +| **Errors** | `InvalidDocumentRevisionError` (40106): a transition whose revision is not the stored one plus one | + +A new document has revision 1. Every replace, transfer, price update and purchase raises it by one, and the transition must state the new revision: the stored revision plus one. A transition built against an older copy of the document is refused, rather than silently overwriting a newer one. A type whose documents can never change after creation carries no revision. `$revision` is not one of the system properties an index may name. + +## Timestamps + +| | | +|---|---| +| **Properties** | `$createdAt`, `$updatedAt`, `$transferredAt` | +| **Value** | A block time, in milliseconds since the Unix epoch | +| **Recorded** | Only when listed in the document type's `required` | +| **Since** | protocol version 1 | +| **On update** | The set is fixed: a contract update may not add one to `required` or remove one (`DataContractInvalidRequiredFieldsUpdateError`, 10276) | + +The platform sets these from the block that processes the transition; the writer never supplies them. A timestamp that is not in `required` is never recorded, and documents of the type do not have it. + +| Event | Sets | +|---|---| +| Create | every listed timestamp, so `$updatedAt` and `$transferredAt` start at the creation time | +| Replace | `$updatedAt` | +| Price update | `$updatedAt` | +| Transfer | `$transferredAt` | +| Purchase | `$transferredAt` | + +Timestamps may be indexed. Some keywords need one in `required`, since they read it: + +- [`ttl`](ttl.md) counts from `$createdAt`. +- `canBeDeletedByModeratorsFor` counts from `$updatedAt`, or from `$createdAt` on a type whose documents cannot be replaced (see [Deletion](deletion.md)). +- A [time-range index](time-range.md) needs the timestamp it buckets. + +## Block heights + +| | | +|---|---| +| **Properties** | `$createdAtBlockHeight`, `$updatedAtBlockHeight`, `$transferredAtBlockHeight`, `$createdAtCoreBlockHeight`, `$updatedAtCoreBlockHeight`, `$transferredAtCoreBlockHeight` | +| **Value** | The `BlockHeight` forms: the Platform block height. The `CoreBlockHeight` forms: the Core chain height recorded with that block. | +| **Recorded** | Only when listed in the document type's `required` | +| **Since** | protocol version 1 | +| **On update** | The set is fixed, as for the timestamps (10276) | + +The same three events as the timestamps, measured in blocks instead of time. Each is set on the same events as the timestamp of its name, and may be indexed. + +## `$creatorId` + +| | | +|---|---| +| **Where** | Documents of a type that sets `transferable: 1` or `tradeMode: 1`, in a contract of format 1 whose config is version 1 or later | +| **Value** | An identifier: the id of the identity that created the document | +| **Recorded** | On those types, from protocol version 10 | +| **Since** | protocol version 10 | +| **Errors** | `UndefinedIndexPropertyError` (10209): an index naming `$creatorId` on a type that does not record it | + +On a type whose documents can change hands, `$ownerId` follows the document while `$creatorId` stays with the identity that created it: a transfer or a purchase never changes it. On a type whose documents never change hands the creator is always the owner, and no `$creatorId` is recorded. Neither is it on a contract of format 0 or with a config of version 0, whatever its types allow. + +`$creatorId` is not written in `required` or `properties`. It may be named: + +- in an index; +- on the referenced side of a reference's `propertyAgreement`, and as a key reference's `identityProperty`; +- by [`creatorRefersTo`](owner-refers-to.md), which checks the creator. A type that does not record creators may not declare it (`InvalidContractStructure`, 10231). + +## See also + +- [What Lives Inside a Document](../data-model/documents.md#what-lives-inside-a-document), for the fields of a document +- [Document Shape](document-shape.md#required), for the `required` list that records timestamps +- [Document Serialization](../serialization/document-serialization.md#high-level-structure), for where each system property sits in the stored bytes +- [Creation, Transfers and Trading](ownership-and-trading.md), for the flags that decide `$revision` and `$creatorId` diff --git a/book/src/contract-keywords/time-range.md b/book/src/contract-keywords/time-range.md new file mode 100644 index 00000000000..4f7accf3a77 --- /dev/null +++ b/book/src/contract-keywords/time-range.md @@ -0,0 +1,96 @@ +# Time-Range Indexes + +`timeRange` groups an index's documents into time windows by one of their timestamps, so that "the most used hashtags this hour" or "posts per day" is a question about one window rather than a scan over time. Each window is `range` seconds long and a new one starts every `step` seconds; when windows overlap, a document belongs to each window that contains its timestamp. Combined with the count and ranking keywords, a time-range index serves trending lists and leaderboards per window, with proofs. It costs one set of index entries per window a document falls in, and a `ttl` lets old windows expire so that this data does not stay in state forever. + +| | | +|---|---| +| **Where** | index; buckets the index's first property | +| **Value** | object: `on`, `range`, `step` (required), `phase`, `ttl` | +| **Default** | absent: the timestamp is indexed as it is | +| **Since** | protocol version 14 (`ttl` included) | +| **On update** | Fixed, like every index (`DataContractInvalidIndexDefinitionUpdateError`, 10217) | +| **Errors** | `DuplicateUniqueIndexError` (40105) on a unique time-range index | + +## Example + +```json +"post": { + "type": "object", + "indices": [ + { + "name": "hourlyByHashtag", + "properties": [{ "$createdAt": "asc" }, { "hashtag": "asc" }], + "timeRange": { "on": "$createdAt", "range": 3600, "step": 900, "ttl": 86400 }, + "countable": "countable", + "rangeCountable": true + }, + { + "name": "onePerDay", + "properties": [{ "$createdAt": "asc" }, { "$ownerId": "asc" }], + "unique": true, + "timeRange": { "on": "$createdAt", "range": 86400, "step": 86400 } + } + ], + "properties": { + "hashtag": { "type": "string", "minLength": 1, "maxLength": 63, "position": 0 }, + "text": { "type": "string", "maxLength": 280, "position": 1 } + }, + "required": ["$createdAt", "hashtag", "text"], + "additionalProperties": false +} +``` + +`hourlyByHashtag` keeps one-hour windows starting every 15 minutes, so each post is counted in four windows, and the counts per hashtag in the window that covers the last hour are one query. Its entries expire a day after their window starts. `onePerDay` has non-overlapping daily windows and is unique, so an identity may post once per day: a second post in the same day is refused with `DuplicateUniqueIndexError` (40105). + +## The keys + +| Key | Value | What it does | +|---|---|---| +| `on` | `"$createdAt"`, `"$updatedAt"` or `"$transferredAt"`, required | The timestamp to bucket. It must be the index's first property and be listed in the type's `required`, since a timestamp that is not required is never recorded. | +| `range` | integer seconds, at least 1, required | The length of each window. An exact multiple of `step`. | +| `step` | integer seconds, at least 1, required | The time between the starts of two windows. | +| `phase` | integer seconds, default `0` | Moves the window boundaries: windows start at `phase + k * step` from the Unix epoch. Less than `step` and less than one year (31536000). Daily windows cut at 06:00 UTC take `"phase": 21600`. | +| `ttl` | integer seconds, at least 1 | How long the index's entries live after their window starts. At least `range` and at most 604800 (one week) at protocol version 14. Absent, entries live forever. | + +## How it works + +**Buckets.** For each document, the index stores the start of every window that contains the document's timestamp, in milliseconds, in place of the timestamp itself. With `range` equal to `step` the windows do not overlap and a document is in exactly one; otherwise it is in `range / step` of them, the index's **overlap factor**, and costs that many sets of entries. The overlap factor is at most 24 at protocol version 14. Windows are declared in seconds because block time, which the timestamps come from, moves in steps of about five seconds. + +A source that changes, `$updatedAt` or `$transferredAt`, moves the document into the current windows each time it changes. Several indexes may bucket the same timestamp with different grids; each grid is stored apart from the others. + +**Queries.** A query selects one window of the index with an `IN_TIME_RANGE` clause on the bucketed timestamp: + +- `newest`: the window that started most recently, which covers the latest slice of time (up to one `step`). +- `oldest`: the oldest window still open, which covers nearly a full `range` of history: the one for "trending over the last hour". +- `byStart`: the window starting at a given millisecond time on the grid, which reaches past windows. + +`newest` and `oldest` are resolved against block time on the server and against the signed time of the response when verified. When the timestamp is bucketed by several grids, the query names the grid. A query may select at most one window. A window with no documents, including one that has not started, is a proved empty answer. Counts, sums and rankings declared on the index are then per window: a ranking below the bucketed timestamp is one leaderboard per window. + +**Uniqueness.** On a unique time-range index, two documents conflict when they fall in the same window and agree on the index's other properties. That is only meaningful when each document is in one window and cannot move, so uniqueness needs non-overlapping windows over `$createdAt`. + +**Expiry (`ttl`).** An entry can be queried until `ttl` seconds after its window's start; a query for an expired window is refused, so every window a query can reach is complete. The expired entries are removed lazily: each later write into the index removes some of the oldest expired entries, within a bounded budget, at no charge to the writer. An index that stops receiving writes keeps its expired entries. + +Everything written under an index with a `ttl` is billed as processing, at an ephemeral-bytes rate, instead of as storage, and nothing is refunded when it is removed. The rate prices a lifetime of at most a week, which is why `ttl` is capped there. + +A `ttl` removes entries from this index only. The documents stay, and so do their entries in the type's other indexes. To delete the documents themselves after a time, use the document type's [`ttl`](ttl.md). + +## Rules at registration + +- `on` names `$createdAt`, `$updatedAt` or `$transferredAt`, which is the index's first property and is listed in `required`. A user property cannot be bucketed. +- `range` and `step` are at least 1, `range` is an exact multiple of `step`, and `range / step` is at most 24. +- `phase` is less than `step` and less than 31536000. +- `ttl`, when present, is at least `range` and at most 604800. Two indexes that bucket the same timestamp with the same `range`, `step` and `phase` share their storage and must declare the same `ttl`, or none. +- A unique time-range index has `range` equal to `step` and `on` equal to `$createdAt`. +- The index is not contested, does not set `nullSearchable: false`, and is not `preallocated`. +- A ranking sits below the bucketed timestamp: a single-property time-range index cannot be ranked, and `rankedCountable.at` cannot name the timestamp. See [Ranked Indexes](ranked.md). +- A `refersTo` lookup cannot resolve through a time-range index. See [Lookups](refers-to-lookup.md). +- On an [index-only type](index-only.md), only `$createdAt` can be bucketed, and a bucketed index cannot serve as the type's proof index. + +A broken rule is refused as `InvalidContractStructure` (10231), or by the meta-schema as `JsonSchemaError` (10101). Before protocol version 14 the keyword is unknown and refused. + +## See also + +- [Time-Range Index TTL](../drive/time-range-ttl.md) for how expired windows are drained, the fee rate and the query gate. +- [Counts, Sums and Averages](aggregates.md) and [Ranked Indexes](ranked.md) for totals and leaderboards per window. +- [Time To Live (ttl)](ttl.md) for expiring whole documents. +- [Indexes](indexes.md) for the index keywords every index uses. diff --git a/book/src/contract-keywords/token-cost.md b/book/src/contract-keywords/token-cost.md new file mode 100644 index 00000000000..d71dcdc19d7 --- /dev/null +++ b/book/src/contract-keywords/token-cost.md @@ -0,0 +1,126 @@ +# Token Costs (tokenCost) + +`tokenCost` makes an action on a document cost tokens: creating a card costs 10 gems, deleting one costs 1. The tokens are taken from whoever signs the transition, and either go to the contract owner or are burned. Reach for it when an app has its own token and wants documents to be paid for with it, or wants to hand out tokens that let users act for free, with the contract owner paying the gas. + +| | | +|---|---| +| **Where** | Document type | +| **Value** | An object keyed by action: `create`, `replace`, `delete`, `transfer`, `update_price`, `purchase`. Each value is a cost object with the keys below. Actions left out cost no tokens | +| **Default** | Absent: no action costs tokens | +| **Since** | protocol version 9. `gasFeesPaidBy` is accepted from 9 and acted on from 14; `optional` is 14 | +| **On update** | Fixed: a cost may not be added, changed or removed on an existing document type (`DocumentTypeUpdateError`, 40212) | +| **Errors** | On a document transition: `RequiredTokenPaymentInfoNotSetError` (40115), `IdentityHasNotAgreedToPayRequiredTokenAmountError` (40116), `IdentityTryingToPayWithWrongTokenError` (40117), `IdentityTokenAccountFrozenError` (40702), `IdentityDoesNotHaveEnoughTokenBalanceError` (40700), `GasFeesPaidByNotAllowedError` (40129), `InconsistentGasFeesPaidByInBatchError` (40130), `GasSponsorInsufficientBalanceError` (40222). At registration: `InvalidTokenPositionError` (10451), `RedundantDocumentPaidForByTokenWithContractId` (10275), `TokenPaymentByBurningOnlyAllowedOnInternalTokenError` (10261), `DataContractNotFoundError` (40008), `InvalidTokenPositionStateError` (40009) | + +The keys of each cost object: + +| Key | Value | Default | Meaning | +|---|---|---|---| +| `tokenPosition` | integer, 0 to 65535 | required | Which token is charged: its position in this contract's `tokens`, or in the contract `contractId` names | +| `amount` | integer, 1 to 281474976710655 | required | How many tokens the action costs | +| `contractId` | identifier (32 bytes) | this contract | The contract whose token is charged, when it is not this one | +| `effect` | `0` transfer to the contract owner, `1` burn | `0` | What happens to the tokens paid | +| `gasFeesPaidBy` | `0` document owner, `1` contract owner, `2` prefer contract owner | `0` | Who the contract owner offers to have pay the gas of this action (acted on from protocol version 14) | +| `optional` | boolean | `false` | `true` lets a transition skip the token and pay the gas in credits instead (protocol version 14) | + +## Example + +```json +"card": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "transferable": 1, + "tradeMode": 1, + "tokenCost": { + "create": { "tokenPosition": 0, "amount": 10, "gasFeesPaidBy": 2, "optional": true }, + "replace": { "tokenPosition": 1, "amount": 1 }, + "delete": { "tokenPosition": 1, "amount": 1, "effect": 1 } + }, + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 63, "position": 0 }, + "attack": { "type": "integer", "minimum": 0, "maximum": 100, "position": 1 } + }, + "required": ["name", "attack"], + "additionalProperties": false +} +``` + +The contract has two tokens. Creating a card costs 10 of token 0, which go to the contract owner. A player who pays with the token and asks for it has the gas paid by the contract owner, when the owner's balance covers it; a player may also skip the token and pay the gas in credits. Replacing a card costs 1 of token 1, also to the owner. Deleting one burns 1 of token 1. Transfers, price updates and purchases cost no tokens. + +## Paying: `$tokenPaymentInfo` + +A document transition on an action with a token cost carries a `$tokenPaymentInfo` in its base, saying which token the signer agrees to pay with, how much at most, and who they ask to pay the gas: + +```json +"$tokenPaymentInfo": { + "$formatVersion": "0", + "tokenContractPosition": 0, + "maximumTokenCost": 10, + "gasFeesPaidBy": "PreferContractOwner" +} +``` + +| Field | Meaning | +|---|---| +| `paymentTokenContractId` | The contract of the token paid with. Leave it out for a token of the document's own contract: it must match the cost's `contractId` exactly, and a cost on the contract's own token has none | +| `tokenContractPosition` | The token's position in that contract | +| `minimumTokenCost`, `maximumTokenCost` | Optional bounds on the amount the signer agrees to pay; a cost outside them refuses the transition | +| `gasFeesPaidBy` | `"DocumentOwner"`, `"ContractOwner"` or `"PreferContractOwner"`: who the signer asks to pay the gas (see [Who pays the gas](#who-pays-the-gas-gasfeespaidby)) | + +When the transition is processed: + +1. A required cost with no `$tokenPaymentInfo` is refused (`RequiredTokenPaymentInfoNotSetError`, 40115). +2. A payment info naming another token than the cost is refused (`IdentityTryingToPayWithWrongTokenError`, 40117). +3. A cost outside the signer's `minimumTokenCost` and `maximumTokenCost` is refused (`IdentityHasNotAgreedToPayRequiredTokenAmountError`, 40116). +4. The signer's gas request must be one the cost offers, and the whole batch must name one payer (40129, 40130). +5. Against state: a signer whose account for the token is frozen is refused (`IdentityTokenAccountFrozenError`, 40702), and so is one whose balance is below `amount` (`IdentityDoesNotHaveEnoughTokenBalanceError`, 40700). + +The signer pays: the creator for a create, the owner for a replace, delete, transfer or price update, and the buyer for a purchase. + +## `effect`: transfer or burn + +- `0`, the default, moves the tokens from the signer to the owner of the contract that holds the document type. When the contract owner performs the action themselves nothing moves, though their balance is still checked. +- `1` burns the tokens from the signer's balance, lowering the token's supply. Only a token of the contract's own can be burned (`TokenPaymentByBurningOnlyAllowedOnInternalTokenError`, 10261, at registration). + +## Tokens of another contract: `contractId` + +A document type may charge a token another contract defines, for example a shared currency. `contractId` names that contract and `tokenPosition` the token in it. The effect must then be `0`: the tokens go to the owner of the contract holding the document type, not to the token's issuer. At registration the named contract must exist (`DataContractNotFoundError`, 40008) and have a token at that position (`InvalidTokenPositionStateError`, 40009), and it must not be the contract itself: leave `contractId` out for the contract's own tokens (`RedundantDocumentPaidForByTokenWithContractId`, 10275). + +## Who pays the gas: `gasFeesPaidBy` + +From protocol version 14 the contract owner can pay the gas (the storage and processing fees) of an action paid with a token. The cost states what the contract owner offers; the transition's `$tokenPaymentInfo.gasFeesPaidBy` states what the signer asks for. The two resolve like this: + +| Cost offers / signer asks | `DocumentOwner` | `PreferContractOwner` | `ContractOwner` | +|---|---|---|---| +| `0` document owner | signer pays | signer pays | refused (40129) | +| `2` prefer contract owner | signer pays | contract owner, if their balance covers it | refused (40129) | +| `1` contract owner | signer pays | contract owner, if their balance covers it | contract owner | + +- A signer can always pay for themself, can always state a preference, and can insist on the contract owner only where the cost offers `1`. A request the cost does not cover is refused (`GasFeesPaidByNotAllowedError`, 40129). An action without a token cost offers `0`, and a transition without `$tokenPaymentInfo` asks for `DocumentOwner`. +- A batch has one payer. Transitions of one batch that resolve to different payers are refused (`InconsistentGasFeesPaidByInBatchError`, 40130); a token transition in the batch is never sponsored, so it counts as the signer paying. +- When the contract owner's balance does not cover the gas (and any action fees they would owe), a batch that insisted is refused and charged nothing (`GasSponsorInsufficientBalanceError`, 40222), and a batch that only preferred falls back to the signer. +- A transition that fails validation is never sponsored: its signer pays for the work that ran. +- Storage refunds still go to the document's owner, whoever paid for the storage. Each token the contract owner hands out is therefore worth up to the storage fee of the largest document the type allows, so a type that offers to pay should bound its documents' size (`maxLength`, `maxItems`, [maxBytes](max-bytes.md)) and price the action to match. +- A sponsor also pays any [action fee](action-fees.md) the action charges. + +Before protocol version 14 the key was accepted and stored but not acted on: the signer always paid. + +## Optional costs + +With `optional: true` a transition may leave `$tokenPaymentInfo` out. It then pays no token, its signer pays the gas in credits as on an action without a token cost, and no sponsorship applies. With `$tokenPaymentInfo` present the token is charged exactly as for a required cost, sponsorship included, and an insufficient token balance is a rejection, never a fallback to credits: the client chooses between token and credits before signing. + +Together with `gasFeesPaidBy` this gives a "free usage" pattern: an app hands out tokens, users act for free while their tokens last, and keep going on credits after. + +## Rules at registration + +- The meta-schema checks the shape (`JsonSchemaError`, 10101): only the six action keys; `tokenPosition` and `amount` required in each cost; values in the ranges above; no other key. Before protocol version 14 it also refuses `optional`. +- Without `contractId`, `tokenPosition` must be a token of this contract (`InvalidTokenPositionError`, 10451). +- With `contractId`: not this contract's own id (10275), no burn (10261), and a contract that exists with a token at that position (40008, 40009). + +## See also + +- [Gas paid by the contract owner](../fees/overview.md#gas-paid-by-the-contract-owner) and [Optional token costs](../fees/overview.md#optional-token-costs), the deep dive +- [Action Fees (actionFees)](action-fees.md), fixed credit fees on the same six actions +- [Creation, Transfers and Trading](ownership-and-trading.md), for the actions a type allows +- [Contract-Level Keys and config](contract-config.md), for the contract's `tokens` +- [Contract Keywords overview](../contract-keywords.md), for the conventions of these tables diff --git a/book/src/contract-keywords/transient.md b/book/src/contract-keywords/transient.md new file mode 100644 index 00000000000..ec5a439fc0b --- /dev/null +++ b/book/src/contract-keywords/transient.md @@ -0,0 +1,71 @@ +# transient + +`transient` lists properties that a transition must carry but a stored document never holds. The value is validated with the rest of the document, can be read by the checks that run on the transition, and is then dropped. Reach for it when a write has to prove something with a value that should not be kept, such as a secret salt. + +| | | +|---|---| +| **Where** | The top of a document type | +| **Value** | An array of names of top-level properties | +| **Default** | Absent: every property is stored | +| **Since** | protocol version 1. A replace drops the values from protocol version 14. | +| **On update** | Fixed (`IncompatibleDocumentTypeSchemaError`, 10246). The list is compared as a set, so reordering or repeating a name is no change. | +| **Errors** | `InvalidContractStructure` (10231) at registration | + +## Example + +The DPNS `domain` type, trimmed to two properties: + +```json +"domain": { + "type": "object", + "documentsMutable": false, + "properties": { + "label": { "type": "string", "minLength": 3, "maxLength": 63, "position": 0 }, + "preorderSalt": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "description": "Salt used in the preorder document", + "position": 1 + } + }, + "required": ["label", "preorderSalt"], + "transient": ["preorderSalt"], + "additionalProperties": false +} +``` + +Registering a name takes two steps. A `preorder` document first commits to a hash of the salt and the name, and the `domain` document then reveals both. Every domain create must carry its 32-byte `preorderSalt`, and DPNS checks it against the preorder. Once the domain is created the salt has done its job, and the stored domain does not hold it. + +## How it works + +- **Checked like any other value.** A transient value is on the transition when the document is validated, so the schema holds it to its rules: a required transient property must be present, and its bounds apply. `maxBytes` and `distinctFrom` apply to it too, and so do checks such as the DPNS one above. +- **Dropped before storing.** After validation, a create drops the transient values before the document is written. From protocol version 14 a replace drops them the same way. Before 14 a replace stored the values it carried, so a replaced document could hold values its create had dropped. +- **Dropped by top-level name.** Only top-level properties can be transient. When a transient property is an object, the whole object is dropped with everything in it. +- **Never stored, never found.** No stored document holds a transient value, so a query cannot find one and a later reader cannot see it. +- **Sent again on replace.** A replace is validated as a whole document, so a required transient property must be carried by every replace, not only the create. +- **Storage.** A transient property is stored as optional, with a presence byte, even when it is required: in a stored document it is always absent. + +## Rules at registration + +From protocol version 14, a document type is refused (`InvalidContractStructure`, 10231) when: + +- an entry does not name a top-level property of the type. A nested path, a system property or an undeclared name is refused; to drop a nested value, list the object around it; +- an index reads a transient property, or a property inside a transient object. Every stored document would lack the value, so the index could find nothing and a unique index would enforce nothing; +- a reference reads one where the value would have to be stored: a `refersTo` lookup may not read one on either side, a `propertyAgreement` may not name one on its referenced side, a key reference may not store its key id with a transient identity, and a `listElement` reference may not find its list's document through one. The referring side of a `propertyAgreement` may be transient: it is checked on the transition; +- `encryptedFor` names one as its recipient or key id; +- `immutable` lists one. A transient property is always absent from the stored document, so every replace that carries it would count as changing it; +- a `propertyConstraints` rule reads one; +- the type is `indexOnly` and declares any transient property. + +Before protocol version 14 none of these was checked. + +On a contract update the list is fixed. It decides which values stored documents hold and how every property is encoded, so documents written under one list could not be read under another. + +## See also + +- [Transient Properties](../data-model/documents.md#transient-properties) in the Documents chapter +- [Document Serialization](../serialization/document-serialization.md#user-defined-properties), for the presence byte a transient property always takes +- [Document Shape](document-shape.md#required), for `required` +- [References (refersTo)](refers-to.md), [encryptedFor](encrypted-for.md), [Mutability](mutability.md), [propertyConstraints](property-constraints.md) and [Index-Only Types](index-only.md), whose rules refuse transient properties diff --git a/book/src/contract-keywords/ttl.md b/book/src/contract-keywords/ttl.md new file mode 100644 index 00000000000..a5b8aed97c8 --- /dev/null +++ b/book/src/contract-keywords/ttl.md @@ -0,0 +1,64 @@ +# Time To Live (ttl) + +`ttl` gives every document of a type a lifetime. The platform deletes each document once that many seconds have passed since it was created, whoever owns it and whatever the type says about who else may delete it. Use it for content that is meant to disappear: stories, invitations, offers, session records. Such documents are also cheaper: they pay for the time they occupy the state rather than for storage forever. + +This is the document type keyword. An index can carry a `ttl` of its own inside `timeRange`, which expires index entries and leaves the documents in place; see [Time-Range Indexes](time-range.md). + +| | | +|---|---| +| **Where** | document type | +| **Value** | integer, seconds, 3600 (one hour) to 31536000 (one year) | +| **Default** | absent: documents live until someone deletes them | +| **Since** | protocol version 14 | +| **On update** | Fixed (`DocumentTypeUpdateError`, 40212): an update may not add, remove or change it | +| **Errors** | `DocumentExpiredError` (40140) for a replace, transfer, purchase or price update of a document past its expiry | + +## Example + +```json +"story": { + "type": "object", + "ttl": 86400, + "canBeDeleted": true, + "properties": { + "caption": { "type": "string", "maxLength": 200, "position": 0 }, + "mediaUrl": { "type": "string", "maxLength": 500, "position": 1 } + }, + "required": ["$createdAt", "mediaUrl"], + "additionalProperties": false +} +``` + +Each story is deleted one day (86,400 seconds) after its creation. Its author may delete it sooner. `$createdAt` must be in `required`, since the expiry is counted from it. + +## How it works + +- **When a document expires.** At its `$createdAt` plus `ttl` seconds. `$createdAt` is the block time of the create, so nothing the writer sends moves the expiry, and documents created in the same block expire together. A replace, transfer, purchase or price update never moves it either: a buyer of an expiring document buys what is left of its life. +- **When it is deleted.** After each block's state transitions, the platform deletes expired documents, oldest first: at most 128 per block, and at most 1,024 in weight, where a document weighs 1 plus the index levels of its type (the values at protocol version 14). The rest wait for the next block. The deletion is an ordinary one: the document and every index entry go, and counts and sums are brought down. +- **Between expiry and deletion.** A document past its expiry that the cleanup has not reached yet can still be queried and referenced, and it still holds its values in the type's unique indexes, so a create with the same unique value is refused as a duplicate until the cleanup has run. It can no longer be replaced, transferred, bought or repriced, and a moderator can no longer restore it: each is refused, paid, with `DocumentExpiredError` (40140). Its owner may still delete it where `canBeDeleted` allows. +- **Earlier deletion.** The owner may delete a document before it expires when `canBeDeleted` allows it, and the contract's moderators when `canBeDeletedByModerators` does. `canBeDeleted: false` only stops the owner; the platform still deletes the document when it expires. +- **What it costs.** The document is stored without storage flags and refunds nothing when it is deleted, by anyone. Instead of the price of permanent storage, each byte it writes pays a price for the time it will live: five tiers up to seven days, then a price per 9.125 days spanned. Creating it also prepays, as processing, the cost of its later deletion. A replace, transfer, purchase or price update pays for the bytes it adds at the price of the lifetime left. See [Fees](../data-model/document-ttl.md#fees). +- **Proofs near the expiry.** A write accepted in the last block before a document expires proves the document present. A proof fetched after the next block's cleanup finds it gone. The one-hour minimum keeps a newly created document in the state well past the moment its writer fetches the proof of the create. + +## Rules at registration + +The refusals below are `InvalidContractStructure` (10231) unless a bullet says otherwise. They hold on every parse of the contract, except the bounds, which are checked when a contract is registered or updated. + +- `$createdAt` must be in `required`. +- Refused together with `documentsKeepHistory: true` (Drive never deletes a document whose type keeps history), with `indexOnly: true` (there is no stored row to delete by id), and on a type with a contested index (a contested document waits in its vote poll, and could expire before it is stored). +- At least `min_document_ttl_seconds` and at most `max_document_ttl_seconds` of `SystemLimits`: 3600 and 31536000 at protocol version 14. The meta-schema itself admits 1 to 4294967295, so a `ttl` of 0 is a `JsonSchemaError` (10101) and one outside the narrower bounds is 10231. +- For references, a type with a `ttl` is deletable. A `permanentDocument` reference and a `listElement` reference may not point at it, a lookup included (`ReferencedDocumentTypeDeletableError`, 40122); a `deletableDocument` reference may. See [References](refers-to.md). + +Everything else combines with a `ttl`: mutable types, `transferable`, `tradeMode`, `canBeDeletedByModerators`, `creationRestrictionMode`, count, sum and ranked indexes, `timeRange` indexes with or without their own `ttl`, references declared on the type, action fees and token costs. + +### On update + +An update may not add, remove or change the `ttl` of an existing document type. Every stored document has the expiry it was written and paid with: adding one would leave stored documents that the cleanup cannot find, removing it would leave entries that delete documents the type says live forever, and changing it would move expiries away from what was paid for. A document type added by an update may declare a `ttl` freely. + +## See also + +- [Document Time To Live](../data-model/document-ttl.md), for the expirations tree, the fee tiers, the payout to epochs and the cleanup +- [Deletion](deletion.md), for the other two ways a document is deleted +- [Time-Range Indexes](time-range.md), for the index `ttl` +- [History](history.md), for why a type that keeps history cannot expire +- [System Properties](system-properties.md), for `$createdAt` diff --git a/book/src/contract-keywords/typed-arrays.md b/book/src/contract-keywords/typed-arrays.md new file mode 100644 index 00000000000..fba443417ce --- /dev/null +++ b/book/src/contract-keywords/typed-arrays.md @@ -0,0 +1,107 @@ +# Typed Arrays + +A typed array is a list property: a document holds any number of values, up to a limit, all of one simple type. A post's tags, the scores of a match or the members of a team are typed arrays. The `items` keyword makes an array a typed array and gives the schema every element follows. Before protocol version 14 an array property had to be a byte array. + +| | | +|---|---| +| **Where** | A property of type `array`, at the top of a document type or inside an object, in place of `byteArray: true` | +| **Value** | The schema of one element: an integer, a number, a string, a boolean, a byte array or an identifier | +| **Since** | protocol version 14 | +| **On update** | `items` may be neither added nor removed (`IncompatibleDocumentTypeSchemaError`, 10246). The keywords inside it follow their own update rules, and a change to how an element is stored is refused (`DocumentTypeUpdateError`, 40212). | +| **Errors** | `JsonSchemaError` (10101); on elements, `DocumentPropertyMaxBytesExceededError` (10421), `DocumentPropertyNotDistinctError` (10419) and the reference errors of [refersTo](refers-to.md) | + +## Example + +```json +"tags": { + "type": "array", + "minItems": 0, + "maxItems": 10, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 32, "maxBytes": 64 }, + "position": 3 +} +``` + +A post holds up to 10 tags, none repeated, each 1 to 32 characters and at most 64 bytes. + +The elements may also be identifiers that point at other documents. The moderation charters contract lists the reasons a moderation team may act on like this: + +```json +"reasons": { + "type": "array", + "minItems": 0, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "permanentDocument", "documentType": "reason" } + }, + "position": 2 +} +``` + +A charter names up to 64 distinct `reason` documents, and each one must exist when the charter is written. + +## How it works + +**On the array**, `minItems` and `maxItems` count elements, and `uniqueItems: true` refuses a document that repeats one. A list that is too long or too short, repeats an element, or holds an element of the wrong type is refused with `JsonSchemaError` (10101). + +**On the elements**, the `items` schema is checked for every element, as a property of that schema would be: + +- the JSON Schema keywords: an element's `enum`, bounds, length and `pattern` (`JsonSchemaError`, 10101); +- [`maxBytes`](max-bytes.md) on string elements (`DocumentPropertyMaxBytesExceededError`, 10421), the error naming the element, such as `tags[2]`; +- [`distinctFrom`](distinct-from.md) on identifier elements: every element must differ from the named property (`DocumentPropertyNotDistinctError`, 10419); +- [`refersTo`](refers-to.md) on identifier elements: each element is checked as a single reference would be, in list order, and the first one that fails refuses the write with that reference's error, naming the element (`reasons[2]` for the third). An empty or absent list checks nothing. + +A replace re-checks the references of the elements the stored list did not hold, and every element when the reference's rules call for it. See [References on the Elements](../data-model/documents.md#references-on-the-elements) for the details. + +**Storage.** A typed array is stored inline in the document: a count of its elements, then each element encoded as a required property of the element's type would be. An identifier element takes 32 bytes, an integer element the width its bounds give it (see [Numbers](property-schemas.md#numbers)), a fixed-size byte array element its bytes, and a string element a length prefix and its bytes. Elements never carry a presence byte. The `reasons` list above is therefore one count byte and 32 bytes per reason. The 5120-byte limit on a single value applies to each element, not to the list. + +**When a document is read from JSON**, identifier and byte array elements are converted from their string form element by element, as a single identifier or byte array is. + +## Rules at registration + +The array: + +- declares `items` and `maxItems`, and not `byteArray`; +- has a `maxItems` of at most 1024, and a `minItems` no higher than its `maxItems`; +- carries no `contentMediaType`, `refersTo`, `distinctFrom` or `maxBytes` of its own: these go on the `items`. + +The element schema (`items`): + +- is written inline: a `$ref` is refused; +- has a `type` of `integer`, `number`, `string`, `boolean` or `array`, and an `array` element must be a byte array (`byteArray: true`). Objects and lists of lists are refused; +- takes these keywords: `type`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`, `minLength`, `maxLength`, `pattern`, `format`, `minItems`, `maxItems` (bytes, on a byte array element), `enum`, `byteArray`, `contentMediaType`, `maxBytes`, `distinctFrom`, `refersTo`, `$comment` and `description`. Anything else, `position`, `const`, `uniqueItems` and `examples` included, is refused. A one-value `enum` does what `const` would, and an update can still widen it; +- may carry an `enum` only on a string, integer, number or boolean element, and every member must be a value of the element's type; +- on an integer element, has an integer `minimum` and `maximum`, the minimum no higher than the maximum; +- may carry `refersTo` only on an identifier element, and not with the `identityPublicKey` target: its key id is a single sibling property, which cannot pair with many elements. + +Where a typed array cannot be used: + +- in an index (`InvalidIndexPropertyTypeError`, 10206), or as an index-only type's terminal or entry payload: nothing is written per element; +- on either side of a `propertyAgreement` in a reference; +- as an operand of a [propertyConstraints](property-constraints.md) rule, other than in a `present` or `absent` test; +- with [`encryptedFor`](encrypted-for.md), which only a byte array takes. + +References count against the limit of 256 per document at `maxItems` each: a list of up to 64 references counts as 64. An `immutable` property may not hold a list of `deletableDocument` references (see [Mutability](mutability.md)). + +A meta-schema violation is refused with `JsonSchemaError` (10101) and the other rules with `InvalidContractStructure` (10231). + +## On update + +- `items` may not be added or removed, so a byte array cannot become a typed array or the reverse (10246). +- Inside `items`, each keyword follows its own update rule (see [Property Schemas](property-schemas.md)): for example `maxLength` and `maxBytes` may be raised, and `enum` may gain values. `refersTo` and `distinctFrom` are fixed. +- A change to how an element is stored is refused (`DocumentTypeUpdateError`, 40212): an integer element whose width or sign would change (by its bounds or its `enum`), or a byte array element that would switch between fixed and variable size or change its fixed size. +- On the array, `maxItems` may be raised, up to 1024 and within the reference limit, and `minItems` lowered. `uniqueItems` may be removed, not added. + +## See also + +- [Typed Arrays](../data-model/documents.md#typed-arrays) and [References on the Elements](../data-model/documents.md#references-on-the-elements) in the Documents chapter +- [Property Schemas](property-schemas.md), for the keywords an element takes +- [References (refersTo)](refers-to.md), [distinctFrom](distinct-from.md) and [maxBytes](max-bytes.md), which apply to every element +- [Document Serialization](../serialization/document-serialization.md#value-encoding-by-type), for the stored form diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 87e77389fa2..f4b57bd5971 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -108,7 +108,7 @@ Deletions (`Delete` and `IndexOnlyDelete`) are never refused: a barred identity ### The Errors -Basic, in their own band (10900-10949): `InvalidContractModerationConfigError` (10900), `ContractModerationSelfTargetError` (10901), `ContractModerationReasonTooLongError` (10903; 10902 is reserved), `InvalidContractModerationReasonDocumentsError` (10904). State, in their own sub-band: `ContractModerationNotEnabledError` (41100), `IdentityNotContractModeratorError` (41101), `ContractModerationTargetNotAllowedError` (41102), `ContractUserAlreadyBannedError` (41103), `ContractUserNotBannedError` (41104), `ContractUserNotSuspendedError` (41105), `ContractSuspensionNotInFutureError` (41106), `ContractUserBannedError` (41107), `ContractUserSuspendedError` (41108), `ContractModerationTargetNotFoundError` (41109), `ContractModeratorIdentityNotFoundError` (41110, from the contract create and update, not from the moderation transition), `ContractModerationCounterpartyBarredError` (41114, from the document gate; 41111 to 41113 are reserved), `ContractUserNotWarnedError` (41117), `ContractUserWarningLimitReachedError` (41118). A contract update that turns a list on or off is refused with the existing `DataContractConfigUpdateError` (40002). Elected moderation has its own band (41200-41299): `ContractModeratedDocumentTypeNotYetUsableError` (41200), `ContractModerationAbilityNotGrantedError` (41201), `ModerationCharterAddedModeratorLimitReachedError` (41202) and `ModerationReasonNotListedError` (41203). A discounted action fee the seated charter does not give is refused with `DocumentActionFeeModeratorsShareMismatchError` (40139), beside the other fee agreement errors. +Basic, in their own band (10900-10949): `InvalidContractModerationConfigError` (10900), `ContractModerationSelfTargetError` (10901), `DocumentActionFeesWithoutModerationError` (10902, see Fee Pots and the Claim), `ContractModerationReasonTooLongError` (10903), `InvalidContractModerationReasonDocumentsError` (10904). State, in their own sub-band: `ContractModerationNotEnabledError` (41100), `IdentityNotContractModeratorError` (41101), `ContractModerationTargetNotAllowedError` (41102), `ContractUserAlreadyBannedError` (41103), `ContractUserNotBannedError` (41104), `ContractUserNotSuspendedError` (41105), `ContractSuspensionNotInFutureError` (41106), `ContractUserBannedError` (41107), `ContractUserSuspendedError` (41108), `ContractModerationTargetNotFoundError` (41109), `ContractModeratorIdentityNotFoundError` (41110, from the contract create and update, not from the moderation transition), `ContractModerationCounterpartyBarredError` (41114, from the document gate; 41111 to 41113 are reserved), `ContractUserNotWarnedError` (41117), `ContractUserWarningLimitReachedError` (41118). A contract update that turns a list on or off is refused with the existing `DataContractConfigUpdateError` (40002). Elected moderation has its own band (41200-41299): `ContractModeratedDocumentTypeNotYetUsableError` (41200), `ContractModerationAbilityNotGrantedError` (41201), `ModerationCharterAddedModeratorLimitReachedError` (41202) and `ModerationReasonNotListedError` (41203). A discounted action fee the seated charter does not give is refused with `DocumentActionFeeModeratorsShareMismatchError` (40139), beside the other fee agreement errors. ## Deleting Documents diff --git a/book/src/drive/document-sum-trees.md b/book/src/drive/document-sum-trees.md index 932b240704c..0eb0510a859 100644 --- a/book/src/drive/document-sum-trees.md +++ b/book/src/drive/document-sum-trees.md @@ -291,7 +291,7 @@ Set at the same level as `type` / `properties` / `indices` on a document type: "properties": { "recipient": { "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, "position": 0, "contentMediaType": "application/x.dash.dpp.identifier" }, - "amount": { "type": "integer", "minimum": 1, "position": 1 }, + "amount": { "type": "integer", "minimum": 1, "maximum": 4294967295, "position": 1 }, "sentAt": { "type": "integer", "minimum": 0, "position": 2 } }, "required": ["recipient", "amount", "sentAt"], diff --git a/book/src/drive/indexes.md b/book/src/drive/indexes.md index 347136c8c74..77656aedc14 100644 --- a/book/src/drive/indexes.md +++ b/book/src/drive/indexes.md @@ -52,7 +52,7 @@ pub struct IndexProperty { ### `name` -A short, human-readable identifier for the index (e.g. `"byOwnerAndType"`). It shows up in error messages and is the key used in `document_type.indexes()` (`BTreeMap`). If omitted in the schema, a random alphanumeric name is generated. Two indexes within the same document type cannot share a name. +A short, human-readable identifier for the index (e.g. `"byOwnerAndType"`). It shows up in error messages and is the key used in `document_type.indexes()` (`BTreeMap`). Every document meta-schema requires it. A parse that skips schema validation (check tx, test fixtures) and meets an unnamed index derives the name from the properties and their directions, joined with `|`, so every parse of the same contract agrees on it. Two indexes within the same document type cannot share a name. ### `properties: Vec` @@ -67,7 +67,7 @@ The schema form is: ] ``` -`asc` / `desc` controls sort order on result enumeration. Drive currently only uses ascending storage, but the field is preserved through the contract. +Every document meta-schema accepts only `"asc"`. Drive stores index entries in ascending order; a query chooses its own result order. ### `unique: bool` diff --git a/book/src/drive/sum-index-examples.md b/book/src/drive/sum-index-examples.md index 48b5509f91f..e4edfec08e8 100644 --- a/book/src/drive/sum-index-examples.md +++ b/book/src/drive/sum-index-examples.md @@ -18,7 +18,7 @@ The `tip` document type carries four properties (`recipient`, `amount`, `sentAt` "properties": { "recipient": { "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, "position": 0, "contentMediaType": "application/x.dash.dpp.identifier" }, - "amount": { "type": "integer", "minimum": 1, "position": 1 }, + "amount": { "type": "integer", "minimum": 1, "maximum": 4294967295, "position": 1 }, "sentAt": { "type": "integer", "minimum": 0, "position": 2 }, "note": { "type": "string", "maxLength": 280, "position": 3 } }, diff --git a/book/src/sdk/identity-keys.md b/book/src/sdk/identity-keys.md index 5f45d6ca6a5..1e21f48868c 100644 --- a/book/src/sdk/identity-keys.md +++ b/book/src/sdk/identity-keys.md @@ -90,7 +90,9 @@ security. This matters because many operations check security levels: - Adding/disabling other keys requires MASTER - Credit transfers require CRITICAL (enforced via the TRANSFER purpose) - - Document operations accept HIGH or MEDIUM depending on the contract + - Document operations accept CRITICAL down to the level each document type + requires (`signatureSecurityLevelRequirement`, HIGH by default), so MEDIUM only + where a type asks for it; a batch holding a token transition needs CRITICAL 3. **Which purposes allow which levels.** Not all combinations are valid for externally added keys (i.e., keys added via identity create/update transitions): diff --git a/book/src/serialization/document-serialization.md b/book/src/serialization/document-serialization.md index 5f6b52230a3..f74d5e78561 100644 --- a/book/src/serialization/document-serialization.md +++ b/book/src/serialization/document-serialization.md @@ -104,9 +104,9 @@ The document's unique identifier, written as raw bytes. This is a 256-bit value The identity that currently owns the document, written as raw bytes. -### `$creatorId` (v2 only, conditional) +### `$creatorId` (v2 and v3, conditional) -Present only in serialization version 2, and only if the document type supports transfers (`documents_transferable`) or trading (`trade_mode != None`). +Present only in serialization versions 2 and 3, and only if the document type supports transfers (`documents_transferable`) or trading (`trade_mode != None`). ```text 0x01 [32 bytes creatorId] — creator ID present @@ -185,7 +185,7 @@ All numeric values use **big-endian** byte order. | `identifier` | 32 bytes raw | | `date` | 8 bytes big-endian f64 (when optional: `0xff` prefix + 8 bytes) | | `array` (typed array, protocol v14) | varint element count + each element encoded exactly as a required property of the element's type (rows above): an identifier element is 32 raw bytes, an integer element takes the width its bounds give it, a fixed-size byte array element is raw, a string or variable-size byte array element has a varint length prefix. Elements never carry a presence byte | -| `object` | Nested fields serialized recursively in their schema position order | +| `object` | Nested fields serialized recursively, in the order the schema lists them (not sorted by `position`) | **Note on date types**: User-property `date` fields are encoded as **f64** (8 bytes). System timestamps (`$createdAt`, `$updatedAt`, `$transferredAt`) are **u64** milliseconds. Both are 8 bytes big-endian but use different numeric representations. From 24b228a2ec9f9447819ffe060b7c7f96cadb3501 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 01:21:11 +0700 Subject: [PATCH 049/113] fix(dpp)!: propertyConstraints compare identifier properties that declare refersTo (PV14) (#5073) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/documents.md | 2 +- .../class_methods/try_from_schema/mod.rs | 6 +- .../v3/property_constraints_tests.rs | 123 ++++++++++++++++ .../document_type/property/mod.rs | 10 ++ .../tests/document/property_constraints.rs | 136 ++++++++++++++++++ .../rs-platform-version/src/version/v14.rs | 26 ++-- 6 files changed, 286 insertions(+), 17 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index c02f52fc6b2..013db9df930 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -712,7 +712,7 @@ The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeas - a comparison, `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` or `greaterThanOrEqual`, listing the left and the right expression; - `{ "in": [expression, [values]] }`, holding if the integer expression takes one of two or more distinct integer values. It says what an `anyOf` of `equal`s says, in one node per value instead of three, so a set of up to 30 values fits the node limit where the `anyOf` fits 10. A value is a literal, never a path or an expression; - a string comparison: `{ "equal": [path, { "const": "closed" }] }` or `notEqual`, with the constant on either side; `{ "notEqual": ["fromCurrency", "toCurrency"] }`, two bare paths that both name string properties, which compares their strings; or `{ "in": [path, ["open", "pending"]] }`, whose values are two or more distinct strings. The path names a string property, typically one with an `enum`. A string on its own is a path, so a constant is written as `{ "const": ... }`, while the values an `in` lists are literals and need no wrapper. Strings are only compared for equality, never ordered or used in arithmetic. A string property the document leaves out equals no constant and no other string property, not even one also left out, so `notEqual` holds for it and `equal` and `in` do not, unless `{ "ifAbsent": [path, "open"] }` gives it a string default, which it then reads as (it may stand wherever the bare path does, and makes the comparison one of strings); `present` and `absent` test it directly. When the property declares an `enum`, every constant compared with it must be one of the enum's values, so a misspelling is refused at registration rather than making the rule quietly never hold; -- an identifier comparison, the same three forms for identifier properties: `{ "equal": ["paymentToken", { "const": "" }] }` or `notEqual`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. Constants are base58 identifiers of 32 bytes, checked at registration, and compared by their bytes, whatever form the document gives the identifier in. An identifier property the document leaves out equals no identifier, not even another one left out; identifiers take no `ifAbsent` default and are never ordered. `$ownerId`, the document's owner, is an identifier operand too: `{ "equal": ["authorId", "$ownerId"] }` holds the author to the owner, and `{ "in": ["$ownerId", ["", ...]] }` lets only the identities listed own a document of the type. It is no property, so `present` or an integer operand refuses it, and comparing it with itself is refused. Since a transfer and a purchase give the document a new owner, each is judged against the rules reading `$ownerId`, with the new owner, and refused when it would break one; an indexOnly type refuses such a rule, since its deletes carry no owner; +- an identifier comparison, the same three forms for identifier properties, those declaring `refersTo` included: `{ "equal": ["paymentToken", { "const": "" }] }` or `notEqual`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. Constants are base58 identifiers of 32 bytes, checked at registration, and compared by their bytes, whatever form the document gives the identifier in. An identifier property the document leaves out equals no identifier, not even another one left out; identifiers take no `ifAbsent` default and are never ordered. `$ownerId`, the document's owner, is an identifier operand too: `{ "equal": ["authorId", "$ownerId"] }` holds the author to the owner, and `{ "in": ["$ownerId", ["", ...]] }` lets only the identities listed own a document of the type. It is no property, so `present` or an integer operand refuses it, and comparing it with itself is refused. Since a transfer and a purchase give the document a new owner, each is judged against the rules reading `$ownerId`, with the new owner, and refused when it would break one; an indexOnly type refuses such a rule, since its deletes carry no owner; - `{ "present": path }`, holding if the document holds the property, and `{ "absent": path }`, holding if it leaves it out (a property set to null counts as left out). An operand reads a property the document leaves out as 0, so only these tell "not given" from "given as 0". They may name a property of any type, an object or a member of one included, since they read no value; - `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; - `{ "allOf": [...] }`, holding if every one of two or more conditions holds; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index b7aaaf3cde6..3a48ec042cd 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -1950,7 +1950,7 @@ fn apply_property_constraints_v0( .map(|property| &property.property_type) { Some(DocumentPropertyType::String(_)) => Some(EqualityKind::Text), - Some(DocumentPropertyType::Identifier) => Some(EqualityKind::Identifier), + Some(property_type) if property_type.is_identifier() => Some(EqualityKind::Identifier), _ => None, }; let constraints = @@ -1994,7 +1994,7 @@ fn apply_property_constraints_v0( ))); } // An identifier is compared with identifiers, never read as a number - Some(DocumentPropertyType::Identifier) => { + Some(property_type) if property_type.is_identifier() => { return Err(structure_error(format!( "rule \"{name}\" reads \"{path}\", which has type identifier, not \ integer or boolean: an identifier property is compared, by equal or \ @@ -2053,7 +2053,7 @@ fn apply_property_constraints_v0( .get(path) .map(|property| &property.property_type) { - Some(DocumentPropertyType::Identifier) => {} + Some(property_type) if property_type.is_identifier() => {} Some(other) => { return Err(structure_error(format!( "rule \"{name}\" compares \"{path}\" with an identifier, but it has \ diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index a6cd3452326..96717ba8149 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -600,6 +600,129 @@ fn should_compare_the_owner_and_refuse_it_on_an_index_only_type() { } } +/// An identifier property that declares `refersTo` is an identifier property +/// all the same: on both paths it compares with a const, with another +/// identifier property whether or not that one declares `refersTo`, with +/// `$ownerId` and in an `in`, and the rules judge its value as they judge any +/// identifier's; read as a number, it is refused as any identifier is. +#[test] +fn should_compare_identifier_properties_that_declare_refers_to() { + let token = Identifier::new([7; 32]); + let other = Identifier::new([8; 32]); + let rules = json!({ + "boughtWithToken": { "equal": ["buyerId", { "const": token.to_string(Encoding::Base58) }] }, + "buyerIsNotSeller": { "notEqual": ["buyerId", "sellerId"] }, + "buyerOwns": { "equal": ["buyerId", "$ownerId"] }, + "knownBuyer": { + "in": [ + "buyerId", + [token.to_string(Encoding::Base58), other.to_string(Encoding::Base58)] + ] + } + }); + let referring_schema = |rules: serde_json::Value, referring: &[&str]| { + let mut schema = order_schema(Some(rules), None); + for path in referring { + schema["properties"][*path]["refersTo"] = json!({ "type": "identity" }); + } + schema_value(schema) + }; + + for referring in [&["buyerId"][..], &["sellerId"], &["buyerId", "sellerId"]] { + for full_validation in [true, false] { + let document_type = parse_dispatched( + referring_schema(rules.clone(), referring), + PlatformVersion::latest(), + full_validation, + ) + .unwrap_or_else(|e| { + panic!("{referring:?}, full_validation {full_validation}: should parse: {e}") + }); + for path in referring { + assert!(matches!( + document_type.flattened_properties()[*path].property_type, + DocumentPropertyType::IdentifierWithReference(_) + )); + } + let constraints = document_type.property_constraints(); + assert_eq!( + constraints["buyerIsNotSeller"].property_reads(), + [ + ("buyerId", PropertyRead::Identifier), + ("sellerId", PropertyRead::Identifier) + ] + ); + for name in ["boughtWithToken", "buyerOwns", "knownBuyer"] { + assert_eq!( + constraints[name].property_reads(), + [("buyerId", PropertyRead::Identifier)], + "{name}" + ); + } + assert!(constraints["buyerOwns"].reads_owner()); + + let order = |buyer: Identifier| { + platform_value!({ + "buyerId": buyer, + "sellerId": Identifier::new([9; 32]), + }) + }; + for (name, owner, holds_for_token, holds_for_other) in [ + ("boughtWithToken", None, true, false), + ("buyerIsNotSeller", None, true, true), + ("buyerOwns", Some(token), true, false), + ("knownBuyer", None, true, true), + ] { + assert_eq!( + constraints[name].holds(&order(token), owner), + Ok(holds_for_token), + "{name}" + ); + assert_eq!( + constraints[name].holds(&order(other), owner), + Ok(holds_for_other), + "{name}" + ); + } + // The seller's id as the buyer's: listed nowhere, and equal to the seller + for name in ["knownBuyer", "buyerIsNotSeller"] { + assert_eq!( + constraints[name].holds(&order(Identifier::new([9; 32])), None), + Ok(false), + "{name}" + ); + } + } + } + + for (rules, needle) in [ + ( + json!({ "rule": { "equal": ["buyerId", "price"] } }), + "rule \"rule\" reads \"buyerId\", which has type identifier, not integer or boolean: \ + an identifier property is compared", + ), + ( + json!({ "rule": { "equal": ["note", "buyerId"] } }), + "rule \"rule\" at equal compares a string property with an identifier property", + ), + ( + json!({ "rule": { "lessThan": ["buyerId", "sellerId"] } }), + "rule \"rule\" at lessThan compares identifiers, which only equal and notEqual do", + ), + ] { + for full_validation in [true, false] { + expect_structure_error( + parse_dispatched( + referring_schema(rules.clone(), &["buyerId"]), + PlatformVersion::latest(), + full_validation, + ), + needle, + ); + } + } +} + /// `$ownerId` is the document's owner, which every document has, not a /// property of the type: a presence test of it is refused on both paths, and /// any other system property the meta-schema refuses when registering. diff --git a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index 5afd8f446fd..ddc28d227b9 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -3725,6 +3725,16 @@ impl DocumentPropertyType { ) } + /// Whether the value is one identifier, whether or not the property + /// carries its own `refersTo` reference. A typed array of identifiers is + /// not one. + pub fn is_identifier(&self) -> bool { + matches!( + self, + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) + ) + } + pub fn sanitize_value_mut(&self, value: &mut Value) { match (self, value.clone()) { // Convert hex or base64 strings to byte arrays for ByteArray fields diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index c95a72d02a2..cfa0a56e9cd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -14,6 +14,7 @@ use super::*; mod property_constraints_tests { use super::*; use crate::execution::validation::state_transition::batch::action_validation::document::document_replace_transition_action::DocumentReplaceTransitionActionValidation; + use crate::execution::validation::state_transition::tests::setup_identity_without_adding_it; use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TempPlatform; use dpp::consensus::basic::document::PropertyConstraintViolation; @@ -1069,6 +1070,141 @@ mod property_constraints_tests { assert_eq!(fixture.stored_offers()[0].owner_id(), recipient.id()); } + /// A `sellerId` that declares `refersTo` an identity is compared with + /// `$ownerId` as any identifier property is: the contract registers, a + /// create naming another existing identity as seller is refused, one naming + /// the owner is accepted, and a transfer is refused. + #[tokio::test] + async fn should_compare_an_identifier_property_that_declares_refers_to() { + let mut schema = owned_offer_schema(); + schema["properties"]["sellerId"]["refersTo"] = platform_value!({ "type": "identity" }); + let mut fixture = OfferFixture::with_schema(schema); + let owner = fixture.identity.id(); + let (other, _, _) = fixture.other_identity(964); + + let result = fixture + .create(|document| document.set("sellerId", Value::Identifier(other.id().to_buffer()))) + .await; + expect_violated(result, "sellerIsOwner", PropertyConstraintViolation::NotMet); + assert!(fixture.stored_offers().is_empty()); + + assert_matches!( + fixture + .create(|document| document.set("sellerId", Value::Identifier(owner.to_buffer()))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let result = fixture.transfer(other.id()).await; + expect_violated(result, "sellerIsOwner", PropertyConstraintViolation::NotMet); + let stored = fixture.stored_offers(); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].owner_id(), owner); + } + + /// Identifier properties that declare `refersTo` an identity are compared + /// with a const, with the identifiers an `in` lists and with each other on + /// a create, as plain ones are. Every identity the rules name exists, so + /// only a rule refuses a create and the accepted one meets its references. + #[tokio::test] + async fn should_judge_const_in_and_pair_rules_over_refers_to_identifiers() { + let seeds = [965, 966, 967, 968]; + let [payer, token_a, token_b, banned] = + seeds.map(|seed| setup_identity_without_adding_it(seed, 0).0.id()); + let base58 = |id: Identifier| Value::Text(id.to_string(Encoding::Base58)); + let referring = |position: u32| { + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "identity" }, + "position": position + }) + }; + let schema = platform_value!({ + "type": "object", + "documentsMutable": true, + "properties": { + "price": { "type": "integer", "minimum": 0, "position": 0 }, + "fee": { "type": "integer", "minimum": 0, "position": 1 }, + "quantity": { "type": "integer", "minimum": 0, "position": 2 }, + "deposit": { "type": "integer", "minimum": 0, "position": 3 }, + "payerId": referring(4), + "refundTo": referring(5), + "paymentToken": referring(6) + }, + "required": ["price", "fee", "quantity", "deposit"], + "propertyConstraints": { + "paidInAcceptedToken": { + "anyOf": [ + { "absent": "paymentToken" }, + { "in": ["paymentToken", [base58(token_a), base58(token_b)]] } + ] + }, + "payerNotBanned": { + "anyOf": [ + { "absent": "payerId" }, + { "notEqual": ["payerId", { "const": base58(banned) }] } + ] + }, + "refundGoesToPayer": { + "anyOf": [{ "absent": "refundTo" }, { "equal": ["refundTo", "payerId"] }] + } + }, + "additionalProperties": false + }); + let mut fixture = OfferFixture::with_schema(schema); + for seed in seeds { + fixture.other_identity(seed); + } + let identifier = |id: Identifier| Value::Identifier(id.to_buffer()); + + let result = fixture + .create(|document| document.set("paymentToken", identifier(banned))) + .await; + expect_violated( + result, + "paidInAcceptedToken", + PropertyConstraintViolation::NotMet, + ); + + let result = fixture + .create(|document| document.set("payerId", identifier(banned))) + .await; + expect_violated( + result, + "payerNotBanned", + PropertyConstraintViolation::NotMet, + ); + + let result = fixture + .create(|document| { + document.set("payerId", identifier(payer)); + document.set("refundTo", identifier(token_a)); + }) + .await; + expect_violated( + result, + "refundGoesToPayer", + PropertyConstraintViolation::NotMet, + ); + assert!(fixture.stored_offers().is_empty()); + + assert_matches!( + fixture + .create(|document| { + document.set("paymentToken", identifier(token_b)); + document.set("payerId", identifier(payer)); + document.set("refundTo", identifier(payer)); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 1); + } + #[tokio::test] async fn should_judge_a_replace_against_the_rules() { let mut fixture = OfferFixture::new(); diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index e3dd5281e00..2fc70a41e01 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1057,19 +1057,19 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// constant and no other string unless an `ifAbsent` gives it a string /// default (`{ "ifAbsent": ["status", "open"] }`, whose default an `enum` /// must list too); `equal`, `notEqual` or `in` of an identifier property -/// or of `$ownerId`, the document's owner, likewise, with base58 -/// identifier constants or another identifier operand and no default, an -/// identifier the document leaves out equalling none; `present` or -/// `absent` naming a property of any type, whether the document holds it -/// (the one way to tell a property left out from one set to 0); `anyOf` or -/// `allOf` over two or more conditions; or `not` over one. In an operand, a -/// property the document leaves out counts as 0, or as the value of an -/// `ifAbsent` operand naming it. Arithmetic is exact `i128`: `divide` and -/// `modulo` are Euclidean (the remainder is never negative), and an -/// overflow, a zero divisor, a negative exponent or a value that is not an -/// integer refuses the document rather than wrapping. Conditions are -/// checked in declared order and no further than the outcome needs -/// (`anyOf` stops at the first that holds, `allOf` at the first that +/// (one declaring `refersTo` included) or of `$ownerId`, the document's +/// owner, likewise, with base58 identifier constants or another identifier +/// operand and no default, an identifier the document leaves out equalling +/// none; `present` or `absent` naming a property of any type, whether the +/// document holds it (the one way to tell a property left out from one set +/// to 0); `anyOf` or `allOf` over two or more conditions; or `not` over +/// one. In an operand, a property the document leaves out counts as 0, or +/// as the value of an `ifAbsent` operand naming it. Arithmetic is exact +/// `i128`: `divide` and `modulo` are Euclidean (the remainder is never +/// negative), and an overflow, a zero divisor, a negative exponent or a +/// value that is not an integer refuses the document rather than wrapping. +/// Conditions are checked in declared order and no further than the outcome +/// needs (`anyOf` stops at the first that holds, `allOf` at the first that /// fails), a fault in one that is checked refuses the document whatever the /// others say, and `not` never turns a fault into a pass, so an earlier /// condition guards a later one. The parser checks that every path an From 5e1e7e0eb455786ea30c37225c8c6d8528261c13 Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:02:17 +0000 Subject: [PATCH 050/113] fix(ci): isolate NPM and Kotlin releases in disposable runners --- .github/NPM_RUNNER.md | 78 +++++++++------ .github/actions/nodejs/action.yaml | 5 + .github/actions/npm-release-build/action.yaml | 8 +- .../release-cargo-target-cache/action.yaml | 9 +- .../tests/test_npm_release_boundary.py | 87 ++++++++++++++--- .github/workflows/npm-runner-validation.yml | 1 + .github/workflows/release-kotlin-sdk.yml | 96 ++++++++++--------- .github/workflows/release-npm-build.yml | 31 +++--- .github/workflows/release.yml | 6 +- 9 files changed, 207 insertions(+), 114 deletions(-) diff --git a/.github/NPM_RUNNER.md b/.github/NPM_RUNNER.md index 351b55fd00e..032f47f81ed 100644 --- a/.github/NPM_RUNNER.md +++ b/.github/NPM_RUNNER.md @@ -1,7 +1,8 @@ # NPM release runners -NPM release compilation uses `[self-hosted, Linux, X64, npm-build]` in the -restricted `platform-npm-releases` runner group. Publishing +NPM release compilation uses a fresh single-job runner with a unique +`platform-release---npm` label in the `platform-release-builds` +runner group. Kotlin releases use the same lifecycle with a `-kotlin` label. Publishing continues on GitHub-hosted Ubuntu with OIDC; the builder receives no publishing credentials. The `npm-release-build` action is shared by releases and image validation so both compile and pack with the same setup. @@ -16,15 +17,17 @@ TypeScript generation comes from the workspace's pinned `ts-protoc-gen` dependen Image-owned native dependencies are verified, never installed using sudo. Rust, Node and the pinned WASM tools use writable runner/user locations. Cargo targets -remain outside the checkout; each release starts with a fresh workspace. The +remain in job-local HOME; each release starts with fresh runner, HOME and workspace state. The runner needs no Docker CLI/socket or KVM device. ## Provisioning and promotion Use the reviewed `dashpay/dash-selfhosted-image` recipe and a tested immutable -image digest, not a moving tag. Register dedicated release capacity with `npm-build` only after -the NPM validation workflow succeeds on that image. Drain old registrations before -replacement; retain their image/configuration for rollback. Old release tags +image digest, not a moving tag. Deploy the host-side disposable release controller +only after the NPM validation workflow succeeds on that image. Do not add generic +release labels to persistent CI registrations. See the +[controller installation and cleanup runbook](https://github.com/dashpay/dash-selfhosted-image/blob/main/docs/disposable-releases.md). +Old release tags still contain their original workflows and do not automatically gain this fix. Requirements-changing PRs select a candidate label bound to the complete PR head @@ -54,32 +57,47 @@ checks committed generated output, tests failure recovery and validates packing. Local setup and generator test commands are in `packages/dapi-grpc/README.md`. +After installing the controller, use the `release.yml` dispatch with +`tag=npm-test:` on a protected development branch for a non-publishing +NPM build. For Kotlin, dispatch `release-kotlin-sdk.yml` from the protected branch +with an existing published `tag` and `dry_run=true`: compilation/artifact upload +run, but release attachment and Maven publication are both skipped. Check that +the image contract matches the selected source. Neither controller unit tests +nor an image smoke test establishes that these end-to-end jobs pass. + ## Separate PR and release state -The `platform-npm-releases` organization runner group must select only the -`dashpay/platform` repository and restrict execution to: +The `platform-release-builds` organization runner group selects only +`dashpay/platform` and contains only controller-created one-job registrations. +Each build requests: -```text -dashpay/platform/.github/workflows/release-npm-build.yml@refs/heads/v4.2-dev +```yaml +runs-on: + group: platform-release-builds + labels: [self-hosted, Linux, X64, 'platform-release-${{ github.run_id }}-${{ github.run_attempt }}-npm'] ``` -Protect that branch with the normal maintainer review policy. `release.yml` -invokes that protected reusable workflow; the reusable workflow rejects PR -callers and arbitrary branch dispatches before checkout. A PR cannot select the -release group by changing its own workflow to request the `npm-build` label. -The group-level selected-workflow restriction is a required operator setting, -not something a repository workflow can grant itself. - -Use separate runner containers/VMs and separate registration, workspace, HOME, -Cargo registry and target-cache storage for PR and release pools. Do not mount -the same cache volumes into both pools. Release caches can persist between -releases; no PR may write them. Ordinary PR validation uses `npm-pr`; image -candidates continue to use fresh one-job registrations and volumes. Do not add -`npm-pr`, `rust-ci` or `kotlin-ci` to the release registration. - -For another maintained branch, create its reviewed protected reusable-workflow -ref and corresponding group policy explicitly. Do not wildcard the workflow -restriction or allow PR refs. Validate the policy by attempting a PR job that -requests the release group: it must be rejected, while a permitted release dry -run succeeds. The workflow guard is defense in depth; enabling the release -pool without its group restriction does not establish this boundary. +There is no fallback to `npm-build`, `rust-ci` or `kotlin-ci`. Without the +controller, builds stay queued. Runtime markers reject accidental routing to an +ordinary runner; they are not cryptographic attestation. The host controller +independently checks repository, event, workflow, run, attempt and commit before +creating fresh JIT capacity. Only one job can consume each registration; the host +destroys its container/processes, HOME, registration and workspace afterward. + +**Ordinary PR caching is unchanged.** PR validation keeps its own persistent +Cargo/Gradle/Yarn caches. Releases reuse the prebaked image/toolchains but never +mount PR state or restore shared executable dependency caches. Yarn caching is +opted out only for the release runtime; Kotlin release build/publication disable +Gradle cache restores. A cold release compile is the intentional tradeoff; do not +reintroduce shared caches to speed it up without reviewing their writer trust. + +`release.yml` calls its local reusable workflow, so the workflow travels with the +release source. Port it and the matching image requirements to 4.3; the host +controller needs no branch-specific allowlist. Branch protection, trusted tags, +fork approvals and hosted publishing authorization remain necessary. Optional +selected-workflow group restrictions are defense in depth, not the mechanism +that erases prior-job state. Labels alone are not authorization. + +This assumes a trusted host and pinned image. A fresh container does not repair +host compromise or retroactively secure old release tags/artifacts. Merge/deploy +the controller before relying on this workflow change for a release. diff --git a/.github/actions/nodejs/action.yaml b/.github/actions/nodejs/action.yaml index 8c8066edf8e..96a834eb632 100644 --- a/.github/actions/nodejs/action.yaml +++ b/.github/actions/nodejs/action.yaml @@ -2,6 +2,10 @@ name: "Setup Node.JS" description: "Setup Node.JS binaries, dependencies and cache" inputs: + cache: + description: "Restore/save executable dependency build caches" + required: false + default: "true" node-version: description: "Node.js version to use" required: false @@ -29,6 +33,7 @@ runs: run: npm config set audit false - name: Cache NPM build artifacts + if: inputs.cache == 'true' uses: actions/cache@v5 with: # Cache the unplugged packages (unpacked native builds), yarn's diff --git a/.github/actions/npm-release-build/action.yaml b/.github/actions/npm-release-build/action.yaml index 8806cac1a7b..d139698a414 100644 --- a/.github/actions/npm-release-build/action.yaml +++ b/.github/actions/npm-release-build/action.yaml @@ -2,7 +2,7 @@ name: Build release NPM packages description: Compile and pack on the provisioned unprivileged Linux image inputs: cache-name: - description: Persistent Cargo target cache name + description: Cargo target directory name (job-local on disposable release runners) default: release-npm-target runs: using: composite @@ -24,7 +24,7 @@ runs: uses: ./.github/actions/rust with: target: wasm32-unknown-unknown - # The runner persists the Cargo registry between runs. + # Never restore a shared Rust build cache into a release. cache: 'false' system-dependencies: verify @@ -54,6 +54,10 @@ runs: - name: Setup Node.JS uses: ./.github/actions/nodejs + with: + # PR image validation may use its own caches; release jobs must not + # import executable state from the ordinary CI cache namespace. + cache: ${{ env.DASH_RELEASE_RUNNER != '1' }} - name: Install Cargo binstall uses: cargo-bins/cargo-binstall@v1.3.1 diff --git a/.github/actions/release-cargo-target-cache/action.yaml b/.github/actions/release-cargo-target-cache/action.yaml index c135fdcc1d5..618117b86ac 100644 --- a/.github/actions/release-cargo-target-cache/action.yaml +++ b/.github/actions/release-cargo-target-cache/action.yaml @@ -1,11 +1,10 @@ --- name: "Release Cargo target cache" description: >- - Point CARGO_TARGET_DIR at a per-workflow release cache outside the workspace - of a persistent self-hosted runner. Release jobs wipe the workspace before - checkout (PR jobs share it), so a cache inside it would never survive. The - cache is reset when it outgrows max-gib or the volume drops below - min-free-gib, so it cannot starve the PR jobs on the same runner of disk. + Point CARGO_TARGET_DIR at a per-workflow directory outside the checkout. + On disposable release runners this is job-local HOME state, destroyed with + the container, never a cache shared with PRs. Reset it when it outgrows + max-gib or available space drops below min-free-gib. inputs: name: description: Cache directory name, unique per release workflow diff --git a/.github/scripts/tests/test_npm_release_boundary.py b/.github/scripts/tests/test_npm_release_boundary.py index 9a98a9197d2..f457408204b 100644 --- a/.github/scripts/tests/test_npm_release_boundary.py +++ b/.github/scripts/tests/test_npm_release_boundary.py @@ -1,34 +1,40 @@ -"""Execute the protected reusable workflow's guard against caller contexts.""" +"""Exercise caller/runtime guards and preserve the PR/release cache boundary.""" import os from pathlib import Path import subprocess +import textwrap import unittest ROOT = Path(__file__).resolve().parents[3] -def release_guard(): - workflow = (ROOT / '.github/workflows/release-npm-build.yml').read_text() - # The guard must run before checkout or any caller-controlled source. - start = workflow.index(' - name: Reject untrusted release callers') - end = workflow.index(' - uses: softwareforgood/', start) - block = workflow[start:end] - script = block.split(' run: |\n', 1)[1] - return '\n'.join(line[10:] for line in script.splitlines() if line.strip()) +def step_script(filename, name): + workflow = (ROOT / '.github/workflows' / filename).read_text() + start = workflow.index(' - name: ' + name) + tail = workflow[start:].split(' run: |\n', 1)[1] + lines = [] + for line in tail.splitlines(): + if line.strip() and not line.startswith(' '): + break + lines.append(line) + return textwrap.dedent('\n'.join(lines)) class ReleaseBoundaryTests(unittest.TestCase): - def run_guard(self, event, ref, repository='dashpay/platform'): - return subprocess.run(['bash', '-c', release_guard()], capture_output=True, text=True, + def run_guard(self, event, ref, repository='dashpay/platform', protected=False): + script = step_script('release-npm-build.yml', 'Reject untrusted release callers') + return subprocess.run(['bash', '-c', script], capture_output=True, text=True, env=dict(os.environ, GITHUB_REPOSITORY=repository, - GITHUB_EVENT_NAME=event, GITHUB_REF=ref)).returncode + GITHUB_EVENT_NAME=event, GITHUB_REF=ref, + GITHUB_REF_PROTECTED=str(protected).lower())).returncode def test_should_allow_release_tags_and_protected_branch_dry_runs(self): for event, ref in [('release', 'refs/tags/v4.2.0-beta.5'), ('workflow_dispatch', 'refs/tags/v4.2.0-beta.5'), - ('workflow_dispatch', 'refs/heads/v4.2-dev')]: + ('workflow_dispatch', 'refs/heads/v4.2-dev'), + ('workflow_dispatch', 'refs/heads/v4.3-dev')]: with self.subTest(event=event, ref=ref): - self.assertEqual(self.run_guard(event, ref), 0) + self.assertEqual(self.run_guard(event, ref, protected=True), 0) def test_should_reject_prs_forks_and_unprotected_dispatches(self): for event, ref in [('pull_request', 'refs/pull/5068/merge'), @@ -39,3 +45,56 @@ def test_should_reject_prs_forks_and_unprotected_dispatches(self): with self.subTest(event=event, ref=ref): self.assertNotEqual(self.run_guard(event, ref), 0) self.assertNotEqual(self.run_guard('release', 'refs/tags/v4.2.0', 'unknown/platform'), 0) + self.assertNotEqual(self.run_guard('workflow_dispatch', 'refs/heads/v4.2-dev'), 0) + + def test_should_reject_persistent_wrong_attempt_and_wrong_kind_runners(self): + for kind, filename in [('npm', 'release-npm-build.yml'), ('kotlin', 'release-kotlin-sdk.yml')]: + script = step_script(filename, 'Verify disposable release runner') + env = dict(os.environ, GITHUB_RUN_ID='123', GITHUB_RUN_ATTEMPT='2', + DASH_RELEASE_RUNNER='1', DASH_RELEASE_RUN_ID='123', + DASH_RELEASE_RUN_ATTEMPT='2', DASH_RELEASE_KIND=kind) + self.assertEqual(subprocess.run(['bash', '-c', script], env=env).returncode, 0) + for key, value in [('DASH_RELEASE_RUNNER', ''), ('DASH_RELEASE_RUN_ID', '124'), + ('DASH_RELEASE_RUN_ATTEMPT', '1'), ('DASH_RELEASE_KIND', 'rust')]: + with self.subTest(kind=kind, key=key): + self.assertNotEqual(subprocess.run(['bash', '-c', script], + env=dict(env, **{key: value})).returncode, 0) + + def test_should_route_both_release_builds_away_from_persistent_ci(self): + for kind, filename in [('npm', 'release-npm-build.yml'), ('kotlin', 'release-kotlin-sdk.yml')]: + workflow = (ROOT / '.github/workflows' / filename).read_text() + build = workflow.split(' attach-release:', 1)[0] + self.assertIn('group: platform-release-builds', build) + self.assertIn('platform-release-${{ github.run_id }}-${{ github.run_attempt }}-' + kind, build) + self.assertNotIn('runs-on: [self-hosted, kotlin-ci]', build) + self.assertNotIn('Linux, X64, npm-build', build) + self.assertLess(build.index('Verify disposable release runner'), build.index('uses: actions/checkout')) + caller = (ROOT / '.github/workflows/release.yml').read_text() + self.assertIn('uses: ./.github/workflows/release-npm-build.yml', caller) + self.assertNotIn('release-npm-build.yml@v4.2-dev', caller) + + def test_should_keep_pr_caching_enabled_but_exclude_it_from_releases(self): + node = (ROOT / '.github/actions/nodejs/action.yaml').read_text() + cache_input = node.split(' cache:\n', 1)[1].split(' node-version:', 1)[0] + self.assertIn('default: "true"', cache_input) + self.assertIn("if: inputs.cache == 'true'", node) + self.assertIn('uses: actions/cache@v5', node) + npm = (ROOT / '.github/actions/npm-release-build/action.yaml').read_text() + self.assertIn("cache: ${{ env.DASH_RELEASE_RUNNER != '1' }}", npm) + kotlin = (ROOT / '.github/workflows/release-kotlin-sdk.yml').read_text() + for gradle in kotlin.split('uses: gradle/actions/setup-gradle@v4')[1:]: + self.assertIn('cache-disabled: true', gradle.split('\n - ', 1)[0]) + # The ordinary image-validation workflow retains its own cache name + # and does not opt into the release runtime marker. + validation = (ROOT / '.github/workflows/npm-runner-validation.yml').read_text() + self.assertIn('cache-name: npm-validation-target', validation) + self.assertNotIn('DASH_RELEASE_RUNNER:', validation) + + def test_should_keep_kotlin_dry_runs_out_of_both_publishing_jobs(self): + kotlin = (ROOT / '.github/workflows/release-kotlin-sdk.yml').read_text() + attach = kotlin.split(' attach-release:', 1)[1].split(' steps:', 1)[0] + maven = kotlin.split(' maven-central-deploy:', 1)[1].split(' steps:', 1)[0] + self.assertIn('if: ${{ !inputs.dry_run }}', attach) + self.assertIn('if: ${{ !inputs.dry_run &&', maven) + self.assertIn("github.ref == format('refs/tags/{0}', inputs.tag)", maven) + self.assertIn('environment: maven-central', maven) diff --git a/.github/workflows/npm-runner-validation.yml b/.github/workflows/npm-runner-validation.yml index f8eb9336c12..b21412df94a 100644 --- a/.github/workflows/npm-runner-validation.yml +++ b/.github/workflows/npm-runner-validation.yml @@ -11,6 +11,7 @@ on: - '.github/workflows/npm-runner-validation.yml' - '.github/workflows/release.yml' - '.github/workflows/release-npm-build.yml' + - '.github/workflows/release-kotlin-sdk.yml' - 'packages/dapi-grpc/**' workflow_dispatch: permissions: diff --git a/.github/workflows/release-kotlin-sdk.yml b/.github/workflows/release-kotlin-sdk.yml index b14f6e40b0f..801b0a53c56 100644 --- a/.github/workflows/release-kotlin-sdk.yml +++ b/.github/workflows/release-kotlin-sdk.yml @@ -19,12 +19,22 @@ name: Release Kotlin SDK on: workflow_call: inputs: + dry_run: + description: 'Build and upload CI artifacts only; never attach or publish' + type: boolean + required: false + default: false tag: description: 'Platform release tag (vX.Y.Z[-pre.N])' required: true type: string workflow_dispatch: inputs: + dry_run: + description: 'Build and upload CI artifacts only; never attach or publish' + type: boolean + required: false + default: false tag: description: >- Existing platform release tag to (re-)release the Kotlin SDK for. @@ -37,14 +47,16 @@ on: jobs: build-and-release: name: Build release AAR (arm64-v8a + x86_64) - # Same persistent runner as kotlin-sdk-build.yml, so the multi-hour - # cargo/NDK build reuses its warm ~/.cargo and target/ caches instead of - # building cold on a hosted runner. No fork PR guard is needed here: the - # workflow only triggers on release/workflow_dispatch (via release.yml), - # never on pull_request. The maven-central-deploy job below deliberately - # stays on a hosted runner so the environment-scoped publishing secrets - # never touch the persistent machine. - runs-on: [self-hosted, kotlin-ci] + # Fresh runner, registration, HOME and workspace for exactly one job. + # Publication remains hosted; no publishing credentials enter this pool. + if: >- + github.repository == 'dashpay/platform' + && (github.event_name == 'release' + || (github.event_name == 'workflow_dispatch' + && (github.ref_protected || startsWith(github.ref, 'refs/tags/')))) + runs-on: + group: platform-release-builds + labels: [self-hosted, Linux, X64, 'platform-release-${{ github.run_id }}-${{ github.run_attempt }}-kotlin'] timeout-minutes: 180 permissions: contents: read # release attachment runs on an ephemeral hosted job @@ -65,27 +77,24 @@ jobs: sha: ${{ steps.resolve-sha.outputs.sha }} steps: - # Same idempotent host check as kotlin-sdk-build.yml, plus gh (used by - # the tag validation below and preinstalled only on hosted images). Runs - # before checkout so the validation step can rely on gh. - - name: Ensure runner dependencies + - name: Verify disposable release runner + shell: bash + run: | + set -euo pipefail + test "${DASH_RELEASE_RUNNER:-}" = 1 + test "${DASH_RELEASE_RUN_ID:-}" = "$GITHUB_RUN_ID" + test "${DASH_RELEASE_RUN_ATTEMPT:-}" = "$GITHUB_RUN_ATTEMPT" + test "${DASH_RELEASE_KIND:-}" = kotlin + + # Verify prebaked native tools. A release job has no sudo or Docker. + - name: Verify runner dependencies run: | set -euo pipefail - MISSING=() for pkg in build-essential cmake curl gh jq libgmp-dev libpulse0 libssl-dev libx11-xcb1 pkg-config python3 unzip zip; do - dpkg -s "$pkg" >/dev/null 2>&1 || MISSING+=("$pkg") + dpkg-query -W -f='${Status}' "$pkg" | grep -Fx 'install ok installed' done - if [ ${#MISSING[@]} -gt 0 ]; then - echo "Installing: ${MISSING[*]}" - sudo apt-get update -qq - sudo apt-get install -qq --yes "${MISSING[@]}" - fi - - if [ ! -x "$HOME/.cargo/bin/rustup" ]; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --no-modify-path --default-toolchain none - fi + test -x "$HOME/.cargo/bin/rustup" echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" # A workflow_dispatch `tag` input is free-form and actions/checkout would @@ -147,17 +156,7 @@ jobs: } >> "$GITHUB_OUTPUT" echo "Releasing Kotlin SDK ${VERSION} for platform release ${TAG}" - # PR jobs run in this same workspace on this persistent runner. Git - # state they leave behind (.git/hooks, .git/config, .git/info/attributes) - # would run inside actions/checkout's own `git checkout`, before any - # later cleanup could remove it, and stale jniLibs or gradle outputs - # must never leak into a release AAR. Start from an empty directory and - # a fresh clone; the release build cache lives outside the workspace. - - name: Empty the workspace left by earlier jobs - run: | - find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + \ - || sudo -n find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + - + # No previous job's Git configuration, hooks or build state exists here. - name: Checkout repository uses: actions/checkout@v4 with: @@ -171,6 +170,9 @@ jobs: id: resolve-sha run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Verify release image contract + run: ci-image-contract verify .github/runner-requirements.json + - name: Verify JDK 17 run: | JAVA_HOME_RESOLVED=$(dirname "$(dirname "$(readlink -f "$(command -v java)")")") @@ -180,21 +182,18 @@ jobs: echo "JAVA_HOME=$JAVA_HOME_RESOLVED" >> "$GITHUB_ENV" echo "$JAVA_HOME_RESOLVED/bin" >> "$GITHUB_PATH" - - name: Set up Android SDK - uses: android-actions/setup-android@v3 - with: - packages: >- - platforms;android-35 - build-tools;35.0.0 - ndk;28.1.13356709 + - name: Verify prebaked Android SDK + run: | + test -f "$ANDROID_SDK_ROOT/platforms/android-35/android.jar" + test -x "$ANDROID_SDK_ROOT/build-tools/35.0.0/aapt2" + test -x "$ANDROID_SDK_ROOT/ndk/28.1.13356709/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" - name: Set up Rust toolchain uses: dtolnay/rust-toolchain@stable with: targets: aarch64-linux-android,x86_64-linux-android - # No actions/cache here: the persistent runner keeps ~/.cargo between - # runs, and the release target cache below lives outside the workspace. + # Job-local only: the host destroys HOME and this cache with the runner. - name: Prepare release Cargo target cache uses: ./.github/actions/release-cargo-target-cache with: @@ -236,6 +235,8 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 + with: + cache-disabled: true - name: Build release AAR working-directory: packages/kotlin-sdk @@ -251,7 +252,7 @@ jobs: printf '%s\n' "$BUILT_SHA" > "dash-sdk-android-${VERSION}.commit.txt" # Release attachment runs on a fresh hosted runner so release-write - # credentials never reach the persistent build machine. + # credentials never reach the disposable build runner. - name: Upload release assets uses: actions/upload-artifact@v4 with: @@ -276,6 +277,7 @@ jobs: attach-release: name: Attach AAR to platform release needs: build-and-release + if: ${{ !inputs.dry_run }} runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -352,7 +354,7 @@ jobs: # selected ref) skips this job cleanly instead of failing at the # environment policy; Maven for such tags goes through the manual # runbook in packages/kotlin-sdk/PUBLISHING.md. - if: ${{ needs.build-and-release.result == 'success' && needs.attach-release.result == 'success' && github.ref == format('refs/tags/{0}', inputs.tag) }} + if: ${{ !inputs.dry_run && needs.build-and-release.result == 'success' && needs.attach-release.result == 'success' && github.ref == format('refs/tags/{0}', inputs.tag) }} runs-on: ubuntu-24.04 timeout-minutes: 60 permissions: @@ -459,6 +461,8 @@ jobs: - name: Setup Gradle if: steps.secrets-gate.outputs.proceed == 'true' && steps.maven-check.outputs.already_published != 'true' uses: gradle/actions/setup-gradle@v4 + with: + cache-disabled: true # Stages the signed release AAR + sources/javadoc jars into # sdk/build/staging-deploy. Task dependencies enforce the publish diff --git a/.github/workflows/release-npm-build.yml b/.github/workflows/release-npm-build.yml index a177e314f04..174b160ec46 100644 --- a/.github/workflows/release-npm-build.yml +++ b/.github/workflows/release-npm-build.yml @@ -1,7 +1,7 @@ name: Build release NPM artifacts -# The runner group permits only this reusable workflow at the protected -# v4.2-dev ref. Callers cannot replace these steps with PR workflow code. +# The host controller allocates a fresh one-job runner for this run/attempt. +# No persistent CI runner carries this label or shares its writable state. on: workflow_call: @@ -15,19 +15,29 @@ jobs: github.repository == 'dashpay/platform' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' - && (github.ref == 'refs/heads/v4.2-dev' || startsWith(github.ref, 'refs/tags/')))) + && (github.ref_protected || startsWith(github.ref, 'refs/tags/')))) runs-on: - group: platform-npm-releases - labels: [self-hosted, Linux, X64, npm-build] + group: platform-release-builds + labels: [self-hosted, Linux, X64, 'platform-release-${{ github.run_id }}-${{ github.run_attempt }}-npm'] timeout-minutes: 120 steps: + - name: Verify disposable release runner + shell: bash + run: | + set -euo pipefail + test "${DASH_RELEASE_RUNNER:-}" = 1 + test "${DASH_RELEASE_RUN_ID:-}" = "$GITHUB_RUN_ID" + test "${DASH_RELEASE_RUN_ATTEMPT:-}" = "$GITHUB_RUN_ATTEMPT" + test "${DASH_RELEASE_KIND:-}" = npm + - name: Reject untrusted release callers shell: bash run: | set -euo pipefail test "$GITHUB_REPOSITORY" = dashpay/platform case "$GITHUB_EVENT_NAME:$GITHUB_REF" in - release:refs/tags/*|workflow_dispatch:refs/tags/*|workflow_dispatch:refs/heads/v4.2-dev) ;; + release:refs/tags/*|workflow_dispatch:refs/tags/*) ;; + workflow_dispatch:refs/heads/*) test "${GITHUB_REF_PROTECTED:-}" = true ;; *) echo '::error::NPM release runners accept only release tags or protected-branch dispatches'; exit 1 ;; esac @@ -36,14 +46,7 @@ jobs: with: name: js-build-${{ github.sha }} - # Start each release from a fresh clone so Git configuration and hooks - # from earlier releases cannot affect checkout. Release-only build - # caches live outside the workspace. - - name: Empty the workspace left by earlier jobs - if: ${{ steps.check-artifact.outputs.exists != 'true' }} - run: | - find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + - + # The entire runner, including HOME and Git state, is new for this job. - name: Check out repo uses: actions/checkout@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d28b68e641..9432b747d9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,9 +28,9 @@ jobs: if: github.event_name == 'release' || startsWith(inputs.tag, 'npm-test:') permissions: contents: read - # Runner-group policy allows this protected reusable workflow only, never - # a copy supplied by a pull request. The checkout remains the caller SHA. - uses: dashpay/platform/.github/workflows/release-npm-build.yml@v4.2-dev + # Keep the workflow with its release source. The host controller supplies + # a fresh JIT runner bound to this run/attempt, independent of branch name. + uses: ./.github/workflows/release-npm-build.yml release-npm: name: Publish NPM packages From 9cd341c014bbc5ede44e5586d5eb041d9bd2adf9 Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:44:07 +0000 Subject: [PATCH 051/113] docs(release): fix NPM dry-run tag example --- .github/NPM_RUNNER.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/NPM_RUNNER.md b/.github/NPM_RUNNER.md index 032f47f81ed..893eb82ecfb 100644 --- a/.github/NPM_RUNNER.md +++ b/.github/NPM_RUNNER.md @@ -58,7 +58,7 @@ checks committed generated output, tests failure recovery and validates packing. Local setup and generator test commands are in `packages/dapi-grpc/README.md`. After installing the controller, use the `release.yml` dispatch with -`tag=npm-test:` on a protected development branch for a non-publishing +`tag=npm-test:v` on a protected development branch for a non-publishing NPM build. For Kotlin, dispatch `release-kotlin-sdk.yml` from the protected branch with an existing published `tag` and `dry_run=true`: compilation/artifact upload run, but release attachment and Maven publication are both skipped. Check that From 10b1c2c48e3c9c5ae0cd433e801fad5704c56a5c Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 03:57:11 +0700 Subject: [PATCH 052/113] feat(platform)!: string length, byte length and array count operands in propertyConstraints rules (PV14) (#5071) Co-authored-by: Claude Opus 5.5 --- .../contract-keywords/property-constraints.md | 8 +- book/src/data-model/documents.md | 7 +- packages/js-evo-sdk/README.md | 2 +- .../document/v3/document-meta.json | 18 +- .../class_methods/try_from_schema/mod.rs | 61 ++++- .../v3/property_constraints_tests.rs | 146 ++++++++++++ .../document_type/property_constraints/mod.rs | 112 ++++++++- .../property_constraints/tests.rs | 214 ++++++++++++++++++ .../tests/document/property_constraints.rs | 90 ++++++++ .../rs-platform-version/src/version/v14.rs | 11 +- .../src/data_contract/property_constraints.rs | 15 +- .../document_type_property_constraints.rs | 28 ++- .../unit/DocumentPropertyConstraints.spec.ts | 57 +++++ 13 files changed, 735 insertions(+), 34 deletions(-) diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index ff68febfe7c..65e102bf28a 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -96,6 +96,10 @@ An integer expression is one of: | `divide` | `{ "divide": [a, b] }` | The Euclidean quotient of `a` by `b` | | `modulo` | `{ "modulo": [a, b] }` | The Euclidean remainder of `a` by `b`, never negative | | `power` | `{ "power": [a, b] }` | `a` to the power `b` | +| `length`, `byteLength` | `{ "length": "title" }` | The characters (as `maxLength` counts them) or UTF-8 bytes (as `maxBytes` counts them) of a string property, 0 when the document leaves it out | +| `count` | `{ "count": "tags" }` | The items of an array property, or the bytes of a byte array property, 0 when the document leaves it out | + +Where `maxLength`, `maxBytes` and `maxItems` bound one property by a fixed number, a size can be compared with another property or bounded only under a condition: `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit. A size never breaks a rule by itself: a property left out or null has size 0, and so would a value of another type, which the schema validation refuses first. Two more forms appear only in string and identifier comparisons, never inside arithmetic: @@ -162,7 +166,7 @@ The meta-schema checks the shape (`JsonSchemaError`, 10101): The parser then checks the rules against the document type (`InvalidContractStructure`, 10231): -- every path an integer expression reads names an integer or boolean property; every path compared with a string names a string property; every path compared with an identifier names an identifier property; every path `present` or `absent` tests names a property of any type, an object included; +- every path an integer expression reads names an integer or boolean property; every path `length` or `byteLength` measures names a string property, and every path `count` counts an array or byte array property; every path compared with a string names a string property; every path compared with an identifier names an identifier property; every path `present` or `absent` tests names a property of any type, an object included; - no rule reads a property that is `transient` or inside a transient object, since a stored document could never be held to it; - every comparison and `in` reads at least one property: a comparison of constants would hold for every document or for none; - strings and identifiers are compared only with `equal`, `notEqual` and `in`; a string is never compared with an identifier; a property is never compared with itself; @@ -188,7 +192,7 @@ A rule within 32 nodes is never deep enough to reach the 64-level bound. Nodes a | `present`, `absent` | 1 | | `anyOf`, `allOf` | 1, plus their conditions | | `not` | 1, plus its condition | -| An integer, a path or an `ifAbsent` | 1 | +| An integer, a path, an `ifAbsent` or a size (`length`, `byteLength`, `count`) | 1 | | `add`, `multiply`, `subtract`, `divide`, `modulo`, `power` | 1, plus their operands | `depositCoversOrder` above is 7 nodes (the comparison, `multiply`, `add` and four paths), and `closedNeedsClosedAt` is 5. An `in` fits up to 30 values in 32 nodes. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 013db9df930..b70e0eabac4 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -724,7 +724,8 @@ Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan - a string, the dotted path of an integer or boolean property of the document type (`"price"`, `"meta.total"`, `"waiveFee"`), whose value it takes, 0 when the document leaves the property out. A boolean reads as 1 for true and 0 for false, so `{ "equal": [{ "multiply": ["waiveFee", "fee"] }, 0] }` says a waived fee is 0; - `{ "ifAbsent": [path, value] }`, the property's value, or `value` when the document leaves it out (an integer value here; a string value gives a string property a default in a string comparison instead); - `{ "add": [...] }` or `{ "multiply": [...] }` over two or more operands; -- `{ "subtract": [a, b] }`, `{ "divide": [a, b] }`, `{ "modulo": [a, b] }` or `{ "power": [a, b] }`. +- `{ "subtract": [a, b] }`, `{ "divide": [a, b] }`, `{ "modulo": [a, b] }` or `{ "power": [a, b] }`; +- a size: `{ "length": path }`, the characters of a string property (counted as `maxLength` counts them), `{ "byteLength": path }`, its UTF-8 bytes (as `maxBytes` counts them), or `{ "count": path }`, the items of an array property or the bytes of a byte array property. Where `maxLength`, `maxBytes` and `maxItems` bound one property by a fixed number, a size can be compared with another property or bounded only under a condition: `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit, and `{ "anyOf": [{ "greaterThan": ["fee", 0] }, { "lessThanOrEqual": [{ "length": "title" }, 20] }] }` keeps a free listing's title short. A property the document leaves out or sets to null has size 0, and a size never breaks a rule by itself (a value of another type would read as 0 too, but the schema validation refuses it first). A JSON number is always a value and a string always a path, so a property named `100` is not confused with the number, and the rule is a tree the meta-schema can check rather than a string with precedence rules to parse. Consensus holds nothing but this tree; an SDK may offer an infix spelling that compiles to it. @@ -732,11 +733,11 @@ The arithmetic is exact over `i128`. Operands are evaluated left to right, and e Conditions are checked in declared order and no further than the outcome needs: a comparison evaluates its left side, then its right; `anyOf` stops at the first condition that holds and `allOf` at the first that fails. A fault in a condition that is checked breaks the rule whatever the others would say, and `not` does not turn it into a pass. So an earlier condition guards a later one: `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` holds for a `b` of 0 without dividing by it, while the same two conditions the other way round divide by zero and break the rule. -The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type, every path compared with a string names a string property (and every constant compared with one that declares an `enum` is one of its values) (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `const`, every `present` or `absent`, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. +The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type, every path `length` or `byteLength` measures a string property, every path `count` counts an array or byte array property, every path compared with a string names a string property (and every constant compared with one that declares an `enum` is one of its values) (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `const`, every `present` or `absent`, every arithmetic operator and every operand (a size is one), and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. `$ownerId` reads the writer, passed in by create and replace (`validate_document_properties` takes the owner; a client that does not know it passes `None`, and `$ownerId` then equals no identifier). Transfers and purchases change no property but the owner, so only the rules reading `$ownerId` are judged again, with the new owner (`DocumentTypeV0Methods::validate_property_constraints_for_new_owner`, next to the `distinctFrom` check); price updates change neither and are not judged. -In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, an `in`, a string comparison (`TextCompare`, `TextIn`), a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and whether by value or by presence), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. +In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, an `in`, a string comparison (`TextCompare`, `TextIn`), a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and how: by value, by presence, by size (`Length`, `Count`) or in a comparison of strings or identifiers), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. ## Rules and Guidelines diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 21db109a290..82b6e94d79d 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -425,7 +425,7 @@ From protocol version 14 a document type can declare rules its documents' proper } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0), or a size: `{ "length": path }` and `{ "byteLength": path }` give the characters and UTF-8 bytes of a string property, and `{ "count": path }` the items of an array or the bytes of a byte array, so `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit (a size read is reported with kind `length` or `count`). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index dd5bf9b5063..1d4d3e65ae7 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -136,7 +136,7 @@ "uniqueItems": true }, "propertyConstraintExpression": { - "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out, an integer, or a string for a string property compared with strings; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; const, a string constant compared with a string property", + "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out, an integer, or a string for a string property compared with strings; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out; const, a string constant compared with a string property", "type": [ "integer", "string", @@ -189,6 +189,18 @@ "power": { "$ref": "#/$defs/propertyConstraintOperandPair" }, + "length": { + "description": "The number of characters of the string property at this path, as maxLength counts them, or 0 when the document leaves it out", + "$ref": "#/$defs/propertyConstraintPath" + }, + "byteLength": { + "description": "The number of UTF-8 bytes of the string property at this path, as maxBytes counts them, or 0 when the document leaves it out", + "$ref": "#/$defs/propertyConstraintPath" + }, + "count": { + "description": "The number of items of the array property at this path, or of bytes of the byte array property, as maxItems counts them, or 0 when the document leaves it out", + "$ref": "#/$defs/propertyConstraintPath" + }, "const": { "description": "A constant, only as one side of an equal or notEqual whose other side is the path of a string property (a string), or of an identifier property or $ownerId (a base58 identifier): a string on its own is a path, and an integer is written as itself", "type": "string" @@ -201,7 +213,7 @@ } }, "propertyConstraintPath": { - "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer or boolean property when an operand reads its value, any property when present or absent tests it. Or $ownerId, the document's owner, which only a comparison of identifiers reads", + "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer or boolean property when an operand reads its value, a string property when length or byteLength measures it, an array or byte array property when count counts its items, any property when present or absent tests it. Or $ownerId, the document's owner, which only a comparison of identifiers reads", "type": "string", "pattern": "^(\\$ownerId|[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*)$" }, @@ -2068,7 +2080,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out ({ \"lessThanOrEqual\": [{ \"count\": \"tags\" }, \"maxTags\"] }). A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property length or byteLength measures a string property, every property count counts an array or byte array property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand, a size included; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 3a48ec042cd..c13c0486e81 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -1880,8 +1880,10 @@ pub(super) fn validate_encrypted_for_declarations( /// Reads the `propertyConstraints` keyword onto the document type and checks /// every property its rules read: by its value, an integer or boolean /// property of the type (a nested one named by its dotted path, as the -/// flattened map names it); by its presence, a property of any type, an object included; either -/// way one that is neither transient nor inside a transient object. A +/// flattened map names it); by its `length` or `byteLength`, a string +/// property; by its `count`, an array or byte array property; by its presence, +/// a property of any type, an object included; any way one that is neither +/// transient nor inside a transient object. A /// transient value is never stored, so a stored document could not be held to /// a rule reading one. The declaration's shape ([`parse_property_constraints`]) and these reads are /// checked on every parse; under full validation, the limits too: at most @@ -1967,6 +1969,8 @@ fn apply_property_constraints_v0( PropertyRead::Value => "reads", PropertyRead::Presence => "tests the presence of", PropertyRead::Text | PropertyRead::Identifier => "compares", + PropertyRead::Length => "measures", + PropertyRead::Count => "counts the items of", }; match read { PropertyRead::Value => match document_type @@ -2048,6 +2052,59 @@ fn apply_property_constraints_v0( ))); } }, + PropertyRead::Length => match document_type + .flattened_properties + .get(path) + .map(|property| &property.property_type) + { + Some(DocumentPropertyType::String(_)) => {} + Some(other) => { + return Err(structure_error(format!( + "rule \"{name}\" measures the length of \"{path}\", which has type {}, \ + not string: count gives the items of an array or byte array", + other.name() + ))); + } + None => { + return Err(structure_error(format!( + "rule \"{name}\" measures the length of \"{path}\", which is not a \ + string property of the document type (a nested one is named by its \ + dotted path)" + ))); + } + }, + PropertyRead::Count => match document_type + .flattened_properties + .get(path) + .map(|property| &property.property_type) + { + Some( + DocumentPropertyType::TypedArray(_) + | DocumentPropertyType::ByteArray(_) + | DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_), + ) => {} + Some(DocumentPropertyType::String(_)) => { + return Err(structure_error(format!( + "rule \"{name}\" counts the items of \"{path}\", which has type \ + string, not array: length or byteLength gives the size of a string" + ))); + } + Some(other) => { + return Err(structure_error(format!( + "rule \"{name}\" counts the items of \"{path}\", which has type {}, \ + not array or byteArray", + other.name() + ))); + } + None => { + return Err(structure_error(format!( + "rule \"{name}\" counts the items of \"{path}\", which is not an array \ + or byte array property of the document type (a nested one is named \ + by its dotted path)" + ))); + } + }, PropertyRead::Identifier => match document_type .flattened_properties .get(path) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index 96717ba8149..ecbd320f82a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -1142,6 +1142,10 @@ fn should_check_the_grammar_with_the_meta_schema_and_the_parser() { json!({ "rule": { "in": ["state", ["open", 2]] } }), json!({ "rule": { "in": ["state", ["open"]] } }), json!({ "rule": { "in": ["state", ["open", "open"]] } }), + json!({ "rule": { "lessThan": [{ "length": 5 }, 10] } }), + json!({ "rule": { "lessThan": [{ "count": ["counts"] }, 10] } }), + json!({ "rule": { "lessThan": [{ "size": "note" }, 10] } }), + json!({ "rule": { "lessThan": [{ "length": "note", "count": "counts" }, 10] } }), ] { let registered = parse_order(rules.clone(), true); assert!( @@ -1295,3 +1299,145 @@ fn should_refuse_adding_removing_or_changing_rules_on_update() { .expect("the update is judged"); assert!(result.is_valid(), "{:?}", result.errors); } + +/// `length` and `byteLength` measure a string property and `count` counts the +/// items of an array property, nested ones included, on both paths. +#[test] +fn should_measure_strings_and_count_arrays_on_both_paths() { + let rules = json!({ + "noteFitsQuantity": { + "lessThanOrEqual": [{ "length": "note" }, { "multiply": ["quantity", 10] }] + }, + "tagWithinBytes": { "lessThanOrEqual": [{ "byteLength": "meta.tag" }, 20] }, + "countsPerUnit": { "lessThanOrEqual": [{ "count": "counts" }, "quantity"] } + }); + for full_validation in [true, false] { + let document_type = parse_order(rules.clone(), full_validation) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let constraints = document_type.property_constraints(); + assert_eq!( + constraints["noteFitsQuantity"].property_reads(), + [ + ("note", PropertyRead::Length), + ("quantity", PropertyRead::Value) + ] + ); + assert_eq!( + constraints["tagWithinBytes"].property_reads(), + [("meta.tag", PropertyRead::Length)] + ); + assert_eq!( + constraints["countsPerUnit"].property_reads(), + [ + ("counts", PropertyRead::Count), + ("quantity", PropertyRead::Value) + ] + ); + } + + // A byte array counts its bytes: here a signature is left out or 64 or 65 + // bytes long + let schema = platform_value!({ + "type": "object", + "properties": { + "signature": { + "type": "array", + "byteArray": true, + "maxItems": 65, + "position": 0 + } + }, + "propertyConstraints": { + "signatureLength": { "in": [{ "count": "signature" }, [0, 64, 65]] } + }, + "additionalProperties": false + }); + for full_validation in [true, false] { + let document_type = + parse_dispatched(schema.clone(), PlatformVersion::latest(), full_validation) + .expect("a rule may count the bytes of a byte array"); + assert_eq!( + document_type.property_constraints()["signatureLength"].property_reads(), + [("signature", PropertyRead::Count)] + ); + } +} + +/// A size reads a property of the type of its measure, stored: `length` and +/// `byteLength` a string, `count` an array or a byte array. +#[test] +fn should_hold_a_size_to_the_property_it_measures() { + for (operand, needle) in [ + ( + json!({ "length": "counts" }), + "measures the length of \"counts\", which has type array, not string: count gives \ + the items of an array or byte array", + ), + ( + json!({ "byteLength": "buyerId" }), + "measures the length of \"buyerId\", which has type identifier, not string", + ), + ( + json!({ "length": "meta" }), + "measures the length of \"meta\", which is not a string property of the document type", + ), + ( + json!({ "byteLength": "$ownerId" }), + "measures the length of \"$ownerId\", which is not a string property", + ), + ( + json!({ "count": "note" }), + "counts the items of \"note\", which has type string, not array: length or \ + byteLength gives the size of a string", + ), + ( + json!({ "count": "buyerId" }), + "counts the items of \"buyerId\", which has type identifier, not array or byteArray", + ), + ( + json!({ "count": "rush" }), + "counts the items of \"rush\", which has type boolean, not array or byteArray", + ), + ( + json!({ "count": "meta.missing" }), + "counts the items of \"meta.missing\", which is not an array or byte array property", + ), + ] { + for full_validation in [true, false] { + expect_structure_error( + parse_order( + json!({ "rule": { "lessThan": [operand.clone(), "price"] } }), + full_validation, + ), + needle, + ); + } + } + + // A transient value is never stored, so no rule may measure one + for (transient, operand, path) in [ + ("note", json!({ "length": "note" }), "note"), + ("meta", json!({ "byteLength": "meta.tag" }), "meta.tag"), + ("counts", json!({ "count": "counts" }), "counts"), + ] { + let verb = if operand.get("count").is_some() { + "counts the items of" + } else { + "measures" + }; + let schema = order_schema( + Some(json!({ "rule": { "lessThan": [operand, "price"] } })), + Some(transient), + ); + for full_validation in [true, false] { + expect_structure_error( + parse_dispatched( + schema_value(schema.clone()), + PlatformVersion::latest(), + full_validation, + ), + &format!("{verb} \"{path}\", which is transient or inside a transient object"), + ); + } + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index 21dc72728f1..04081b19672 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -34,9 +34,13 @@ //! //! An operand is an integer value, the dotted path of an integer or boolean //! property (a boolean reads as 1 for true and 0 for false), or an object with -//! one key: an arithmetic operator over its operands, or `ifAbsent`, a -//! property with the value it takes when the document leaves it out. A -//! property named on its own takes 0 when absent. A string constant is written +//! one key: an arithmetic operator over its operands, `ifAbsent`, a property +//! with the value it takes when the document leaves it out, or a size: +//! `length` and `byteLength`, the characters and the UTF-8 bytes of a string +//! property, and `count`, the items of an array or byte array property. A +//! property named on its own takes 0 when absent, and so does the size of one +//! (`{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }`). A string +//! constant is written //! `{ "const": "closed" }`, since a string on its own is a path; `equal` and //! `notEqual` compare one with a string property, or two bare paths naming //! string properties with each other, and an `in` whose values are strings @@ -83,11 +87,15 @@ const NOT: &str = "not"; const PRESENT: &str = "present"; const ABSENT: &str = "absent"; const IN: &str = "in"; +const LENGTH: &str = "length"; +const BYTE_LENGTH: &str = "byteLength"; +const COUNT: &str = "count"; /// The operand key of a string constant: `{ "const": "closed" }`. const CONST: &str = "const"; /// Every key an operand object may hold, for the errors. -const OPERAND_KEYS: &str = "add, subtract, multiply, divide, modulo, power or ifAbsent"; +const OPERAND_KEYS: &str = + "add, subtract, multiply, divide, modulo, power, ifAbsent, length, byteLength or count"; /// The deepest a condition or an operand may sit in its rule: the rule's own /// condition at depth 0, and each operand of a comparison, and each condition @@ -155,6 +163,39 @@ impl ConstraintComparison { } } +/// What a size operand measures of the property it names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SizeMeasure { + /// `length`: the characters of a string property, as `maxLength` counts + /// them. + Length, + /// `byteLength`: the UTF-8 bytes of a string property, as `maxBytes` + /// counts them. + ByteLength, + /// `count`: the items of an array property, or the bytes of a byte array + /// property, as `maxItems` counts them. + Count, +} + +impl SizeMeasure { + /// The operand key declaring it. + pub fn wire_name(self) -> &'static str { + match self { + SizeMeasure::Length => LENGTH, + SizeMeasure::ByteLength => BYTE_LENGTH, + SizeMeasure::Count => COUNT, + } + } + + /// How an operand of this measure reads the property it names. + fn read(self) -> PropertyRead { + match self { + SizeMeasure::Length | SizeMeasure::ByteLength => PropertyRead::Length, + SizeMeasure::Count => PropertyRead::Count, + } + } +} + /// An integer expression, one side of a rule or an operand inside one. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConstraintExpression { @@ -164,6 +205,10 @@ pub enum ConstraintExpression { /// for true, 0 for false), or `if_absent` when the document leaves it out: /// 0 for a path on its own, the declared value for an `ifAbsent` operand. Property { path: String, if_absent: i128 }, + /// `length`, `byteLength` or `count`: the size of the property at the + /// dotted `path`, as `measure` counts it, or 0 when the document leaves it + /// out. + Size { measure: SizeMeasure, path: String }, /// `add`: the sum of two or more operands. Add(Vec), /// `multiply`: the product of two or more operands. @@ -192,6 +237,9 @@ impl ConstraintExpression { /// fractional part as an integer, which the document could not be stored /// with anyway) that fits an `i128` /// ([`PropertyConstraintViolation::Overflow`] otherwise); + /// * a size is never a fault: a property the document leaves out, or sets + /// to null, has size 0, and so does a value of another type than the + /// one measured, which the schema validation reported first refuses; /// * `add` and `multiply` fold their operands from the left, so an overflow /// on the way is a fault even when a later operand would bring the result /// back in range; @@ -209,6 +257,11 @@ impl ConstraintExpression { ConstraintExpression::Property { path, if_absent } => { property_value(data, path, *if_absent) } + ConstraintExpression::Size { measure, path } => { + // A size fits a `usize`, which always fits an `i128` + i128::try_from(property_size(data, path, *measure)) + .map_err(|_| PropertyConstraintViolation::Overflow) + } ConstraintExpression::Add(operands) => { operands.iter().try_fold(0i128, |sum, operand| { sum.checked_add(operand.evaluate(data)?) @@ -260,7 +313,9 @@ impl ConstraintExpression { /// The nodes of the expression: this one, and those of its operands. pub fn node_count(&self) -> usize { 1 + match self { - ConstraintExpression::Value(_) | ConstraintExpression::Property { .. } => 0, + ConstraintExpression::Value(_) + | ConstraintExpression::Property { .. } + | ConstraintExpression::Size { .. } => 0, ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { operands.iter().map(ConstraintExpression::node_count).sum() } @@ -275,7 +330,7 @@ impl ConstraintExpression { fn reads_property(&self) -> bool { match self { ConstraintExpression::Value(_) => false, - ConstraintExpression::Property { .. } => true, + ConstraintExpression::Property { .. } | ConstraintExpression::Size { .. } => true, ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { operands.iter().any(ConstraintExpression::reads_property) } @@ -288,12 +343,13 @@ impl ConstraintExpression { } } - /// Appends the properties the expression reads, each by its value, to - /// `reads`, in the order it reads them. + /// Appends the properties the expression reads, each by its value or its + /// size, to `reads`, in the order it reads them. fn collect_property_reads<'a>(&'a self, reads: &mut Vec<(&'a str, PropertyRead)>) { match self { ConstraintExpression::Value(_) => {} ConstraintExpression::Property { path, .. } => reads.push((path, PropertyRead::Value)), + ConstraintExpression::Size { measure, path } => reads.push((path, measure.read())), ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { for operand in operands { operand.collect_property_reads(reads); @@ -323,6 +379,10 @@ pub enum PropertyRead { /// By its value, compared with identifier constants: an identifier /// property. Identifier, + /// By its size, in a `length` or `byteLength` operand: a string property. + Length, + /// By its size, in a `count` operand: an array or byte array property. + Count, } /// What a comparison of equality compares when it is not integers: strings or @@ -1525,6 +1585,21 @@ fn parse_expression( } ConstraintExpression::Power(Box::new(base), Box::new(exponent)) } + // What the path names is checked against the parsed document type + LENGTH | BYTE_LENGTH | COUNT => { + let Some(path) = operands.as_text() else { + return Err(format!("at {at} must name a property path")); + }; + let measure = match key { + LENGTH => SizeMeasure::Length, + BYTE_LENGTH => SizeMeasure::ByteLength, + _ => SizeMeasure::Count, + }; + ConstraintExpression::Size { + measure, + path: path.to_string(), + } + } CONST => { at.truncate(parent); return Err(format!( @@ -1653,6 +1728,27 @@ fn property_value( } } +/// The size of the property at `path` in `data`, as `measure` counts it: 0 +/// when the document leaves it out or sets it to null, and for a value of +/// another type than `measure` reads, which the schema validation reported +/// before the rules refuses. A byte array counts its bytes, whichever form the +/// document gives them in. +fn property_size(data: &Value, path: &str, measure: SizeMeasure) -> usize { + let Ok(Some(value)) = data.get_optional_value_at_path(path) else { + return 0; + }; + match (measure, value) { + (SizeMeasure::Length, Value::Text(text)) => text.chars().count(), + (SizeMeasure::ByteLength, Value::Text(text)) => text.len(), + (SizeMeasure::Count, Value::Array(items)) => items.len(), + (SizeMeasure::Count, Value::Bytes(bytes)) => bytes.len(), + (SizeMeasure::Count, Value::Bytes20(_)) => 20, + (SizeMeasure::Count, Value::Bytes32(_) | Value::Identifier(_)) => 32, + (SizeMeasure::Count, Value::Bytes36(_)) => 36, + _ => 0, + } +} + /// `base` to the power `exponent`, exactly. An exponent too large for /// `checked_pow` leaves only the bases 0, 1 and -1 in range. fn power(base: i128, exponent: i128) -> Result { diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index fd4edbb2ed2..e5191e8f379 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -1987,3 +1987,217 @@ fn should_read_a_boolean_as_one_or_zero() { Some(PropertyConstraintViolation::NotMet) ); } + +// ── sizes ─────────────────────────────────────────────────────────────── + +/// `length`, `byteLength` and `count` are operands naming a property, one node +/// each, read by their size. +#[test] +fn should_parse_the_size_operands() { + for (key, measure, read) in [ + ("length", SizeMeasure::Length, PropertyRead::Length), + ("byteLength", SizeMeasure::ByteLength, PropertyRead::Length), + ("count", SizeMeasure::Count, PropertyRead::Count), + ] { + let rule = parse_rule_value(platform_value!({ + "lessThanOrEqual": [{ key: "meta.body" }, "limit"] + })); + assert_eq!( + rule, + PropertyConstraint::Compare { + comparison: ConstraintComparison::LessThanOrEqual, + left: ConstraintExpression::Size { + measure, + path: "meta.body".to_string(), + }, + right: property("limit"), + }, + "{key}" + ); + assert_eq!(measure.wire_name(), key); + assert_eq!(rule.node_count(), 3, "{key}"); + assert_eq!( + rule.property_reads(), + [("meta.body", read), ("limit", PropertyRead::Value)], + "{key}" + ); + } + + // A size reads a property, so it alone keeps a comparison with a literal + // meaningful, inside arithmetic and in an `in` too + let rule = parse_rule_value(platform_value!({ + "in": [{ "add": [{ "count": "tags" }, 1] }, [1, 2, 3]] + })); + assert_eq!(rule.property_reads(), [("tags", PropertyRead::Count)]); + assert_eq!(rule.node_count(), 7); + + // Two measures of one property are different conditions + let rules = parse(platform_value!({ + "rule": { + "anyOf": [ + { "lessThanOrEqual": [{ "length": "title" }, 10] }, + { "lessThanOrEqual": [{ "byteLength": "title" }, 10] } + ] + } + })) + .expect("parses"); + assert_eq!(rules["rule"].repeated_condition(), None); +} + +#[test] +fn should_refuse_a_malformed_size_operand() { + for (operand, needle) in [ + ( + platform_value!({ "length": 5 }), + "at lessThan[0].length must name a property path", + ), + ( + platform_value!({ "byteLength": ["title"] }), + "at lessThan[0].byteLength must name a property path", + ), + ( + platform_value!({ "count": { "add": ["a", 1] } }), + "at lessThan[0].count must name a property path", + ), + ( + platform_value!({ "size": "title" }), + "names \"size\", which is not one of add, subtract, multiply, divide, modulo, \ + power, ifAbsent, length, byteLength or count", + ), + ] { + expect_refusal( + platform_value!({ "rule": { "lessThan": [operand, 10] } }), + needle, + ); + } +} + +/// `length` counts characters, as `maxLength` does, and `byteLength` UTF-8 +/// bytes, as `maxBytes` does. +#[test] +fn should_measure_a_string_in_characters_and_in_bytes() { + for (text, characters, bytes) in [ + ("", 0, 0), + ("hello", 5, 5), + ("héllo", 5, 6), + ("日本", 2, 6), + ("👍🏽", 2, 8), + ] { + let values = data(&[("title", Value::Text(text.to_string()))]); + assert_eq!( + evaluate(platform_value!({ "length": "title" }), &values), + Ok(characters), + "{text:?}" + ); + assert_eq!( + evaluate(platform_value!({ "byteLength": "title" }), &values), + Ok(bytes), + "{text:?}" + ); + } +} + +/// `count` counts the items of an array, and the bytes of a byte array in every +/// form a document gives one in. +#[test] +fn should_count_the_items_of_an_array_and_the_bytes_of_a_byte_array() { + for (value, items) in [ + (Value::Array(vec![]), 0), + ( + Value::Array(vec![ + Value::Text("a".to_string()), + Value::Text("b".to_string()), + Value::Text("c".to_string()), + ]), + 3, + ), + (Value::Bytes(vec![7; 10]), 10), + (Value::Bytes20([7; 20]), 20), + (Value::Bytes32([7; 32]), 32), + (Value::Identifier([7; 32]), 32), + (Value::Bytes36([7; 36]), 36), + ] { + let values = data(&[("tags", value.clone())]); + assert_eq!( + evaluate(platform_value!({ "count": "tags" }), &values), + Ok(items), + "{value:?}" + ); + } +} + +/// A size never faults: a property left out or set to null has size 0, and so +/// does a value of another type, which the schema validation reported first +/// refuses. +#[test] +fn should_take_a_size_of_zero_for_a_property_left_out_or_of_another_type() { + let values = data(&[ + ("empty", Value::Null), + ("number", Value::U64(12345)), + ("title", Value::Text("hello".to_string())), + ("tags", Value::Array(vec![Value::U8(1), Value::U8(2)])), + ]); + for (expression, expected) in [ + (platform_value!({ "length": "missing" }), 0), + (platform_value!({ "byteLength": "empty" }), 0), + (platform_value!({ "count": "meta.missing" }), 0), + (platform_value!({ "length": "number" }), 0), + (platform_value!({ "length": "tags" }), 0), + (platform_value!({ "count": "title" }), 0), + (platform_value!({ "count": "number" }), 0), + ] { + assert_eq!( + evaluate(expression.clone(), &values), + Ok(expected), + "{expression:?}" + ); + } + + // A rule over a size left out holds or not as 0 says + let rule = parse_rule_value(platform_value!({ + "greaterThanOrEqual": [{ "count": "tags" }, 1] + })); + assert_eq!( + rule.violation(&data(&[]), None), + Some(PropertyConstraintViolation::NotMet) + ); +} + +/// A size compares with other properties: here a list holds at most as many +/// tags as its `maxTags`, and a free listing's title is short. +#[test] +fn should_compare_a_size_with_other_properties() { + let tags_within_limit = parse_rule_value(platform_value!({ + "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] + })); + let tags = |count: usize| Value::Array(vec![Value::Text("tag".to_string()); count]); + assert_eq!( + tags_within_limit.violation(&data(&[("tags", tags(2)), ("maxTags", Value::U8(3))]), None), + None + ); + assert_eq!( + tags_within_limit.violation(&data(&[("tags", tags(4)), ("maxTags", Value::U8(3))]), None), + Some(PropertyConstraintViolation::NotMet) + ); + + let short_title_when_free = parse_rule_value(platform_value!({ + "anyOf": [ + { "greaterThan": ["fee", 0] }, + { "lessThanOrEqual": [{ "length": "title" }, 5] } + ] + })); + let listing = + |fee: u64, title: &str| data(&[("fee", Value::U64(fee)), ("title", Value::from(title))]); + assert_eq!( + short_title_when_free.violation(&listing(0, "héllo"), None), + None + ); + assert_eq!( + short_title_when_free.violation(&listing(10, "a long title"), None), + None + ); + assert_eq!( + short_title_when_free.violation(&listing(0, "a long title"), None), + Some(PropertyConstraintViolation::NotMet) + ); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index cfa0a56e9cd..07a1b2a2c0c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -218,6 +218,44 @@ mod property_constraints_tests { }) } + /// An `offer` type with the integers [`set_valid_offer`] fills, a `title`, a + /// typed array of `tags` and a byte array `signature`, and four rules on + /// their sizes: `shortTitle` (at most 10 characters), `titleBytes` (at most + /// 12 UTF-8 bytes), `tagsPerUnit` (no more tags than the quantity) and + /// `signatureLength` (left out, or 64 or 65 bytes). + fn sized_offer_schema() -> Value { + platform_value!({ + "type": "object", + "properties": { + "price": { "type": "integer", "minimum": 0, "position": 0 }, + "fee": { "type": "integer", "minimum": 0, "position": 1 }, + "quantity": { "type": "integer", "minimum": 0, "position": 2 }, + "deposit": { "type": "integer", "minimum": 0, "position": 3 }, + "title": { "type": "string", "maxLength": 40, "position": 4 }, + "tags": { + "type": "array", + "maxItems": 8, + "items": { "type": "string", "maxLength": 16 }, + "position": 5 + }, + "signature": { + "type": "array", + "byteArray": true, + "maxItems": 65, + "position": 6 + } + }, + "required": ["price", "fee", "quantity", "deposit"], + "propertyConstraints": { + "shortTitle": { "lessThanOrEqual": [{ "length": "title" }, 10] }, + "titleBytes": { "lessThanOrEqual": [{ "byteLength": "title" }, 12] }, + "tagsPerUnit": { "lessThanOrEqual": [{ "count": "tags" }, "quantity"] }, + "signatureLength": { "in": [{ "count": "signature" }, [0, 64, 65]] } + }, + "additionalProperties": false + }) + } + /// An offer that meets every rule: (100 + 10) * 2 = 220. fn set_valid_offer(document: &mut Document) { document.set("price", Value::U64(100)); @@ -1375,4 +1413,56 @@ mod property_constraints_tests { && e.violation() == PropertyConstraintViolation::NotMet ); } + + /// Sizes read by real creates: a title too long in characters, one short + /// enough in characters but too long in bytes, more tags than the quantity + /// and a signature of the wrong length are each refused with the rule they + /// break, and an offer meeting all four is stored. + #[tokio::test] + async fn should_judge_the_sizes_of_strings_arrays_and_byte_arrays() { + let mut fixture = OfferFixture::with_schema(sized_offer_schema()); + let tags = |count: usize| Value::Array(vec![Value::Text("tag".to_string()); count]); + + // 12 characters + let result = fixture + .create(|document| document.set("title", Value::from("a long title"))) + .await; + expect_violated(result, "shortTitle", PropertyConstraintViolation::NotMet); + + // 8 characters, 16 bytes + let result = fixture + .create(|document| document.set("title", Value::from("éééééééé"))) + .await; + expect_violated(result, "titleBytes", PropertyConstraintViolation::NotMet); + + // 3 tags for a quantity of 2 + let result = fixture + .create(|document| document.set("tags", tags(3))) + .await; + expect_violated(result, "tagsPerUnit", PropertyConstraintViolation::NotMet); + + // 10 bytes + let result = fixture + .create(|document| document.set("signature", Value::Bytes(vec![7; 10]))) + .await; + expect_violated( + result, + "signatureLength", + PropertyConstraintViolation::NotMet, + ); + assert!(fixture.stored_offers().is_empty()); + + // 4 characters in 5 bytes, 2 tags, a 64-byte signature + assert_matches!( + fixture + .create(|document| { + document.set("title", Value::from("Café")); + document.set("tags", tags(2)); + document.set("signature", Value::Bytes(vec![7; 64])); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 1); + } } diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 2fc70a41e01..fa2affdcfec 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1048,8 +1048,11 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// (`equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan`, /// `greaterThanOrEqual`) of two integer expressions built from integer /// literals, paths of integer or boolean properties (a boolean reading as -/// 1 for true and 0 for false) and `add`, `subtract`, `multiply`, -/// `divide`, `modulo` and `power`; `in`, whether an integer expression +/// 1 for true and 0 for false), `add`, `subtract`, `multiply`, +/// `divide`, `modulo` and `power`, and sizes: `length` and `byteLength`, +/// the characters and UTF-8 bytes of a string property, and `count`, the +/// items of an array or byte array property, each 0 for a property the +/// document leaves out; `in`, whether an integer expression /// takes one of two or more distinct integer values; `equal` or `notEqual` /// of a string property and a `{ "const": string }` or of two bare paths /// naming string properties, or `in` of a string property and two or more @@ -1073,7 +1076,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// fails), a fault in one that is checked refuses the document whatever the /// others say, and `not` never turns a fault into a pass, so an earlier /// condition guards a later one. The parser checks that every path an -/// operand reads names an integer or boolean property, every path compared +/// operand reads names an integer or boolean property, every path a +/// `length` or `byteLength` measures a string property, every path a +/// `count` counts an array or byte array property, every path compared /// with identifiers an identifier property, every path compared with /// strings a string property (whose `enum`, if it declares one, lists every /// constant it is compared with), and every path `present` or `absent` diff --git a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs index 43e9ed8adb5..560ff1b3c42 100644 --- a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs +++ b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs @@ -62,12 +62,13 @@ const PROPERTY_CONSTRAINTS_KEYWORD: &str = "propertyConstraints"; /// the rule's name (its key in `propertyConstraints`), the rule exactly as the /// document type's schema declares it, every property it reads in declared /// order (`kind` is `"value"` for an integer operand, `"presence"` for -/// `present` / `absent`, `"text"` for a string comparison and `"identifier"` -/// for an identifier comparison; `$ownerId` is no property and is not listed), -/// and whether it reads `$ownerId`, which makes a transfer or a purchase answer -/// to it too. Rules are listed in name order, the order consensus checks them -/// in. A document type declaring none gives `[]`, and so does every document -/// type when the SDK's protocol version is below 14. +/// `present` / `absent`, `"text"` for a string comparison, `"identifier"` for +/// an identifier comparison, `"length"` for a `length` or `byteLength` operand +/// and `"count"` for a `count` operand; `$ownerId` is no property and is not +/// listed), and whether it reads `$ownerId`, which makes a transfer or a +/// purchase answer to it too. Rules are listed in name order, the order +/// consensus checks them in. A document type declaring none gives `[]`, and so +/// does every document type when the SDK's protocol version is below 14. /// /// The contract is read from its platform serialization at the SDK's protocol /// version, as `dash_sdk_add_known_contracts` reads it, without re-validating @@ -358,6 +359,8 @@ fn read_kind_name(read: PropertyRead) -> &'static str { PropertyRead::Presence => "presence", PropertyRead::Text => "text", PropertyRead::Identifier => "identifier", + PropertyRead::Length => "length", + PropertyRead::Count => "count", } } diff --git a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs index b5d5c827a37..d2457e3e409 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs @@ -2,8 +2,8 @@ //! created or replaced document's properties to, from protocol version 14 //! onward. //! -//! A rule is a condition: a comparison of integer expressions, a membership -//! test (`in`), a comparison of a string or an identifier property (or +//! A rule is a condition: a comparison of integer expressions (sizes of +//! strings and arrays included), a membership test (`in`), a comparison of a string or an identifier property (or //! `$ownerId`, the document's owner) with constants or with another property //! of its kind, a presence test (`present`, `absent`), or `anyOf`, `allOf` or //! `not` over conditions. Consensus evaluates every rule on each create and @@ -38,7 +38,11 @@ const DOCUMENT_PROPERTY_CONSTRAINTS_TS: &'static str = r#" * - `ifAbsent`: a property path and the integer it takes when left out; * - `add` and `multiply` over two or more operands, `subtract`, `divide`, * `modulo` and `power` over exactly two. Arithmetic is exact over 128-bit - * integers; `divide` and `modulo` are Euclidean. + * integers; `divide` and `modulo` are Euclidean; + * - `length` and `byteLength`: the characters (as `maxLength` counts them) + * and the UTF-8 bytes of a string property; `count`: the items of an array + * property, or the bytes of a byte array property. Each is 0 when the + * document leaves the property out. */ export type PropertyConstraintExpression = | number @@ -50,7 +54,10 @@ export type PropertyConstraintExpression = | { subtract: [PropertyConstraintExpression, PropertyConstraintExpression] } | { divide: [PropertyConstraintExpression, PropertyConstraintExpression] } | { modulo: [PropertyConstraintExpression, PropertyConstraintExpression] } - | { power: [PropertyConstraintExpression, PropertyConstraintExpression] }; + | { power: [PropertyConstraintExpression, PropertyConstraintExpression] } + | { length: string } + | { byteLength: string } + | { count: string }; /** * One side of a comparison of strings or identifiers. @@ -97,9 +104,16 @@ export type PropertyConstraintCondition = /** * How a rule reads a property: `value` as an integer operand, `presence` in * `present` or `absent`, `text` compared with strings, `identifier` compared - * with identifiers. + * with identifiers, `length` by the size of a string (`length` or + * `byteLength`), `count` by the items of an array or byte array. */ -export type PropertyConstraintReadKind = 'value' | 'presence' | 'text' | 'identifier'; +export type PropertyConstraintReadKind = + | 'value' + | 'presence' + | 'text' + | 'identifier' + | 'length' + | 'count'; /** * A single `propertyConstraints` rule of a document type. @@ -169,6 +183,8 @@ fn read_kind_name(read: PropertyRead) -> &'static str { PropertyRead::Presence => "presence", PropertyRead::Text => "text", PropertyRead::Identifier => "identifier", + PropertyRead::Length => "length", + PropertyRead::Count => "count", } } diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts index b18dae30a63..6352f9153ce 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts @@ -167,6 +167,63 @@ describe('DataContract: propertyConstraints (v14)', () => { expect(byType.get('offer')).to.have.length(5); }); + it('should report sizes as length and count reads, and check them', () => { + const rules = { + titleBytes: { lessThanOrEqual: [{ byteLength: 'title' }, 12] }, + tagsWithinLimit: { lessThanOrEqual: [{ count: 'tags' }, 'maxTags'] }, + }; + const contract = buildContract({ + listing: { + type: 'object', + properties: { + title: { type: 'string', maxLength: 40, position: 0 }, + tags: { + type: 'array', + maxItems: 8, + items: { type: 'string', maxLength: 16 }, + position: 1, + }, + maxTags: { type: 'integer', minimum: 0, maximum: 8, position: 2 }, + }, + additionalProperties: false, + propertyConstraints: rules, + }, + }); + + expect(contract.documentTypePropertyConstraints('listing')).to.deep.equal([ + { + name: 'tagsWithinLimit', + rule: rules.tagsWithinLimit, + reads: [{ path: 'tags', kind: 'count' }, { path: 'maxTags', kind: 'value' }], + readsOwner: false, + }, + { + name: 'titleBytes', + rule: rules.titleBytes, + reads: [{ path: 'title', kind: 'length' }], + readsOwner: false, + }, + ]); + + const listing = (properties: Record) => new wasm.Document({ + properties, + documentTypeName: 'listing', + dataContractId: contract.id, + ownerId, + revision: BigInt(1), + }); + expect(contract.checkDocumentPropertyConstraints( + listing({ title: 'Café', tags: ['a', 'b'], maxTags: 2 }), + )).to.equal(undefined); + expect(contract.checkDocumentPropertyConstraints( + listing({ title: 'Café', tags: ['a', 'b', 'c'], maxTags: 2 }), + )).to.deep.include({ rule: 'tagsWithinLimit', violation: 'NotMet' }); + // 8 characters, 16 bytes + expect(contract.checkDocumentPropertyConstraints( + listing({ title: 'éééééééé' }), + )).to.deep.include({ rule: 'titleBytes', violation: 'NotMet' }); + }); + it('should report integer literals past Number.MAX_SAFE_INTEGER exactly, as bigint', () => { const big = 9007199254740993n; // 2 ** 53 + 1, which a number rounds const rules = { From 81ee8df291428d04a7b8bc44ba888495bfa3da2a Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 04:05:39 +0700 Subject: [PATCH 053/113] feat(platform)!: creation, update and transfer times and heights in propertyConstraints rules (PV14) (#5078) Co-authored-by: Claude Opus 5.5 --- .../contract-keywords/property-constraints.md | 35 +- book/src/data-model/documents.md | 9 +- packages/js-evo-sdk/README.md | 4 +- .../document/v3/document-meta.json | 8 +- .../class_methods/try_from_schema/mod.rs | 21 +- .../v3/property_constraints_tests.rs | 147 ++++- .../try_from_schema/v3/typed_array_tests.rs | 15 +- .../document_type/methods/mod.rs | 41 +- .../methods/versioned_methods.rs | 30 +- .../document_type/property_constraints/mod.rs | 358 +++++++++++- .../property_constraints/tests.rs | 521 +++++++++++++++--- .../methods/validate_document/mod.rs | 7 +- .../methods/validate_document/v0/mod.rs | 43 +- .../v0/mod.rs | 12 +- .../advanced_structure_v0/mod.rs | 3 +- .../advanced_structure_v1/mod.rs | 3 +- .../advanced_structure_v0/mod.rs | 3 +- .../advanced_structure_v0/mod.rs | 13 +- .../advanced_structure_v0/mod.rs | 20 +- .../advanced_structure_v0/mod.rs | 13 +- .../advanced_structure_v0/mod.rs | 20 +- .../tests/document/property_constraints.rs | 236 +++++++- .../rs-platform-version/src/version/v14.rs | 113 ++-- .../src/data_contract/property_constraints.rs | 132 ++++- .../platform/moderation_charters/requests.rs | 3 +- .../document_type_property_constraints.rs | 61 +- packages/wasm-dpp2/src/data_contract/model.rs | 8 +- .../unit/DocumentPropertyConstraints.spec.ts | 59 ++ 28 files changed, 1670 insertions(+), 268 deletions(-) diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index 65e102bf28a..96af5a85356 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -49,9 +49,10 @@ - **Create and replace.** The rules run after the JSON schema validation of the document's properties (and after [maxBytes](max-bytes.md)), so every value a rule reads has passed its property's schema. A replace is judged on the whole new document, not only on what changed. - **Name order, first failure.** Rules are checked in the order of their names, and the first rule the document breaks refuses the transition with `DocumentPropertyConstraintViolatedError` (10422). The error names the document type, the rule, and why it failed (below). -- **Transfer and purchase.** These change only the owner. Rules that read `$ownerId` are judged again, against the stored document and its new owner; other rules are not, since nothing they read changed. A transfer or purchase that would break an owner rule is refused with 10422. -- **Price updates and deletes** are not judged, with one exception: a delete of an [index-only](index-only.md) document carries the row's values, which are validated like a create's, rules included. The delete does not carry the owner, which is why an index-only type may not have a rule reading `$ownerId`. -- **No state, no fee.** A rule reads only the document and its owner. It changes nothing stored and adds no fee; the limits below bound its cost. SDKs that validate a document before sending it apply the same rules. +- **Transfer and purchase.** These change only the owner and the transfer's time and heights. Rules that read `$ownerId` or `$transferredAt…` are judged again, against the stored document with its new owner and transfer values; other rules are not, since nothing they read changed. A transfer or purchase that would break such a rule is refused with 10422. +- **Price updates** change only the update's time and heights, so the rules that read `$updatedAt…` are judged again the same way; other rules are not. +- **Deletes** are not judged, with one exception: a delete of an [index-only](index-only.md) document carries the row's values, which are validated like a create's, rules included. The delete carries neither the owner nor any time or height, which is why an index-only type may not have a rule reading `$ownerId` or a system time or height. +- **No state, no fee.** A rule reads only the document, its owner and its times and heights. It changes nothing stored and adds no fee; the limits below bound its cost. SDKs that validate a document before sending it apply the same rules. Why a rule fails, as the error reports it: @@ -98,6 +99,7 @@ An integer expression is one of: | `power` | `{ "power": [a, b] }` | `a` to the power `b` | | `length`, `byteLength` | `{ "length": "title" }` | The characters (as `maxLength` counts them) or UTF-8 bytes (as `maxBytes` counts them) of a string property, 0 when the document leaves it out | | `count` | `{ "count": "tags" }` | The items of an array property, or the bytes of a byte array property, 0 when the document leaves it out | +| system time or height | `"$createdAt"`, `"$updatedAtBlockHeight"` | A time or height the document records (see [Times and heights](#times-and-heights)) | Where `maxLength`, `maxBytes` and `maxItems` bound one property by a fixed number, a size can be compared with another property or bounded only under a condition: `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit. A size never breaks a rule by itself: a property left out or null has size 0, and so would a value of another type, which the schema validation refuses first. @@ -108,7 +110,7 @@ Two more forms appear only in string and identifier comparisons, never inside ar | `{ "const": "closed" }` | A string constant, or, compared with an identifier property or `$ownerId`, a base58 identifier | | `{ "ifAbsent": ["status", "open"] }` | A string property, read as the given string when the document leaves it out | -A bare JSON string is always a path and a bare JSON number always a value, so a constant string needs `{ "const": ... }`. The values an `in` lists are literals and need no wrapper. A path is a property name, or names joined by dots for a nested property (`"rewardSplit.leader"`); the only `$` name a rule accepts is `$ownerId`. +A bare JSON string is always a path and a bare JSON number always a value, so a constant string needs `{ "const": ... }`. The values an `in` lists are literals and need no wrapper. A path is a property name, or names joined by dots for a nested property (`"rewardSplit.leader"`); the only `$` names a rule accepts are `$ownerId` and the times and heights below. A `number` property (a float) cannot be read by a rule, which keeps every result exact. @@ -135,6 +137,24 @@ An identifier property compares in the same three ways: `{ "equal": ["paymentTok It is not a property: `present`, `absent` and integer expressions refuse it, and comparing it with itself is refused. On create and replace it is the writer. A transfer or purchase is judged with the new owner, as described in [How it works](#how-it-works). An [index-only](index-only.md) type may not declare a rule that reads it. +## Times and heights + +A rule can read when the document was created, last updated and last transferred, as an integer: + +| | block time (ms) | Platform block height | Core block height | +|---|---|---|---| +| creation | `$createdAt` | `$createdAtBlockHeight` | `$createdAtCoreBlockHeight` | +| last update: a create, a replace or a price update | `$updatedAt` | `$updatedAtBlockHeight` | `$updatedAtCoreBlockHeight` | +| last transfer: a create, a transfer or a purchase | `$transferredAt` | `$transferredAtBlockHeight` | `$transferredAtCoreBlockHeight` | + +- `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week from its creation. +- `{ "lessThanOrEqual": ["$updatedAt", "endsAt"] }` refuses a replace or a price update after the listing ends. +- `{ "lessThanOrEqual": ["$transferredAt", "endsAt"] }` refuses a transfer or a purchase after it ends. + +A rule may read one only when the document type records it by listing it in `required`, so every stored document holds it. None takes an `ifAbsent` default, `present` and `absent` refuse them, and an [index-only](index-only.md) type reads none. Each write is judged with the values the stored document ends up with: a create with its block's time and heights for all three events; a replace with the stored creation and transfer values and its block's as the update; a price update with its block's as the update; a transfer or a purchase with its block's as the transfer. + +SDK pre-checks run before the block exists: they use the device clock for the times a write records, and do not judge a rule reading a block height, which is unknown until the block. + ## Evaluation order and short-circuiting Conditions are checked in declared order and no further than the outcome needs. A comparison evaluates its left side, then its right. `anyOf` stops at the first condition that holds, `allOf` at the first that fails. Operands are evaluated left to right. @@ -162,7 +182,7 @@ The meta-schema checks the shape (`JsonSchemaError`, 10101): - every condition and every operator object has exactly one key; - a comparison, `subtract`, `divide`, `modulo` and `power` take exactly two operands; `add` and `multiply` two or more; `anyOf` and `allOf` two or more conditions, no two alike; an `in` two or more distinct values, all integers or all strings; - no `anyOf` or `allOf` holds its own kind directly, and no `not` holds a `not`; -- a path matches `$ownerId` or dotted names of 1 to 64 letters, digits or underscores, so `$createdAt` and other system properties are refused. +- a path matches `$ownerId`, one of the nine [times and heights](#times-and-heights), or dotted names of 1 to 64 letters, digits or underscores, so `$revision` and other system properties are refused. The parser then checks the rules against the document type (`InvalidContractStructure`, 10231): @@ -172,7 +192,8 @@ The parser then checks the rules against the document type (`InvalidContractStru - strings and identifiers are compared only with `equal`, `notEqual` and `in`; a string is never compared with an identifier; a property is never compared with itself; - string constants and `ifAbsent` defaults are in the property's `enum` when it has one; identifier constants are base58 identifiers of 32 bytes; - no literal divisor is 0 and no literal exponent is negative; -- `present` and `absent` do not name `$ownerId`, and an index-only type has no rule reading it; +- every time or height a rule reads is one the type lists in `required`, and takes no `ifAbsent` default; +- `present` and `absent` do not name `$ownerId` or a time or height, and an index-only type has no rule reading any of them; - no `anyOf` or `allOf` lists two conditions that parse alike, such as `1` and `1.0`, or two `in` conditions listing the same values in another order; - no condition or operand nests more than 64 levels deep. @@ -192,7 +213,7 @@ A rule within 32 nodes is never deep enough to reach the 64-level bound. Nodes a | `present`, `absent` | 1 | | `anyOf`, `allOf` | 1, plus their conditions | | `not` | 1, plus its condition | -| An integer, a path, an `ifAbsent` or a size (`length`, `byteLength`, `count`) | 1 | +| An integer, a path, an `ifAbsent`, a size (`length`, `byteLength`, `count`) or a time or height | 1 | | `add`, `multiply`, `subtract`, `divide`, `modulo`, `power` | 1, plus their operands | `depositCoversOrder` above is 7 nodes (the comparison, `multiply`, `add` and four paths), and `closedNeedsClosedAt` is 5. An `in` fits up to 30 values in 32 nodes. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index b70e0eabac4..9d871d794eb 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -725,7 +725,8 @@ Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan - `{ "ifAbsent": [path, value] }`, the property's value, or `value` when the document leaves it out (an integer value here; a string value gives a string property a default in a string comparison instead); - `{ "add": [...] }` or `{ "multiply": [...] }` over two or more operands; - `{ "subtract": [a, b] }`, `{ "divide": [a, b] }`, `{ "modulo": [a, b] }` or `{ "power": [a, b] }`; -- a size: `{ "length": path }`, the characters of a string property (counted as `maxLength` counts them), `{ "byteLength": path }`, its UTF-8 bytes (as `maxBytes` counts them), or `{ "count": path }`, the items of an array property or the bytes of a byte array property. Where `maxLength`, `maxBytes` and `maxItems` bound one property by a fixed number, a size can be compared with another property or bounded only under a condition: `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit, and `{ "anyOf": [{ "greaterThan": ["fee", 0] }, { "lessThanOrEqual": [{ "length": "title" }, 20] }] }` keeps a free listing's title short. A property the document leaves out or sets to null has size 0, and a size never breaks a rule by itself (a value of another type would read as 0 too, but the schema validation refuses it first). +- a size: `{ "length": path }`, the characters of a string property (counted as `maxLength` counts them), `{ "byteLength": path }`, its UTF-8 bytes (as `maxBytes` counts them), or `{ "count": path }`, the items of an array property or the bytes of a byte array property. Where `maxLength`, `maxBytes` and `maxItems` bound one property by a fixed number, a size can be compared with another property or bounded only under a condition: `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit, and `{ "anyOf": [{ "greaterThan": ["fee", 0] }, { "lessThanOrEqual": [{ "length": "title" }, 20] }] }` keeps a free listing's title short. A property the document leaves out or sets to null has size 0, and a size never breaks a rule by itself (a value of another type would read as 0 too, but the schema validation refuses it first); +- a system time or height: `"$createdAt"`, `"$updatedAt"` and `"$transferredAt"`, block times in milliseconds, and each with `BlockHeight` or `CoreBlockHeight` appended, the Platform and Core block heights: those of the document's creation, of its last create, replace or price update, and of its last create, transfer or purchase. A rule may read one only on a type that records it by listing it in `required`, so every stored document holds it; it takes no `ifAbsent`, and an indexOnly type, whose deletes carry none, reads none. `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week from its creation, and `{ "lessThanOrEqual": ["$updatedAt", "endsAt"] }` refuses a replace or a price update after it ends. A JSON number is always a value and a string always a path, so a property named `100` is not confused with the number, and the rule is a tree the meta-schema can check rather than a string with precedence rules to parse. Consensus holds nothing but this tree; an SDK may offer an infix spelling that compiles to it. @@ -733,11 +734,11 @@ The arithmetic is exact over `i128`. Operands are evaluated left to right, and e Conditions are checked in declared order and no further than the outcome needs: a comparison evaluates its left side, then its right; `anyOf` stops at the first condition that holds and `allOf` at the first that fails. A fault in a condition that is checked breaks the rule whatever the others would say, and `not` does not turn it into a pass. So an earlier condition guards a later one: `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` holds for a `b` of 0 without dividing by it, while the same two conditions the other way round divide by zero and break the rule. -The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type, every path `length` or `byteLength` measures a string property, every path `count` counts an array or byte array property, every path compared with a string names a string property (and every constant compared with one that declares an `enum` is one of its values) (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `const`, every `present` or `absent`, every arithmetic operator and every operand (a size is one), and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. +The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type, every path `length` or `byteLength` measures a string property, every path `count` counts an array or byte array property, every system time or height a rule reads is one the type lists in `required` (and an indexOnly type reads none), every path compared with a string names a string property (and every constant compared with one that declares an `enum` is one of its values) (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `const`, every `present` or `absent`, every arithmetic operator and every operand (a size is one), and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. -Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. `$ownerId` reads the writer, passed in by create and replace (`validate_document_properties` takes the owner; a client that does not know it passes `None`, and `$ownerId` then equals no identifier). Transfers and purchases change no property but the owner, so only the rules reading `$ownerId` are judged again, with the new owner (`DocumentTypeV0Methods::validate_property_constraints_for_new_owner`, next to the `distinctFrom` check); price updates change neither and are not judged. +Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. `$ownerId` and the system times and heights read the values of the document version being written (`validate_document_properties` takes them as `DocumentSystemValues`): on a create the writer and the block's time and heights, on a replace the writer, the stored creation and transfer values and the block's as the update. A client passes what it knows: an owner it does not know equals no identifier, and a rule reading a time or height it does not know is not judged. The SDK pre-checks use the device clock for the times a write records, and leave a rule reading a block height unjudged, since the height is unknown until the block. Transfers and purchases change no property, only the owner and the transfer's time and heights, so only the rules reading those are judged again, with the new values (`DocumentTypeV0Methods::validate_property_constraints_for_system_change`, next to the `distinctFrom` check). Price updates change only the update's time and heights, and are judged against the rules reading those the same way. -In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, an `in`, a string comparison (`TextCompare`, `TextIn`), a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and how: by value, by presence, by size (`Length`, `Count`) or in a comparison of strings or identifiers), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. +In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, an `in`, a string comparison (`TextCompare`, `TextIn`), a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and how: by value, by presence, by size (`Length`, `Count`) or in a comparison of strings or identifiers, and `system_reads` the system times and heights it reads), each rule's `holds` and `violation` evaluate it against a document's data and `DocumentSystemValues`, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. ## Rules and Guidelines diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 82b6e94d79d..1832771c498 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -425,7 +425,7 @@ From protocol version 14 a document type can declare rules its documents' proper } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0), or a size: `{ "length": path }` and `{ "byteLength": path }` give the characters and UTF-8 bytes of a string property, and `{ "count": path }` the items of an array or the bytes of a byte array, so `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit (a size read is reported with kind `length` or `count`). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0), or a size: `{ "length": path }` and `{ "byteLength": path }` give the characters and UTF-8 bytes of a string property, and `{ "count": path }` the items of an array or the bytes of a byte array, so `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit (a size read is reported with kind `length` or `count`). A type that lists `$createdAt`, `$updatedAt` or `$transferredAt` (or any of them with `BlockHeight` or `CoreBlockHeight` appended) in `required` may read it too: `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week, and since a price update sets `$updatedAt` and a transfer or purchase `$transferredAt`, each is judged against the rules reading those. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: @@ -441,7 +441,7 @@ try { } ``` -To find a broken rule before paying for a refused transition, a contract lists a document type's rules and checks a document against them with the code consensus runs. The check covers the rules alone, not the JSON schema, and reads the document's owner for `$ownerId`: +To find a broken rule before paying for a refused transition, a contract lists a document type's rules and checks a document against them with the code consensus runs. The check covers the rules alone, not the JSON schema. It reads the document's owner for `$ownerId`, and the device clock for the times the write will record (`readsSystem` lists the ones a rule reads); a rule reading a block height is not checked, since the height is unknown until the block: ```ts contract.documentTypePropertyConstraints('offer'); diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 1d4d3e65ae7..b80409fad81 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -136,7 +136,7 @@ "uniqueItems": true }, "propertyConstraintExpression": { - "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out, an integer, or a string for a string property compared with strings; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out; const, a string constant compared with a string property", + "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; a system time or height the document type records ($createdAt, $updatedAtBlockHeight, ...); or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out, an integer, or a string for a string property compared with strings; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out; const, a string constant compared with a string property", "type": [ "integer", "string", @@ -213,9 +213,9 @@ } }, "propertyConstraintPath": { - "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer or boolean property when an operand reads its value, a string property when length or byteLength measures it, an array or byte array property when count counts its items, any property when present or absent tests it. Or $ownerId, the document's owner, which only a comparison of identifiers reads", + "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer or boolean property when an operand reads its value, a string property when length or byteLength measures it, an array or byte array property when count counts its items, any property when present or absent tests it. Or $ownerId, the document's owner, which only a comparison of identifiers reads, or a system time or height an integer operand reads ($createdAt, $updatedAt, $transferredAt, and each with BlockHeight or CoreBlockHeight appended), one the document type lists in required", "type": "string", - "pattern": "^(\\$ownerId|[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*)$" + "pattern": "^(\\$ownerId|\\$(created|updated|transferred)At(BlockHeight|CoreBlockHeight)?|[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*)$" }, "propertyConstraintOperands": { "type": "array", @@ -2080,7 +2080,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out ({ \"lessThanOrEqual\": [{ \"count\": \"tags\" }, \"maxTags\"] }). A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property length or byteLength measures a string property, every property count counts an array or byte array property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand, a size included; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, a system time or height the document type records by listing it in required ($createdAt, $updatedAt and $transferredAt, block times in milliseconds, and each with BlockHeight or CoreBlockHeight appended, the Platform and Core block heights: those of the create, of the last create, replace or price update, and of the last create, transfer or purchase), or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out ({ \"lessThanOrEqual\": [{ \"count\": \"tags\" }, \"maxTags\"] }). A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property length or byteLength measures a string property, every property count counts an array or byte array property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand, a size included; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. Likewise a transfer or a purchase is judged against the rules reading the transfer's time and heights, and a price update against those reading the update's, since each sets them; an indexOnly type reads no system time or height. The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index c13c0486e81..c0245710302 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -1883,7 +1883,9 @@ pub(super) fn validate_encrypted_for_declarations( /// flattened map names it); by its `length` or `byteLength`, a string /// property; by its `count`, an array or byte array property; by its presence, /// a property of any type, an object included; any way one that is neither -/// transient nor inside a transient object. A +/// transient nor inside a transient object. A system time or height a rule +/// reads (`$createdAt`, ...) must be one the type records, listed in +/// `required`, and an indexOnly type reads none, nor `$ownerId`. A /// transient value is never stored, so a stored document could not be held to /// a rule reading one. The declaration's shape ([`parse_property_constraints`]) and these reads are /// checked on every parse; under full validation, the limits too: at most @@ -2143,6 +2145,23 @@ fn apply_property_constraints_v0( does not carry" ))); } + for system_property in constraint.system_reads() { + let system_name = system_property.name(); + // Nor its times and heights + if document_type.index_only { + return Err(structure_error(format!( + "rule \"{name}\" reads {system_name}, which a delete of an indexOnly \ + document does not carry" + ))); + } + // A stored document holds only the times and heights its type requires + if !document_type.required_fields.contains(system_name) { + return Err(structure_error(format!( + "rule \"{name}\" reads {system_name}, which the document type does not \ + record: list it in required" + ))); + } + } // A constant or a default a string property's `enum` does not list is a // typo: the property could never hold it for (path, constant) in constraint.text_constants() { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index ecbd320f82a..52aa7cc85ca 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -13,7 +13,9 @@ use crate::consensus::basic::basic_error::BasicError; use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; -use crate::data_contract::document_type::property_constraints::{PropertyConstraint, PropertyRead}; +use crate::data_contract::document_type::property_constraints::{ + DocumentSystemValues, PropertyConstraint, PropertyRead, SystemProperty, +}; use crate::data_contract::methods::validate_update::DataContractUpdateValidationMethodsV0; use crate::data_contract::DataContract; use crate::serialization::{ @@ -667,6 +669,11 @@ fn should_compare_identifier_properties_that_declare_refers_to() { "sellerId": Identifier::new([9; 32]), }) }; + // The system values of an order owned by `owner`, no time or height + let owned = |owner: Option| DocumentSystemValues { + owner_id: owner, + ..Default::default() + }; for (name, owner, holds_for_token, holds_for_other) in [ ("boughtWithToken", None, true, false), ("buyerIsNotSeller", None, true, true), @@ -674,12 +681,12 @@ fn should_compare_identifier_properties_that_declare_refers_to() { ("knownBuyer", None, true, true), ] { assert_eq!( - constraints[name].holds(&order(token), owner), + constraints[name].holds(&order(token), &owned(owner)), Ok(holds_for_token), "{name}" ); assert_eq!( - constraints[name].holds(&order(other), owner), + constraints[name].holds(&order(other), &owned(owner)), Ok(holds_for_other), "{name}" ); @@ -687,7 +694,7 @@ fn should_compare_identifier_properties_that_declare_refers_to() { // The seller's id as the buyer's: listed nowhere, and equal to the seller for name in ["knownBuyer", "buyerIsNotSeller"] { assert_eq!( - constraints[name].holds(&order(Identifier::new([9; 32])), None), + constraints[name].holds(&order(Identifier::new([9; 32])), &owned(None)), Ok(false), "{name}" ); @@ -723,8 +730,10 @@ fn should_compare_identifier_properties_that_declare_refers_to() { } } -/// `$ownerId` is the document's owner, which every document has, not a -/// property of the type: a presence test of it is refused on both paths, and +/// `$ownerId` and the system times and heights are no properties of the type: +/// every document has an owner, and a type that records a time holds it on +/// every document. A presence test of one is refused on both paths (the +/// meta-schema admits them as paths, for comparisons and operands), and one of /// any other system property the meta-schema refuses when registering. #[test] fn should_refuse_a_presence_test_of_a_system_property() { @@ -737,7 +746,18 @@ fn should_refuse_a_presence_test_of_a_system_property() { "tests the presence of \"$ownerId\", which is not a property of the document type", ); } - let rules = json!({ "rule": { "present": "$createdAt" } }); + // The meta-schema admits a system time or height as a path, for an operand to + // read, and the parser refuses a presence test of one on both paths + for full_validation in [true, false] { + expect_structure_error( + parse_order( + json!({ "rule": { "absent": "$createdAt" } }), + full_validation, + ), + "tests the presence of \"$createdAt\", which is not a property of the document type", + ); + } + let rules = json!({ "rule": { "present": "$revision" } }); let registered = parse_order(rules.clone(), true); assert!( registered.as_ref().is_err_and(is_json_schema_error), @@ -745,7 +765,7 @@ fn should_refuse_a_presence_test_of_a_system_property() { ); expect_structure_error( parse_order(rules, false), - "tests the presence of \"$createdAt\", which is not a property of the document type", + "tests the presence of \"$revision\", which is not a property of the document type", ); } @@ -850,7 +870,11 @@ fn should_refuse_a_rule_reading_anything_but_an_integer_property() { ), ( "$createdAt", - "reads \"$createdAt\", which is not an integer or boolean property", + "reads $createdAt, which the document type does not record: list it in required", + ), + ( + "$revision", + "reads \"$revision\", which is not an integer or boolean property", ), ] { for full_validation in [true, false] { @@ -859,8 +883,13 @@ fn should_refuse_a_rule_reading_anything_but_an_integer_property() { full_validation, ); // The meta-schema refuses a `$` in a path when registering, but for - // `$ownerId`, which only a comparison of identifiers may read - if full_validation && operand.starts_with('$') && operand != "$ownerId" { + // `$ownerId`, which only a comparison of identifiers may read, and the + // system times and heights an operand may read + if full_validation + && operand.starts_with('$') + && operand != "$ownerId" + && SystemProperty::from_name(operand).is_none() + { assert!( result.as_ref().is_err_and(is_json_schema_error), "{operand}: {result:?}" @@ -1441,3 +1470,99 @@ fn should_hold_a_size_to_the_property_it_measures() { } } } + +/// A rule reads a system time or height the type records, by listing it in +/// `required`, on both paths; one the type does not record is refused, and so +/// is any on an indexOnly type, whose deletes carry none. +#[test] +fn should_read_the_system_times_and_heights_the_type_records() { + let rules = json!({ + "depositAfterCreation": { "greaterThan": ["deposit", "$createdAt"] }, + "pricedAboveHeight": { "lessThan": ["$updatedAtBlockHeight", "price"] }, + "transferredOnCore": { "notEqual": ["$transferredAtCoreBlockHeight", 0] } + }); + let recording = |required: &[&str]| { + let mut schema = order_schema(Some(rules.clone()), None); + let mut names = vec!["price", "fee", "quantity", "deposit"]; + names.extend_from_slice(required); + schema["required"] = json!(names); + schema_value(schema) + }; + for full_validation in [true, false] { + let document_type = parse_dispatched( + recording(&[ + "$createdAt", + "$updatedAtBlockHeight", + "$transferredAtCoreBlockHeight", + ]), + PlatformVersion::latest(), + full_validation, + ) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let constraints = document_type.property_constraints(); + assert_eq!( + constraints["depositAfterCreation"].system_reads(), + [SystemProperty::CreatedAt] + ); + assert_eq!( + constraints["depositAfterCreation"].property_paths(), + ["deposit"] + ); + assert_eq!( + constraints["pricedAboveHeight"].system_reads(), + [SystemProperty::UpdatedAtBlockHeight] + ); + assert_eq!( + constraints["transferredOnCore"].system_reads(), + [SystemProperty::TransferredAtCoreBlockHeight] + ); + + // Not recording `$updatedAtBlockHeight`, the type may not read it + expect_structure_error( + parse_dispatched( + recording(&["$createdAt", "$transferredAtCoreBlockHeight"]), + PlatformVersion::latest(), + full_validation, + ), + "rule \"pricedAboveHeight\" reads $updatedAtBlockHeight, which the document type \ + does not record: list it in required", + ); + } + + let index_only = schema_value(json!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "indices": [{ + "name": "byTopic", + "properties": [{ "topic": "asc" }, { "until": "asc" }] + }], + "properties": { + "topic": { "type": "string", "maxLength": 50, "position": 0 }, + "until": { "type": "integer", "minimum": 0, "position": 1 } + }, + "required": ["topic", "until", "$createdAt"], + "additionalProperties": false, + "propertyConstraints": { "rule": { "lessThan": ["$createdAt", "until"] } } + })); + for full_validation in [true, false] { + expect_structure_error( + parse_dispatched( + index_only.clone(), + PlatformVersion::latest(), + full_validation, + ), + "rule \"rule\" reads $createdAt, which a delete of an indexOnly document does not \ + carry", + ); + } + + // The meta-schema admits exactly the nine names + for name in ["$createdAtHeight", "$deletedAt", "$updatedAtCoreHeight"] { + let registered = parse_order(json!({ "rule": { "lessThan": [name, "price"] } }), true); + assert!( + registered.as_ref().is_err_and(is_json_schema_error), + "{name}: the meta-schema should refuse it, got {registered:?}" + ); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs index 246e6fe4da2..776eb93d828 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs @@ -16,6 +16,7 @@ use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::data_contract::document_type::array::{ArrayItemConstraints, TypedArrayProperty}; +use crate::data_contract::document_type::property_constraints::DocumentSystemValues; use crate::data_contract::document_type::{ ByteArrayPropertySizes, DocumentPropertyType, StringPropertySizes, }; @@ -986,7 +987,12 @@ fn should_refuse_a_document_whose_typed_array_breaks_its_schema() { let contract = charter_contract(platform_version); contract - .validate_document_properties("charter", charter_properties(), None, platform_version) + .validate_document_properties( + "charter", + charter_properties(), + &DocumentSystemValues::default(), + platform_version, + ) .map(|result| assert!(result.is_valid(), "the base document is valid: {result:?}")) .expect("validation runs"); @@ -1023,7 +1029,12 @@ fn should_refuse_a_document_whose_typed_array_breaks_its_schema() { .expect("property applies"); let result = contract - .validate_document_properties("charter", properties, None, platform_version) + .validate_document_properties( + "charter", + properties, + &DocumentSystemValues::default(), + platform_version, + ) .expect("validation returns a consensus result, never an error"); let Some(ConsensusError::BasicError(BasicError::JsonSchemaError(error))) = diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index 5e82283ec7e..c864b2822f0 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -21,6 +21,9 @@ use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; use crate::data_contract::document_type::methods::versioned_methods::DocumentTypeV0MethodsVersioned; +use crate::data_contract::document_type::property_constraints::{ + DocumentSystemValues, SystemChange, +}; #[cfg(feature = "validation")] use crate::data_contract::document_type::{DocumentPropertyType, StringPropertySizes}; use crate::fee::Credits; @@ -686,9 +689,10 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe /// `DocumentPropertyConstraintViolatedError` (10422), naming the rule and why (the rule /// does not hold, or evaluating it overflowed, divided by zero, raised to a negative /// power or read a value that is not an integer). A property the document - /// leaves out counts as 0, or as its `ifAbsent` value, and `$ownerId` reads - /// `owner_id`, the document's owner (`None` when the caller does not know it, which - /// `$ownerId` then equals no identifier for). Reads the properties and the owner alone: + /// leaves out counts as 0, or as its `ifAbsent` value; `$ownerId` and the system + /// times and heights read `system`, the values of the document version being written + /// (an owner the caller does not know equals no identifier, and a rule reading a time + /// or height it does not know is not judged). Reads the properties and `system` alone: /// `DataContract::validate_document_properties` runs it after the schema validation, /// so document create and replace, and every client validating a document, apply it. /// @@ -698,7 +702,7 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe fn validate_property_constraints( &self, data: &Value, - owner_id: Option, + system: &DocumentSystemValues, platform_version: &PlatformVersion, ) -> Result where @@ -712,7 +716,7 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe .validate_property_constraints { None => Ok(SimpleConsensusValidationResult::default()), - Some(0) => Ok(self.validate_property_constraints_v0(data, owner_id)), + Some(0) => Ok(self.validate_property_constraints_v0(data, system)), Some(version) => Err(ProtocolError::UnknownVersionMismatch { method: "validate_property_constraints".to_string(), known_versions: vec![0], @@ -722,19 +726,24 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe } /// Judges a stored document's properties, `data`, against the rules of the document - /// type's `propertyConstraints` that read `$ownerId`, with `new_owner_id` as the owner, - /// in name order: a transfer or a purchase gives the document a new owner and changes - /// nothing else, so these are the only rules it can break, and the first broken fails - /// with `DocumentPropertyConstraintViolatedError` (10422) as it would on a write. The - /// document type's other rules held when the document was written and still do. A type - /// with no rule reading `$ownerId` costs nothing, and its data is not copied. + /// type's `propertyConstraints` that `change` can break, with `system` the document's + /// system values after it, in name order. A transfer or a purchase gives the document a + /// new owner and a new transfer time and heights, and a price update a new update time + /// and heights; neither changes a property, so the rules reading what it changes are + /// the only ones it can break ([`PropertyConstraint::reads_change`]), and the first + /// broken fails with `DocumentPropertyConstraintViolatedError` (10422) as it would on a + /// write. The document type's other rules held when the document was written and still + /// do. A type with no such rule costs nothing, and its data is not copied. /// /// Versioned with [`Self::validate_property_constraints`]: `None` before protocol /// version 14, where no parsed document type carries a rule. - fn validate_property_constraints_for_new_owner( + /// + /// [`PropertyConstraint::reads_change`]: crate::data_contract::document_type::property_constraints::PropertyConstraint::reads_change + fn validate_property_constraints_for_system_change( &self, data: &BTreeMap, - new_owner_id: Identifier, + system: &DocumentSystemValues, + change: SystemChange, platform_version: &PlatformVersion, ) -> Result where @@ -748,9 +757,11 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe .validate_property_constraints { None => Ok(SimpleConsensusValidationResult::default()), - Some(0) => Ok(self.validate_property_constraints_for_new_owner_v0(data, new_owner_id)), + Some(0) => { + Ok(self.validate_property_constraints_for_system_change_v0(data, system, change)) + } Some(version) => Err(ProtocolError::UnknownVersionMismatch { - method: "validate_property_constraints_for_new_owner".to_string(), + method: "validate_property_constraints_for_system_change".to_string(), known_versions: vec![0], received: version, }), diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs index 4d0cf0aa953..08f08178e5f 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs @@ -3,6 +3,9 @@ use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; +use crate::data_contract::document_type::property_constraints::{ + DocumentSystemValues, SystemChange, +}; use crate::data_contract::document_type::v0::DocumentTypeV0; use crate::data_contract::document_type::v1::DocumentTypeV1; use crate::data_contract::document_type::v2::DocumentTypeV2; @@ -850,13 +853,13 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa fn validate_property_constraints_v0( &self, data: &Value, - owner_id: Option, + system: &DocumentSystemValues, ) -> SimpleConsensusValidationResult where Self: DocumentTypeV2Getters, { for (name, constraint) in self.property_constraints() { - if let Some(violation) = constraint.violation(data, owner_id) { + if let Some(violation) = constraint.violation(data, system) { return SimpleConsensusValidationResult::new_with_error( DocumentPropertyConstraintViolatedError::new( self.name().clone(), @@ -870,29 +873,30 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa SimpleConsensusValidationResult::default() } - /// `validate_property_constraints_for_new_owner` version 0: every rule of the document - /// type's `propertyConstraints` that reads `$ownerId` is evaluated against `data` with - /// `new_owner_id` as the owner, in name order, and the first one broken is reported. - /// The data is copied into a map value only when such a rule exists. - fn validate_property_constraints_for_new_owner_v0( + /// `validate_property_constraints_for_system_change` version 0: every rule of the + /// document type's `propertyConstraints` that `change` can break is evaluated against + /// `data` with `system`, in name order, and the first one broken is reported. The data + /// is copied into a map value only when such a rule exists. + fn validate_property_constraints_for_system_change_v0( &self, data: &BTreeMap, - new_owner_id: Identifier, + system: &DocumentSystemValues, + change: SystemChange, ) -> SimpleConsensusValidationResult where Self: DocumentTypeV2Getters, { - let mut owner_rules = self + let mut changed_rules = self .property_constraints() .iter() - .filter(|(_, constraint)| constraint.reads_owner()) + .filter(|(_, constraint)| constraint.reads_change(change)) .peekable(); - if owner_rules.peek().is_none() { + if changed_rules.peek().is_none() { return SimpleConsensusValidationResult::default(); } let data = Value::from(data.clone()); - for (name, constraint) in owner_rules { - if let Some(violation) = constraint.violation(&data, Some(new_owner_id)) { + for (name, constraint) in changed_rules { + if let Some(violation) = constraint.violation(&data, system) { return SimpleConsensusValidationResult::new_with_error( DocumentPropertyConstraintViolatedError::new( self.name().clone(), diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index 04081b19672..dd5e6e5b977 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -48,6 +48,11 @@ //! property compared with strings a default. An identifier property compares //! the same ways, its constants written base58, without defaults, and so does //! `$ownerId`, the document's owner, which a transfer or a purchase changes. +//! The block time and heights of the document's creation, last update and last +//! transfer are integer operands too (`"$createdAt"`, `"$updatedAtBlockHeight"`, +//! [`SystemProperty`]), on a document type that records them, so a price update +//! and a transfer or a purchase, which change some of them, are judged against +//! the rules reading those. //! How the arithmetic treats overflow, division and powers is set out on //! [`ConstraintExpression::evaluate`], and how conditions combine on //! [`PropertyConstraint::holds`]. @@ -63,10 +68,17 @@ #[cfg(test)] mod tests; +use crate::block::block_info::BlockInfo; use crate::consensus::basic::document::PropertyConstraintViolation; use crate::data_contract::document_type::property_names; use crate::data_contract::errors::DataContractError; -use crate::document::property_names::OWNER_ID; +use crate::document::property_names::{ + CREATED_AT, CREATED_AT_BLOCK_HEIGHT, CREATED_AT_CORE_BLOCK_HEIGHT, OWNER_ID, TRANSFERRED_AT, + TRANSFERRED_AT_BLOCK_HEIGHT, TRANSFERRED_AT_CORE_BLOCK_HEIGHT, UPDATED_AT, + UPDATED_AT_BLOCK_HEIGHT, UPDATED_AT_CORE_BLOCK_HEIGHT, +}; +use crate::document::{Document, DocumentV0Getters}; +use crate::prelude::{BlockHeight, CoreBlockHeight, TimestampMillis}; use platform_value::string_encoding::Encoding; use platform_value::{Identifier, Value, ValueMapHelper}; use std::collections::{BTreeMap, BTreeSet}; @@ -163,6 +175,188 @@ impl ConstraintComparison { } } +/// A system property of a document a rule may read as an integer operand, by +/// its name (`"$createdAt"`): the block time, in milliseconds, the Platform +/// block height or the Core chain block height of the document's creation, of +/// its last update (a create, a replace or a price update) or of its last +/// transfer (a create, a transfer or a purchase). A rule may read one only on a +/// document type that records it, by listing it in `required`, so every stored +/// document of the type holds it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SystemProperty { + /// `$createdAt` + CreatedAt, + /// `$updatedAt` + UpdatedAt, + /// `$transferredAt` + TransferredAt, + /// `$createdAtBlockHeight` + CreatedAtBlockHeight, + /// `$updatedAtBlockHeight` + UpdatedAtBlockHeight, + /// `$transferredAtBlockHeight` + TransferredAtBlockHeight, + /// `$createdAtCoreBlockHeight` + CreatedAtCoreBlockHeight, + /// `$updatedAtCoreBlockHeight` + UpdatedAtCoreBlockHeight, + /// `$transferredAtCoreBlockHeight` + TransferredAtCoreBlockHeight, +} + +impl SystemProperty { + /// Every system property a rule may read. + pub const ALL: [SystemProperty; 9] = [ + SystemProperty::CreatedAt, + SystemProperty::UpdatedAt, + SystemProperty::TransferredAt, + SystemProperty::CreatedAtBlockHeight, + SystemProperty::UpdatedAtBlockHeight, + SystemProperty::TransferredAtBlockHeight, + SystemProperty::CreatedAtCoreBlockHeight, + SystemProperty::UpdatedAtCoreBlockHeight, + SystemProperty::TransferredAtCoreBlockHeight, + ]; + + /// Its name, as a rule and `required` write it. + pub fn name(self) -> &'static str { + match self { + SystemProperty::CreatedAt => CREATED_AT, + SystemProperty::UpdatedAt => UPDATED_AT, + SystemProperty::TransferredAt => TRANSFERRED_AT, + SystemProperty::CreatedAtBlockHeight => CREATED_AT_BLOCK_HEIGHT, + SystemProperty::UpdatedAtBlockHeight => UPDATED_AT_BLOCK_HEIGHT, + SystemProperty::TransferredAtBlockHeight => TRANSFERRED_AT_BLOCK_HEIGHT, + SystemProperty::CreatedAtCoreBlockHeight => CREATED_AT_CORE_BLOCK_HEIGHT, + SystemProperty::UpdatedAtCoreBlockHeight => UPDATED_AT_CORE_BLOCK_HEIGHT, + SystemProperty::TransferredAtCoreBlockHeight => TRANSFERRED_AT_CORE_BLOCK_HEIGHT, + } + } + + /// The system property a rule names `name`, if any. + pub fn from_name(name: &str) -> Option { + SystemProperty::ALL + .into_iter() + .find(|property| property.name() == name) + } + + /// Whether `change` sets it. + pub fn changed_by(self, change: SystemChange) -> bool { + match change { + SystemChange::Transfer => matches!( + self, + SystemProperty::TransferredAt + | SystemProperty::TransferredAtBlockHeight + | SystemProperty::TransferredAtCoreBlockHeight + ), + SystemChange::PriceUpdate => matches!( + self, + SystemProperty::UpdatedAt + | SystemProperty::UpdatedAtBlockHeight + | SystemProperty::UpdatedAtCoreBlockHeight + ), + } + } +} + +/// A write that changes a stored document's system values and none of its +/// properties, so only the rules reading what it changes can break. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SystemChange { + /// A transfer or a purchase: a new owner, and the time and heights of the + /// last transfer. + Transfer, + /// A price update: the time and heights of the last update. + PriceUpdate, +} + +/// The system values of the document version a rule is judged against: its +/// owner, which `$ownerId` reads, and the times and heights [`SystemProperty`] +/// names. Consensus passes every one the document type records; a client +/// passes those it knows. `$ownerId` equals no identifier when the owner is +/// unknown, and [`PropertyConstraint::violation`] does not judge a rule reading +/// a time or a height it is not given. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DocumentSystemValues { + pub owner_id: Option, + pub created_at: Option, + pub updated_at: Option, + pub transferred_at: Option, + pub created_at_block_height: Option, + pub updated_at_block_height: Option, + pub transferred_at_block_height: Option, + pub created_at_core_block_height: Option, + pub updated_at_core_block_height: Option, + pub transferred_at_core_block_height: Option, +} + +impl DocumentSystemValues { + /// The owner alone, no time or height. + pub fn owned_by(owner_id: Identifier) -> Self { + DocumentSystemValues { + owner_id: Some(owner_id), + ..Default::default() + } + } + + /// A document created by `owner_id` in the block `block_info` describes: + /// its creation, last update and last transfer all at that block, as a + /// create records every one its type requires. + pub fn created_in_block(owner_id: Identifier, block_info: &BlockInfo) -> Self { + DocumentSystemValues { + owner_id: Some(owner_id), + created_at: Some(block_info.time_ms), + updated_at: Some(block_info.time_ms), + transferred_at: Some(block_info.time_ms), + created_at_block_height: Some(block_info.height), + updated_at_block_height: Some(block_info.height), + transferred_at_block_height: Some(block_info.height), + created_at_core_block_height: Some(block_info.core_height), + updated_at_core_block_height: Some(block_info.core_height), + transferred_at_core_block_height: Some(block_info.core_height), + } + } + + /// The values `document` holds. + pub fn of_document(document: &Document) -> Self { + DocumentSystemValues { + owner_id: Some(document.owner_id()), + created_at: document.created_at(), + updated_at: document.updated_at(), + transferred_at: document.transferred_at(), + created_at_block_height: document.created_at_block_height(), + updated_at_block_height: document.updated_at_block_height(), + transferred_at_block_height: document.transferred_at_block_height(), + created_at_core_block_height: document.created_at_core_block_height(), + updated_at_core_block_height: document.updated_at_core_block_height(), + transferred_at_core_block_height: document.transferred_at_core_block_height(), + } + } + + /// The value of `property`, `None` when not given. + pub fn value(&self, property: SystemProperty) -> Option { + match property { + SystemProperty::CreatedAt => self.created_at.map(i128::from), + SystemProperty::UpdatedAt => self.updated_at.map(i128::from), + SystemProperty::TransferredAt => self.transferred_at.map(i128::from), + SystemProperty::CreatedAtBlockHeight => self.created_at_block_height.map(i128::from), + SystemProperty::UpdatedAtBlockHeight => self.updated_at_block_height.map(i128::from), + SystemProperty::TransferredAtBlockHeight => { + self.transferred_at_block_height.map(i128::from) + } + SystemProperty::CreatedAtCoreBlockHeight => { + self.created_at_core_block_height.map(i128::from) + } + SystemProperty::UpdatedAtCoreBlockHeight => { + self.updated_at_core_block_height.map(i128::from) + } + SystemProperty::TransferredAtCoreBlockHeight => { + self.transferred_at_core_block_height.map(i128::from) + } + } + } +} + /// What a size operand measures of the property it names. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SizeMeasure { @@ -209,6 +403,9 @@ pub enum ConstraintExpression { /// dotted `path`, as `measure` counts it, or 0 when the document leaves it /// out. Size { measure: SizeMeasure, path: String }, + /// A system property, `"$createdAt"` say: its value in the + /// [`DocumentSystemValues`] the rule is judged with. + System(SystemProperty), /// `add`: the sum of two or more operands. Add(Vec), /// `multiply`: the product of two or more operands. @@ -240,6 +437,9 @@ impl ConstraintExpression { /// * a size is never a fault: a property the document leaves out, or sets /// to null, has size 0, and so does a value of another type than the /// one measured, which the schema validation reported first refuses; + /// * a system property takes its value in `system`, 0 when not given + /// ([`PropertyConstraint::violation`] does not judge a rule reading one it + /// is not given); /// * `add` and `multiply` fold their operands from the left, so an overflow /// on the way is a fault even when a later operand would bring the result /// back in range; @@ -251,9 +451,14 @@ impl ConstraintExpression { /// * `power` refuses a negative exponent /// ([`PropertyConstraintViolation::NegativeExponent`]), which has no /// integer result, and takes `0` to the power `0` as `1`. - pub fn evaluate(&self, data: &Value) -> Result { + pub fn evaluate( + &self, + data: &Value, + system: &DocumentSystemValues, + ) -> Result { match self { ConstraintExpression::Value(value) => Ok(*value), + ConstraintExpression::System(property) => Ok(system.value(*property).unwrap_or(0)), ConstraintExpression::Property { path, if_absent } => { property_value(data, path, *if_absent) } @@ -264,24 +469,25 @@ impl ConstraintExpression { } ConstraintExpression::Add(operands) => { operands.iter().try_fold(0i128, |sum, operand| { - sum.checked_add(operand.evaluate(data)?) + sum.checked_add(operand.evaluate(data, system)?) .ok_or(PropertyConstraintViolation::Overflow) }) } ConstraintExpression::Multiply(operands) => { operands.iter().try_fold(1i128, |product, operand| { product - .checked_mul(operand.evaluate(data)?) + .checked_mul(operand.evaluate(data, system)?) .ok_or(PropertyConstraintViolation::Overflow) }) } ConstraintExpression::Subtract(left, right) => { - let (left, right) = (left.evaluate(data)?, right.evaluate(data)?); + let (left, right) = (left.evaluate(data, system)?, right.evaluate(data, system)?); left.checked_sub(right) .ok_or(PropertyConstraintViolation::Overflow) } ConstraintExpression::Divide(left, right) => { - let (dividend, divisor) = (left.evaluate(data)?, right.evaluate(data)?); + let (dividend, divisor) = + (left.evaluate(data, system)?, right.evaluate(data, system)?); if divisor == 0 { return Err(PropertyConstraintViolation::DivisionByZero); } @@ -291,7 +497,8 @@ impl ConstraintExpression { .ok_or(PropertyConstraintViolation::Overflow) } ConstraintExpression::Modulo(left, right) => { - let (dividend, divisor) = (left.evaluate(data)?, right.evaluate(data)?); + let (dividend, divisor) = + (left.evaluate(data, system)?, right.evaluate(data, system)?); match divisor { 0 => Err(PropertyConstraintViolation::DivisionByZero), // Every integer is a multiple of -1. `checked_rem_euclid` refuses @@ -304,7 +511,8 @@ impl ConstraintExpression { } } ConstraintExpression::Power(left, right) => { - let (base, exponent) = (left.evaluate(data)?, right.evaluate(data)?); + let (base, exponent) = + (left.evaluate(data, system)?, right.evaluate(data, system)?); power(base, exponent) } } @@ -315,7 +523,8 @@ impl ConstraintExpression { 1 + match self { ConstraintExpression::Value(_) | ConstraintExpression::Property { .. } - | ConstraintExpression::Size { .. } => 0, + | ConstraintExpression::Size { .. } + | ConstraintExpression::System(_) => 0, ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { operands.iter().map(ConstraintExpression::node_count).sum() } @@ -330,7 +539,9 @@ impl ConstraintExpression { fn reads_property(&self) -> bool { match self { ConstraintExpression::Value(_) => false, - ConstraintExpression::Property { .. } | ConstraintExpression::Size { .. } => true, + ConstraintExpression::Property { .. } + | ConstraintExpression::Size { .. } + | ConstraintExpression::System(_) => true, ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { operands.iter().any(ConstraintExpression::reads_property) } @@ -347,7 +558,7 @@ impl ConstraintExpression { /// size, to `reads`, in the order it reads them. fn collect_property_reads<'a>(&'a self, reads: &mut Vec<(&'a str, PropertyRead)>) { match self { - ConstraintExpression::Value(_) => {} + ConstraintExpression::Value(_) | ConstraintExpression::System(_) => {} ConstraintExpression::Property { path, .. } => reads.push((path, PropertyRead::Value)), ConstraintExpression::Size { measure, path } => reads.push((path, measure.read())), ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { @@ -364,6 +575,29 @@ impl ConstraintExpression { } } } + + /// Appends the system properties the expression reads to `reads`, in the + /// order it reads them. + fn collect_system_reads(&self, reads: &mut Vec) { + match self { + ConstraintExpression::System(property) => reads.push(*property), + ConstraintExpression::Value(_) + | ConstraintExpression::Property { .. } + | ConstraintExpression::Size { .. } => {} + ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { + for operand in operands { + operand.collect_system_reads(reads); + } + } + ConstraintExpression::Subtract(left, right) + | ConstraintExpression::Divide(left, right) + | ConstraintExpression::Modulo(left, right) + | ConstraintExpression::Power(left, right) => { + left.collect_system_reads(reads); + right.collect_system_reads(reads); + } + } + } } /// How a rule reads a property, which decides the properties it may name. @@ -507,10 +741,10 @@ pub enum PropertyConstraint { } impl PropertyConstraint { - /// Whether a document whose properties are `data`, owned by `owner_id`, - /// meets the condition. `owner_id` is what `$ownerId` reads; `None` for a - /// document whose owner the caller does not know, which `$ownerId` then - /// equals no identifier for. + /// Whether a document whose properties are `data` and whose system values + /// are `system` meets the condition. `$ownerId` reads `system.owner_id`, + /// equalling no identifier when it is `None`, and a system property reads + /// its value there, 0 when not given. /// /// Evaluated left to right, and no further than the outcome needs: a /// comparison evaluates its left side, then its right one; `anyOf` checks @@ -526,19 +760,20 @@ impl PropertyConstraint { pub fn holds( &self, data: &Value, - owner_id: Option, + system: &DocumentSystemValues, ) -> Result { + let owner_id = system.owner_id; match self { PropertyConstraint::Compare { comparison, left, right, } => { - let (left, right) = (left.evaluate(data)?, right.evaluate(data)?); + let (left, right) = (left.evaluate(data, system)?, right.evaluate(data, system)?); Ok(comparison.holds(left, right)) } PropertyConstraint::In { operand, values } => { - Ok(values.contains(&operand.evaluate(data)?)) + Ok(values.contains(&operand.evaluate(data, system)?)) } PropertyConstraint::TextCompare { comparison, @@ -592,7 +827,7 @@ impl PropertyConstraint { PropertyConstraint::Absent(path) => Ok(!is_present(data, path)), PropertyConstraint::AnyOf(conditions) => { for condition in conditions { - if condition.holds(data, owner_id)? { + if condition.holds(data, system)? { return Ok(true); } } @@ -600,26 +835,36 @@ impl PropertyConstraint { } PropertyConstraint::AllOf(conditions) => { for condition in conditions { - if !condition.holds(data, owner_id)? { + if !condition.holds(data, system)? { return Ok(false); } } Ok(true) } - PropertyConstraint::Not(condition) => Ok(!condition.holds(data, owner_id)?), + PropertyConstraint::Not(condition) => Ok(!condition.holds(data, system)?), } } - /// Why a document whose properties are `data`, owned by `owner_id`, breaks - /// the rule, `None` when it meets it: the first fault met on the way - /// ([`Self::holds`]), or [`PropertyConstraintViolation::NotMet`] when the - /// rule evaluates to false. + /// Why a document whose properties are `data` and whose system values are + /// `system` breaks the rule, `None` when it meets it: the first fault met + /// on the way ([`Self::holds`]), or [`PropertyConstraintViolation::NotMet`] + /// when the rule evaluates to false. A rule reading a system property + /// `system` does not give is not judged: consensus gives every one the + /// document type records, the only ones a rule may read, so only a client + /// that does not know one skips the rule. pub fn violation( &self, data: &Value, - owner_id: Option, + system: &DocumentSystemValues, ) -> Option { - match self.holds(data, owner_id) { + if self + .system_reads() + .into_iter() + .any(|property| system.value(property).is_none()) + { + return None; + } + match self.holds(data, system) { Ok(true) => None, Ok(false) => Some(PropertyConstraintViolation::NotMet), Err(violation) => Some(violation), @@ -630,8 +875,8 @@ impl PropertyConstraint { /// `SystemLimits::max_property_constraint_nodes`: every comparison and /// logical operator, every `in` and each value it lists, every string /// constant, every `present` or `absent` with the property it names, every - /// arithmetic operator and every operand (an integer value, or a property - /// with or without `ifAbsent`). + /// arithmetic operator and every operand (an integer value, a property with + /// or without `ifAbsent`, a size or a system property). pub fn node_count(&self) -> usize { 1 + match self { PropertyConstraint::Compare { left, right, .. } => { @@ -694,6 +939,52 @@ impl PropertyConstraint { } } + /// The system properties the rule reads (`"$createdAt"`, ...), in + /// declared order, one read twice listed twice. `$ownerId` is + /// [`Self::reads_owner`]'s. + pub fn system_reads(&self) -> Vec { + let mut reads = Vec::new(); + self.collect_system_reads(&mut reads); + reads + } + + /// Whether `change` can break the rule: it reads the owner, or the time + /// and heights of the last transfer, for a transfer or a purchase; the + /// time and heights of the last update for a price update. Such a write + /// changes those and no property, so a rule reading neither held when the + /// document was written and still does. + pub fn reads_change(&self, change: SystemChange) -> bool { + (change == SystemChange::Transfer && self.reads_owner()) + || self + .system_reads() + .into_iter() + .any(|property| property.changed_by(change)) + } + + fn collect_system_reads(&self, reads: &mut Vec) { + match self { + PropertyConstraint::Compare { left, right, .. } => { + left.collect_system_reads(reads); + right.collect_system_reads(reads); + } + PropertyConstraint::In { operand, .. } => operand.collect_system_reads(reads), + PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { + for condition in conditions { + condition.collect_system_reads(reads); + } + } + PropertyConstraint::Not(condition) => condition.collect_system_reads(reads), + PropertyConstraint::TextCompare { .. } + | PropertyConstraint::TextCompareProperties { .. } + | PropertyConstraint::TextIn { .. } + | PropertyConstraint::IdentifierCompare { .. } + | PropertyConstraint::IdentifierCompareProperties { .. } + | PropertyConstraint::IdentifierIn { .. } + | PropertyConstraint::Present(_) + | PropertyConstraint::Absent(_) => {} + } + } + /// Every string constant the rule compares a property with, as the /// property's dotted path and the constant, in declared order. pub fn text_constants(&self) -> Vec<(&str, &str)> { @@ -1513,6 +1804,9 @@ fn parse_expression( )); } if let Some(path) = value.as_text() { + if let Some(property) = SystemProperty::from_name(path) { + return Ok(ConstraintExpression::System(property)); + } return Ok(ConstraintExpression::Property { path: path.to_string(), if_absent: 0, @@ -1541,6 +1835,12 @@ fn parse_expression( let Some(path) = path.as_text() else { return Err(format!("at {at} must name a property path first")); }; + if SystemProperty::from_name(path).is_some() { + return Err(format!( + "at {at} gives {path} a default, but a system property a rule reads is \ + always set: name it on its own" + )); + } if if_absent.as_text().is_some() { return Err(format!( "at {at} gives a string default, which only a comparison of strings \ diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index e5191e8f379..2cfdb80de03 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -63,7 +63,9 @@ fn data(entries: &[(&str, Value)]) -> Value { /// The value of `expression`, parsed as the left side of an `equal`, for `data`. fn evaluate(expression: Value, data: &Value) -> Result { match parse_rule_value(platform_value!({ "equal": [expression, "anchor"] })) { - PropertyConstraint::Compare { left, .. } => left.evaluate(data), + PropertyConstraint::Compare { left, .. } => { + left.evaluate(data, &DocumentSystemValues::default()) + } other => panic!("an equal parses to a comparison, got {other:?}"), } } @@ -737,27 +739,45 @@ fn should_hold_an_in_when_its_operand_takes_a_listed_value() { let rule = parse_rule_value(platform_value!({ "in": ["kind", [1, 3, 7]] })); for (kind, holds) in [(1, true), (3, true), (7, true), (2, false), (8, false)] { assert_eq!( - rule.holds(&data(&[("kind", Value::U64(kind))]), None), + rule.holds( + &data(&[("kind", Value::U64(kind))]), + &DocumentSystemValues::default() + ), Ok(holds), "kind {kind}" ); } // An absent operand reads as 0 - assert_eq!(rule.holds(&data(&[]), None), Ok(false)); + assert_eq!( + rule.holds(&data(&[]), &DocumentSystemValues::default()), + Ok(false) + ); let with_zero = parse_rule_value(platform_value!({ "in": ["kind", [0, 1]] })); - assert_eq!(with_zero.holds(&data(&[]), None), Ok(true)); + assert_eq!( + with_zero.holds(&data(&[]), &DocumentSystemValues::default()), + Ok(true) + ); let divided = parse_rule_value(platform_value!({ "in": [{ "divide": [10, "kind"] }, [2, 5]] })); assert_eq!( - divided.violation(&data(&[("kind", Value::U64(5))]), None), + divided.violation( + &data(&[("kind", Value::U64(5))]), + &DocumentSystemValues::default() + ), None ); assert_eq!( - divided.violation(&data(&[("kind", Value::U64(3))]), None), + divided.violation( + &data(&[("kind", Value::U64(3))]), + &DocumentSystemValues::default() + ), Some(PropertyConstraintViolation::NotMet) ); assert_eq!( - divided.violation(&data(&[("kind", Value::U64(0))]), None), + divided.violation( + &data(&[("kind", Value::U64(0))]), + &DocumentSystemValues::default() + ), Some(PropertyConstraintViolation::DivisionByZero) ); @@ -903,17 +923,17 @@ fn should_compare_a_string_property_with_constants() { None => data(&[]), }; assert_eq!( - equal.holds(&values, None), + equal.holds(&values, &DocumentSystemValues::default()), Ok(is_closed), "equal, {status:?}" ); assert_eq!( - not_equal.holds(&values, None), + not_equal.holds(&values, &DocumentSystemValues::default()), Ok(!is_closed), "notEqual, {status:?}" ); assert_eq!( - in_list.holds(&values, None), + in_list.holds(&values, &DocumentSystemValues::default()), Ok(is_listed), "in, {status:?}" ); @@ -924,16 +944,22 @@ fn should_compare_a_string_property_with_constants() { "anyOf": [{ "notEqual": ["status", { "const": "closed" }] }, { "present": "closedAt" }] })); let closed = Value::Text("closed".to_string()); - assert_eq!(rule.violation(&data(&[]), None), None); + assert_eq!( + rule.violation(&data(&[]), &DocumentSystemValues::default()), + None + ); assert_eq!( rule.violation( &data(&[("status", closed.clone()), ("closedAt", Value::U64(9))]), - None + &DocumentSystemValues::default() ), None ); assert_eq!( - rule.violation(&data(&[("status", closed)]), None), + rule.violation( + &data(&[("status", closed)]), + &DocumentSystemValues::default() + ), Some(PropertyConstraintViolation::NotMet) ); } @@ -1043,12 +1069,12 @@ fn should_compare_two_string_properties() { } let values = data(&entries); assert_eq!( - equal.holds(&values, None), + equal.holds(&values, &DocumentSystemValues::default()), Ok(same), "equal, {from:?} {to:?}" ); assert_eq!( - not_equal.holds(&values, None), + not_equal.holds(&values, &DocumentSystemValues::default()), Ok(!same), "notEqual, {from:?} {to:?}" ); @@ -1158,25 +1184,45 @@ fn should_read_a_string_default_for_a_property_left_out() { Some(value) => data(&[("status", value.clone())]), None => data(&[]), }; - assert_eq!(open.holds(&values, None), Ok(is_open), "equal, {status:?}"); - assert_eq!(listed.holds(&values, None), Ok(is_open), "in, {status:?}"); + assert_eq!( + open.holds(&values, &DocumentSystemValues::default()), + Ok(is_open), + "equal, {status:?}" + ); + assert_eq!( + listed.holds(&values, &DocumentSystemValues::default()), + Ok(is_open), + "in, {status:?}" + ); } let same_default = parse_rule_value(platform_value!({ "equal": [{ "ifAbsent": ["from", "USD"] }, { "ifAbsent": ["to", "USD"] }] })); - assert_eq!(same_default.holds(&data(&[]), None), Ok(true)); assert_eq!( - same_default.holds(&data(&[("to", text_value("USD"))]), None), + same_default.holds(&data(&[]), &DocumentSystemValues::default()), + Ok(true) + ); + assert_eq!( + same_default.holds( + &data(&[("to", text_value("USD"))]), + &DocumentSystemValues::default() + ), Ok(true) ); assert_eq!( - same_default.holds(&data(&[("to", text_value("EUR"))]), None), + same_default.holds( + &data(&[("to", text_value("EUR"))]), + &DocumentSystemValues::default() + ), Ok(false) ); // Without defaults, two properties left out are not equal let bare = parse_rule_value(platform_value!({ "equal": ["from", "to"] })); - assert_eq!(bare.holds(&data(&[]), None), Ok(false)); + assert_eq!( + bare.holds(&data(&[]), &DocumentSystemValues::default()), + Ok(false) + ); // A default is part of the node it sits in, and listed for the enum check apart // from the constants compared @@ -1326,8 +1372,16 @@ fn should_compare_identifier_properties() { Some(value) => data(&[("buyerId", value.clone())]), None => data(&[]), }; - assert_eq!(is_a.holds(&values, None), Ok(equals_a), "equal, {buyer:?}"); - assert_eq!(listed.holds(&values, None), Ok(is_listed), "in, {buyer:?}"); + assert_eq!( + is_a.holds(&values, &DocumentSystemValues::default()), + Ok(equals_a), + "equal, {buyer:?}" + ); + assert_eq!( + listed.holds(&values, &DocumentSystemValues::default()), + Ok(is_listed), + "in, {buyer:?}" + ); } let distinct = parse_rule_value(platform_value!({ "notEqual": ["buyerId", "sellerId"] })); @@ -1341,11 +1395,23 @@ fn should_compare_identifier_properties() { } data(&entries) }; - assert_eq!(distinct.holds(&pair(Some(a), Some(b)), None), Ok(true)); - assert_eq!(distinct.holds(&pair(Some(a), Some(a)), None), Ok(false)); - assert_eq!(distinct.holds(&pair(Some(a), None), None), Ok(true)); + assert_eq!( + distinct.holds(&pair(Some(a), Some(b)), &DocumentSystemValues::default()), + Ok(true) + ); + assert_eq!( + distinct.holds(&pair(Some(a), Some(a)), &DocumentSystemValues::default()), + Ok(false) + ); + assert_eq!( + distinct.holds(&pair(Some(a), None), &DocumentSystemValues::default()), + Ok(true) + ); // Two identifiers left out are not equal - assert_eq!(distinct.holds(&pair(None, None), None), Ok(true)); + assert_eq!( + distinct.holds(&pair(None, None), &DocumentSystemValues::default()), + Ok(true) + ); // A comparison of a path with a constant is three nodes, an in two plus one per value assert_eq!(is_a.node_count(), 3); @@ -1437,13 +1503,34 @@ fn should_compare_the_owner() { let buyer = |identifier: Identifier| data(&[("buyerId", Value::Identifier(identifier.to_buffer()))]); - assert_eq!(buyer_owns.holds(&buyer(a), Some(a)), Ok(true)); - assert_eq!(buyer_owns.holds(&buyer(a), Some(b)), Ok(false)); - assert_eq!(buyer_owns.holds(&buyer(a), None), Ok(false)); - assert_eq!(buyer_owns.holds(&data(&[]), Some(a)), Ok(false)); - assert_eq!(allowed_writers.holds(&data(&[]), Some(b)), Ok(true)); - assert_eq!(allowed_writers.holds(&data(&[]), Some(c)), Ok(false)); - assert_eq!(allowed_writers.holds(&data(&[]), None), Ok(false)); + assert_eq!( + buyer_owns.holds(&buyer(a), &DocumentSystemValues::owned_by(a)), + Ok(true) + ); + assert_eq!( + buyer_owns.holds(&buyer(a), &DocumentSystemValues::owned_by(b)), + Ok(false) + ); + assert_eq!( + buyer_owns.holds(&buyer(a), &DocumentSystemValues::default()), + Ok(false) + ); + assert_eq!( + buyer_owns.holds(&data(&[]), &DocumentSystemValues::owned_by(a)), + Ok(false) + ); + assert_eq!( + allowed_writers.holds(&data(&[]), &DocumentSystemValues::owned_by(b)), + Ok(true) + ); + assert_eq!( + allowed_writers.holds(&data(&[]), &DocumentSystemValues::owned_by(c)), + Ok(false) + ); + assert_eq!( + allowed_writers.holds(&data(&[]), &DocumentSystemValues::default()), + Ok(false) + ); assert_eq!( buyer_owns.property_reads(), @@ -1745,10 +1832,16 @@ fn should_report_whether_a_rule_holds_and_the_left_fault_first() { ]) }; // (10 + 2) * 3 = 36 - assert_eq!(rule.violation(&order(10, 2, 3, 36), None), None); - assert_eq!(rule.violation(&order(10, 2, 3, 100), None), None); assert_eq!( - rule.violation(&order(10, 2, 3, 35), None), + rule.violation(&order(10, 2, 3, 36), &DocumentSystemValues::default()), + None + ); + assert_eq!( + rule.violation(&order(10, 2, 3, 100), &DocumentSystemValues::default()), + None + ); + assert_eq!( + rule.violation(&order(10, 2, 3, 35), &DocumentSystemValues::default()), Some(PropertyConstraintViolation::NotMet) ); @@ -1761,7 +1854,7 @@ fn should_report_whether_a_rule_holds_and_the_left_fault_first() { ("negative", Value::I64(-1)), ]); assert_eq!( - both_sides_fail.violation(&values, None), + both_sides_fail.violation(&values, &DocumentSystemValues::default()), Some(PropertyConstraintViolation::DivisionByZero) ); @@ -1807,20 +1900,34 @@ fn should_combine_conditions_with_any_of_all_of_and_not() { ] { let values = data(&[("a", Value::U64(a)), ("b", Value::U64(b))]); assert_eq!( - any_of.holds(&values, None), + any_of.holds(&values, &DocumentSystemValues::default()), Ok(either), "a {a}, b {b}: anyOf" ); - assert_eq!(all_of.holds(&values, None), Ok(both), "a {a}, b {b}: allOf"); - assert_eq!(not.holds(&values, None), Ok(!either), "a {a}, b {b}: not"); assert_eq!( - any_of.violation(&values, None), + all_of.holds(&values, &DocumentSystemValues::default()), + Ok(both), + "a {a}, b {b}: allOf" + ); + assert_eq!( + not.holds(&values, &DocumentSystemValues::default()), + Ok(!either), + "a {a}, b {b}: not" + ); + assert_eq!( + any_of.violation(&values, &DocumentSystemValues::default()), (!either).then_some(PropertyConstraintViolation::NotMet), "a {a}, b {b}" ); } // An absent property still counts as 0 - assert_eq!(any_of.holds(&data(&[("b", Value::U64(5))]), None), Ok(true)); + assert_eq!( + any_of.holds( + &data(&[("b", Value::U64(5))]), + &DocumentSystemValues::default() + ), + Ok(true) + ); } /// Conditions are checked in declared order, no further than the outcome needs, so an @@ -1842,41 +1949,51 @@ fn should_stop_at_the_outcome_and_break_the_rule_on_the_first_fault() { let values = |a: u64, b: u64| data(&[("a", Value::U64(a)), ("b", Value::U64(b))]); let zero_divisor = values(6, 0); - assert_eq!(guarded_any_of.violation(&zero_divisor, None), None); assert_eq!( - unguarded_any_of.violation(&zero_divisor, None), + guarded_any_of.violation(&zero_divisor, &DocumentSystemValues::default()), + None + ); + assert_eq!( + unguarded_any_of.violation(&zero_divisor, &DocumentSystemValues::default()), Some(PropertyConstraintViolation::DivisionByZero) ); assert_eq!( - guarded_all_of.violation(&zero_divisor, None), + guarded_all_of.violation(&zero_divisor, &DocumentSystemValues::default()), Some(PropertyConstraintViolation::NotMet) ); assert_eq!( - negated.violation(&zero_divisor, None), + negated.violation(&zero_divisor, &DocumentSystemValues::default()), Some(PropertyConstraintViolation::DivisionByZero) ); // 4 / 2 = 2, 6 / 2 = 3 for rule in [&guarded_any_of, &unguarded_any_of, &guarded_all_of] { - assert_eq!(rule.violation(&values(4, 2), None), None, "{rule:?}"); assert_eq!( - rule.violation(&values(6, 2), None), + rule.violation(&values(4, 2), &DocumentSystemValues::default()), + None, + "{rule:?}" + ); + assert_eq!( + rule.violation(&values(6, 2), &DocumentSystemValues::default()), Some(PropertyConstraintViolation::NotMet), "{rule:?}" ); } assert_eq!( - negated.violation(&values(4, 2), None), + negated.violation(&values(4, 2), &DocumentSystemValues::default()), Some(PropertyConstraintViolation::NotMet) ); - assert_eq!(negated.violation(&values(6, 2), None), None); + assert_eq!( + negated.violation(&values(6, 2), &DocumentSystemValues::default()), + None + ); // An allOf stops at the first condition that fails, before a later fault let fails_before_the_fault = parse_rule_value(platform_value!({ "allOf": [{ "equal": ["a", 1] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] })); assert_eq!( - fails_before_the_fault.violation(&zero_divisor, None), + fails_before_the_fault.violation(&zero_divisor, &DocumentSystemValues::default()), Some(PropertyConstraintViolation::NotMet) ); } @@ -1921,12 +2038,12 @@ fn should_tell_a_property_left_out_from_one_set_to_zero() { let present_rule = parse_rule_value(platform_value!({ "present": path })); let absent_rule = parse_rule_value(platform_value!({ "absent": path })); assert_eq!( - present_rule.holds(&values, None), + present_rule.holds(&values, &DocumentSystemValues::default()), Ok(present), "present {path}" ); assert_eq!( - absent_rule.holds(&values, None), + absent_rule.holds(&values, &DocumentSystemValues::default()), Ok(!present), "absent {path}" ); @@ -1937,13 +2054,22 @@ fn should_tell_a_property_left_out_from_one_set_to_zero() { let rule = parse_rule_value(platform_value!({ "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] })); - assert_eq!(rule.violation(&data(&[]), None), None); assert_eq!( - rule.violation(&data(&[("discount", Value::U64(5))]), None), + rule.violation(&data(&[]), &DocumentSystemValues::default()), None ); assert_eq!( - rule.violation(&data(&[("discount", Value::U64(0))]), None), + rule.violation( + &data(&[("discount", Value::U64(5))]), + &DocumentSystemValues::default() + ), + None + ); + assert_eq!( + rule.violation( + &data(&[("discount", Value::U64(0))]), + &DocumentSystemValues::default() + ), Some(PropertyConstraintViolation::NotMet) ); } @@ -1980,10 +2106,16 @@ fn should_read_a_boolean_as_one_or_zero() { })); let order = |waived: bool, fee: u64| data(&[("waived", Value::Bool(waived)), ("fee", Value::U64(fee))]); - assert_eq!(rule.violation(&order(true, 0), None), None); - assert_eq!(rule.violation(&order(false, 10), None), None); assert_eq!( - rule.violation(&order(true, 10), None), + rule.violation(&order(true, 0), &DocumentSystemValues::default()), + None + ); + assert_eq!( + rule.violation(&order(false, 10), &DocumentSystemValues::default()), + None + ); + assert_eq!( + rule.violation(&order(true, 10), &DocumentSystemValues::default()), Some(PropertyConstraintViolation::NotMet) ); } @@ -2158,7 +2290,7 @@ fn should_take_a_size_of_zero_for_a_property_left_out_or_of_another_type() { "greaterThanOrEqual": [{ "count": "tags" }, 1] })); assert_eq!( - rule.violation(&data(&[]), None), + rule.violation(&data(&[]), &DocumentSystemValues::default()), Some(PropertyConstraintViolation::NotMet) ); } @@ -2172,11 +2304,17 @@ fn should_compare_a_size_with_other_properties() { })); let tags = |count: usize| Value::Array(vec![Value::Text("tag".to_string()); count]); assert_eq!( - tags_within_limit.violation(&data(&[("tags", tags(2)), ("maxTags", Value::U8(3))]), None), + tags_within_limit.violation( + &data(&[("tags", tags(2)), ("maxTags", Value::U8(3))]), + &DocumentSystemValues::default() + ), None ); assert_eq!( - tags_within_limit.violation(&data(&[("tags", tags(4)), ("maxTags", Value::U8(3))]), None), + tags_within_limit.violation( + &data(&[("tags", tags(4)), ("maxTags", Value::U8(3))]), + &DocumentSystemValues::default() + ), Some(PropertyConstraintViolation::NotMet) ); @@ -2189,15 +2327,266 @@ fn should_compare_a_size_with_other_properties() { let listing = |fee: u64, title: &str| data(&[("fee", Value::U64(fee)), ("title", Value::from(title))]); assert_eq!( - short_title_when_free.violation(&listing(0, "héllo"), None), + short_title_when_free.violation(&listing(0, "héllo"), &DocumentSystemValues::default()), + None + ); + assert_eq!( + short_title_when_free.violation( + &listing(10, "a long title"), + &DocumentSystemValues::default() + ), + None + ); + assert_eq!( + short_title_when_free.violation( + &listing(0, "a long title"), + &DocumentSystemValues::default() + ), + Some(PropertyConstraintViolation::NotMet) + ); +} + +// ── system times and heights ──────────────────────────────────────────── + +/// Each system time and height is an integer operand named as `required` names +/// it, one node, read from the system values rather than the properties. +#[test] +fn should_parse_the_system_times_and_heights_as_operands() { + for system in SystemProperty::ALL { + assert_eq!(SystemProperty::from_name(system.name()), Some(system)); + let rule = parse_rule_value(platform_value!({ + "lessThan": [system.name(), "deadline"] + })); + assert_eq!( + rule, + PropertyConstraint::Compare { + comparison: ConstraintComparison::LessThan, + left: ConstraintExpression::System(system), + right: property("deadline"), + }, + "{}", + system.name() + ); + assert_eq!(rule.node_count(), 3); + assert_eq!(rule.property_reads(), [("deadline", PropertyRead::Value)]); + assert_eq!(rule.system_reads(), [system]); + assert!(!rule.reads_owner()); + } + for name in ["$ownerId", "$id", "$revision", "$createdat", "createdAt"] { + assert_eq!(SystemProperty::from_name(name), None, "{name}"); + } + + // A system value alone keeps a comparison with a literal meaningful: it + // differs from document to document + let rule = parse_rule_value(platform_value!({ + "greaterThan": ["$createdAtBlockHeight", 1000] + })); + assert_eq!(rule.system_reads(), [SystemProperty::CreatedAtBlockHeight]); +} + +#[test] +fn should_refuse_a_default_for_a_system_property() { + expect_refusal( + platform_value!({ + "rule": { "lessThan": [{ "ifAbsent": ["$createdAt", 0] }, "deadline"] } + }), + "at lessThan[0].ifAbsent gives $createdAt a default, but a system property a rule \ + reads is always set: name it on its own", + ); +} + +/// A rule reads the system values it is judged with: here a listing ends +/// within a week of its creation. +#[test] +fn should_read_the_system_values_the_rule_is_judged_with() { + let rule = parse_rule_value(platform_value!({ + "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] + })); + let created_at = |time: u64| DocumentSystemValues { + created_at: Some(time), + ..Default::default() + }; + let listing = |ends_at: u64| data(&[("endsAt", Value::U64(ends_at))]); + let day = 86_400_000u64; + assert_eq!( + rule.violation(&listing(10 * day), &created_at(4 * day)), None ); assert_eq!( - short_title_when_free.violation(&listing(10, "a long title"), None), + rule.violation(&listing(12 * day), &created_at(4 * day)), + Some(PropertyConstraintViolation::NotMet) + ); + + // Every system value reads its own field + let values = DocumentSystemValues { + owner_id: None, + created_at: Some(1), + updated_at: Some(2), + transferred_at: Some(3), + created_at_block_height: Some(4), + updated_at_block_height: Some(5), + transferred_at_block_height: Some(6), + created_at_core_block_height: Some(7), + updated_at_core_block_height: Some(8), + transferred_at_core_block_height: Some(9), + }; + for (index, property) in SystemProperty::ALL.into_iter().enumerate() { + let expected = i128::try_from(index + 1).expect("small"); + assert_eq!( + values.value(property), + Some(expected), + "{}", + property.name() + ); + let rule = parse_rule_value(platform_value!({ "equal": [property.name(), "expected"] })); + let expected_data = data(&[("expected", Value::I128(expected))]); + assert_eq!( + rule.violation(&expected_data, &values), + None, + "{}", + property.name() + ); + } +} + +/// Consensus gives every system value a type records, the only ones a rule may +/// read; a client that does not know one skips the rule rather than guess. +#[test] +fn should_not_judge_a_rule_reading_a_system_value_not_given() { + let rule = parse_rule_value(platform_value!({ + "anyOf": [ + { "absent": "endsAt" }, + { "greaterThan": ["endsAt", "$updatedAtBlockHeight"] } + ] + })); + let early = data(&[("endsAt", Value::U64(5))]); + assert_eq!( + rule.violation(&early, &DocumentSystemValues::default()), None ); + // Given, it is judged + let at_height = |height: u64| DocumentSystemValues { + updated_at_block_height: Some(height), + ..Default::default() + }; + assert_eq!(rule.violation(&early, &at_height(3)), None); assert_eq!( - short_title_when_free.violation(&listing(0, "a long title"), None), + rule.violation(&early, &at_height(9)), Some(PropertyConstraintViolation::NotMet) ); } + +/// A transfer or a purchase can break the rules reading the owner or the +/// transfer's time and heights, a price update those reading the update's. +#[test] +fn should_tell_which_writes_a_rule_answers_to() { + for (rule, transfer, price_update) in [ + ( + platform_value!({ "lessThan": ["$transferredAt", "endsAt"] }), + true, + false, + ), + ( + platform_value!({ "not": { "in": ["$transferredAtCoreBlockHeight", [1, 2]] } }), + true, + false, + ), + ( + platform_value!({ "lessThan": ["$updatedAt", "endsAt"] }), + false, + true, + ), + ( + platform_value!({ + "anyOf": [ + { "absent": "endsAt" }, + { "lessThan": [{ "add": ["$updatedAtBlockHeight", 1] }, "endsAt"] } + ] + }), + false, + true, + ), + ( + platform_value!({ "lessThan": ["$createdAt", "endsAt"] }), + false, + false, + ), + ( + platform_value!({ "equal": ["sellerId", "$ownerId"] }), + true, + false, + ), + ( + platform_value!({ "lessThan": ["price", "endsAt"] }), + false, + false, + ), + ] { + let parsed = parse_rule_value(rule.clone()); + assert_eq!( + parsed.reads_change(SystemChange::Transfer), + transfer, + "transfer, {rule:?}" + ); + assert_eq!( + parsed.reads_change(SystemChange::PriceUpdate), + price_update, + "price update, {rule:?}" + ); + } +} + +/// A create records every time and height at its block; a stored document's +/// values are read back from it. +#[test] +fn should_take_the_system_values_of_a_create_and_of_a_document() { + let owner = Identifier::new([3; 32]); + let block = BlockInfo { + time_ms: 1_700_000_000_000, + height: 42, + core_height: 2_100_000, + ..Default::default() + }; + let created = DocumentSystemValues::created_in_block(owner, &block); + assert_eq!(created.owner_id, Some(owner)); + for property in SystemProperty::ALL { + let expected = match property { + SystemProperty::CreatedAt + | SystemProperty::UpdatedAt + | SystemProperty::TransferredAt => 1_700_000_000_000, + SystemProperty::CreatedAtBlockHeight + | SystemProperty::UpdatedAtBlockHeight + | SystemProperty::TransferredAtBlockHeight => 42, + _ => 2_100_000, + }; + assert_eq!( + created.value(property), + Some(expected), + "{}", + property.name() + ); + } + + let document: Document = crate::document::DocumentV0 { + owner_id: owner, + created_at: Some(10), + updated_at: Some(20), + transferred_at: None, + created_at_block_height: Some(1), + updated_at_core_block_height: Some(7), + ..Default::default() + } + .into(); + let stored = DocumentSystemValues::of_document(&document); + assert_eq!( + stored, + DocumentSystemValues { + owner_id: Some(owner), + created_at: Some(10), + updated_at: Some(20), + created_at_block_height: Some(1), + updated_at_core_block_height: Some(7), + ..Default::default() + } + ); +} diff --git a/packages/rs-dpp/src/data_contract/methods/validate_document/mod.rs b/packages/rs-dpp/src/data_contract/methods/validate_document/mod.rs index 3473b952130..7e87071ba8c 100644 --- a/packages/rs-dpp/src/data_contract/methods/validate_document/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/validate_document/mod.rs @@ -1,5 +1,6 @@ +use crate::data_contract::document_type::property_constraints::DocumentSystemValues; use crate::prelude::DataContract; -use platform_value::{Identifier, Value}; +use platform_value::Value; use platform_version::version::PlatformVersion; mod v0; @@ -34,7 +35,7 @@ impl DataContractDocumentValidationMethodsV0 for DataContract { &self, name: &str, properties: Value, - owner_id: Option, + system: &DocumentSystemValues, platform_version: &PlatformVersion, ) -> Result { match platform_version @@ -43,7 +44,7 @@ impl DataContractDocumentValidationMethodsV0 for DataContract { .methods .validate_document { - 0 => self.validate_document_properties_v0(name, properties, owner_id, platform_version), + 0 => self.validate_document_properties_v0(name, properties, system, platform_version), version => Err(ProtocolError::UnknownVersionMismatch { method: "DataContract::validate_document_properties".to_string(), known_versions: vec![0], diff --git a/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs b/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs index b4cf536bca7..a6d650021a0 100644 --- a/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs @@ -3,6 +3,7 @@ use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::methods::{ DocumentTypeBasicMethods, DocumentTypeV0Methods, }; +use crate::data_contract::document_type::property_constraints::DocumentSystemValues; use crate::data_contract::document_type::DocumentType; use crate::consensus::basic::document::{ @@ -16,7 +17,7 @@ use crate::data_contract::DataContract; use crate::document::{Document, DocumentV0Getters}; use crate::validation::SimpleConsensusValidationResult; use crate::ProtocolError; -use platform_value::{Identifier, Value}; +use platform_value::Value; use platform_version::version::PlatformVersion; use std::ops::Deref; @@ -29,15 +30,17 @@ pub trait DataContractDocumentValidationMethodsV0 { ) -> Result; /// Validates a document's properties, `value`, against its document type: the - /// schema, the string byte caps and the `propertyConstraints` rules. `owner_id` is - /// the document's owner, what a rule's `$ownerId` reads; `None` when the caller does - /// not know it, which `$ownerId` then equals no identifier for. Consensus passes the - /// writer on create and replace. + /// schema, the string byte caps and the `propertyConstraints` rules. `system` holds + /// the system values of the document version being written, what a rule's `$ownerId` + /// and system times and heights read: an owner the caller does not know equals no + /// identifier, and a rule reading a time or height it does not know is not judged. + /// Consensus passes the writer and the block's time and heights on create, and the + /// stored ones where a replace keeps them. fn validate_document_properties( &self, name: &str, value: Value, - owner_id: Option, + system: &DocumentSystemValues, platform_version: &PlatformVersion, ) -> Result; } @@ -48,7 +51,7 @@ impl DataContract { &self, name: &str, value: Value, - owner_id: Option, + system: &DocumentSystemValues, platform_version: &PlatformVersion, ) -> Result { let Some(document_type) = self.document_type_optional_for_name(name) else { @@ -117,7 +120,7 @@ impl DataContract { // schema error keeps precedence and every value a rule reads is known to be an // integer. let property_constraints_result = - document_type.validate_property_constraints(&value, owner_id, platform_version)?; + document_type.validate_property_constraints(&value, system, platform_version)?; let json_value = match value.try_into_validating_json() { Ok(json_value) => json_value, @@ -169,7 +172,7 @@ impl DataContract { self.validate_document_properties_v0( name, document.properties().into(), - Some(document.owner_id()), + &DocumentSystemValues::of_document(document), platform_version, ) } @@ -182,6 +185,7 @@ mod tests { use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::data_contract::created_data_contract::CreatedDataContract; + use crate::data_contract::document_type::property_constraints::DocumentSystemValues; use crate::tests::fixtures::get_data_contract_fixture; use platform_value::Value; use platform_version::version::PlatformVersion; @@ -219,7 +223,12 @@ mod tests { ); let result = data_contract - .validate_document_properties("noTimeDocument", value, None, platform_version) + .validate_document_properties( + "noTimeDocument", + value, + &DocumentSystemValues::default(), + platform_version, + ) .expect("validation should return a consensus result"); let Some(ConsensusError::BasicError(BasicError::ValueError(ValueError { .. }))) = @@ -252,7 +261,12 @@ mod tests { ); let result = data_contract - .validate_document_properties("noTimeDocument", value, None, platform_version) + .validate_document_properties( + "noTimeDocument", + value, + &DocumentSystemValues::default(), + platform_version, + ) .expect("validation should return a consensus result"); assert!(matches!( @@ -273,7 +287,12 @@ mod tests { )]); let result = data_contract - .validate_document_properties("noTimeDocument", value, None, platform_version) + .validate_document_properties( + "noTimeDocument", + value, + &DocumentSystemValues::default(), + platform_version, + ) .expect("validation should return a consensus result"); assert!( diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index ab3e8cccc5b..229787b8b78 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -3552,6 +3552,7 @@ mod tests { #[cfg(test)] mod shielded_profile_schema_tests { + use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use dpp::data_contract::validate_document::DataContractDocumentValidationMethodsV0; use dpp::platform_value::{platform_value, Value}; use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; @@ -3566,7 +3567,12 @@ mod shielded_profile_schema_tests { let properties = platform_value!({ "shieldedAddress": Value::Bytes(vec![0; length]) }); let result = contract - .validate_document_properties("profile", properties, None, pv) + .validate_document_properties( + "profile", + properties, + &DocumentSystemValues::default(), + pv, + ) .unwrap(); assert_eq!( result.is_valid(), @@ -3578,7 +3584,7 @@ mod shielded_profile_schema_tests { .validate_document_properties( "profile", platform_value!({"shieldedAddress": "not bytes"}), - None, + &DocumentSystemValues::default(), pv, ) .unwrap(); @@ -3587,7 +3593,7 @@ mod shielded_profile_schema_tests { .validate_document_properties( "profile", platform_value!({"displayName": "Alice"}), - None, + &DocumentSystemValues::default(), pv, ) .unwrap(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v0/mod.rs index f4629441e5a..dd7bd7a6608 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v0/mod.rs @@ -1,3 +1,4 @@ +use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use dpp::block::block_info::BlockInfo; use dpp::consensus::basic::document::{DocumentCreationNotAllowedError, InvalidDocumentTypeError}; use dpp::consensus::state::document::document_contest_not_paid_for_error::DocumentContestNotPaidForError; @@ -109,7 +110,7 @@ impl DocumentCreateTransitionActionStructureValidationV0 for DocumentCreateTrans .validate_document_properties( document_type_name, self.data().into(), - Some(owner_id), + &DocumentSystemValues::created_in_block(owner_id, &self.block_info()), platform_version, ) .map_err(Error::Protocol) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs index ef14d3dec19..318fa760077 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs @@ -1,3 +1,4 @@ +use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use dpp::block::block_info::BlockInfo; use dpp::consensus::basic::document::{DocumentCreationNotAllowedError, InvalidDocumentTypeError}; use dpp::consensus::state::document::document_contest_index_mismatch_error::DocumentContestIndexMismatchError; @@ -143,7 +144,7 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans .validate_document_properties( document_type_name, self.data().into(), - Some(owner_id), + &DocumentSystemValues::created_in_block(owner_id, &self.block_info()), platform_version, ) .map_err(Error::Protocol)?; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_index_only_delete_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_index_only_delete_transition_action/advanced_structure_v0/mod.rs index bd4b2acf8db..6ee9481db2c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_index_only_delete_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_index_only_delete_transition_action/advanced_structure_v0/mod.rs @@ -1,3 +1,4 @@ +use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use dpp::consensus::basic::document::{ InvalidDocumentTransitionActionError, InvalidDocumentTypeError, }; @@ -128,7 +129,7 @@ impl DocumentIndexOnlyDeleteTransitionActionStructureValidationV0 .validate_document_properties( document_type_name, user_data.into(), - None, + &DocumentSystemValues::default(), platform_version, ) .map_err(Error::Protocol) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs index 34efa777cd8..e154128f389 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs @@ -1,3 +1,4 @@ +use dpp::data_contract::document_type::property_constraints::{DocumentSystemValues, SystemChange}; use dpp::consensus::basic::document::{InvalidDocumentTransitionActionError, InvalidDocumentTypeError}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; @@ -78,13 +79,15 @@ impl DocumentPurchaseTransitionActionStructureValidationV0 for DocumentPurchaseT // Added in place at protocol version 14, inert for every earlier version this // generation serves: `validate_property_constraints` is `None` there, so the call - // returns an empty result. From 14, the new owner is judged against the rules of - // `propertyConstraints` that read `$ownerId`: the stored properties met every rule - // when they were written, and the owner is all this action changes. + // returns an empty result. From 14, the document as it changes hands (its new owner, + // and the transfer's time and heights) is judged against the rules of + // `propertyConstraints` that read them: the stored properties met every rule when + // they were written, and these are all this action changes that a rule reads. document_type - .validate_property_constraints_for_new_owner( + .validate_property_constraints_for_system_change( self.document().properties(), - self.document().owner_id(), + &DocumentSystemValues::of_document(self.document()), + SystemChange::Transfer, platform_version, ) .map_err(Error::Protocol) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs index adcec146298..3314713fc22 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs @@ -1,3 +1,4 @@ +use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use dpp::consensus::basic::document::{InvalidDocumentTransitionActionError, InvalidDocumentTypeError}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; @@ -46,13 +47,26 @@ impl DocumentReplaceTransitionActionStructureValidationV0 for DocumentReplaceTra )); } - // Validate user defined properties - + // Validate user defined properties. The rules read the writer, the times and + // heights the replace keeps (creation, last transfer) and the ones it sets (the + // update), as the stored document will hold them. + let system = DocumentSystemValues { + owner_id: Some(owner_id), + created_at: self.created_at(), + updated_at: self.updated_at(), + transferred_at: self.transferred_at(), + created_at_block_height: self.created_at_block_height(), + updated_at_block_height: self.updated_at_block_height(), + transferred_at_block_height: self.transferred_at_block_height(), + created_at_core_block_height: self.created_at_core_block_height(), + updated_at_core_block_height: self.updated_at_core_block_height(), + transferred_at_core_block_height: self.transferred_at_core_block_height(), + }; let result = data_contract .validate_document_properties( document_type_name, self.data().into(), - Some(owner_id), + &system, platform_version, ) .map_err(Error::Protocol)?; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs index 299d2264145..088e1f84c5c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs @@ -1,3 +1,4 @@ +use dpp::data_contract::document_type::property_constraints::{DocumentSystemValues, SystemChange}; use dpp::consensus::basic::document::{InvalidDocumentTransitionActionError, InvalidDocumentTypeError}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; @@ -64,13 +65,15 @@ impl DocumentTransferTransitionActionStructureValidationV0 for DocumentTransferT // Added in place at protocol version 14, inert for every earlier version this // generation serves: `validate_property_constraints` is `None` there, so the call - // returns an empty result. From 14, the new owner is judged against the rules of - // `propertyConstraints` that read `$ownerId`: the stored properties met every rule - // when they were written, and the owner is all this action changes. + // returns an empty result. From 14, the document as it changes hands (its new owner, + // and the transfer's time and heights) is judged against the rules of + // `propertyConstraints` that read them: the stored properties met every rule when + // they were written, and these are all this action changes that a rule reads. document_type - .validate_property_constraints_for_new_owner( + .validate_property_constraints_for_system_change( self.document().properties(), - self.document().owner_id(), + &DocumentSystemValues::of_document(self.document()), + SystemChange::Transfer, platform_version, ) .map_err(Error::Protocol) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/advanced_structure_v0/mod.rs index c595dba4919..45080459e5f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/advanced_structure_v0/mod.rs @@ -1,6 +1,9 @@ use dpp::consensus::basic::document::{InvalidDocumentTransitionActionError, InvalidDocumentTypeError}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::document_type::property_constraints::{DocumentSystemValues, SystemChange}; +use dpp::document::DocumentV0Getters; use dpp::validation::SimpleConsensusValidationResult; use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; use drive::state_transition_action::batch::batched_transition::document_transition::document_update_price_transition_action::{DocumentUpdatePriceTransitionAction, DocumentUpdatePriceTransitionActionAccessorsV0}; @@ -18,7 +21,7 @@ impl DocumentUpdatePriceTransitionActionStructureValidationV0 { fn validate_structure_v0( &self, - _platform_version: &PlatformVersion, + platform_version: &PlatformVersion, ) -> Result { let contract_fetch_info = self.base().data_contract_fetch_info(); let data_contract = &contract_fetch_info.contract; @@ -43,7 +46,20 @@ impl DocumentUpdatePriceTransitionActionStructureValidationV0 .into(), )) } else { - Ok(SimpleConsensusValidationResult::default()) + // Added in place at protocol version 14, inert for every earlier version this + // generation serves: `validate_property_constraints` is `None` there, so the call + // returns an empty result. From 14, a price update sets the document's update + // time and heights, and is judged against the rules of `propertyConstraints` + // reading them: the stored properties met every rule when they were written, + // and those times and heights are all this action changes that a rule reads. + document_type + .validate_property_constraints_for_system_change( + self.document().properties(), + &DocumentSystemValues::of_document(self.document()), + SystemChange::PriceUpdate, + platform_version, + ) + .map_err(Error::Protocol) } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index 07a1b2a2c0c..f5ef1b5917a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -256,6 +256,80 @@ mod property_constraints_tests { }) } + /// A mutable, transferable and purchasable `offer` type with the integers + /// [`set_valid_offer`] fills and an `endsAt` time, recording the time of its + /// creation, last update and last transfer and the block height of its + /// creation, and five rules on them: `endsAfterCreation` + /// (`endsAt > $createdAt`), `endsWithinAWeek` (`endsAt - $createdAt` at most + /// a week), `listedAfterHeight10` (`$createdAtBlockHeight >= 10`), + /// `updatedBeforeEnd` (`$updatedAt <= endsAt`) and `transferredBeforeEnd` + /// (`$transferredAt <= endsAt`). + fn timed_offer_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "transferable": 1, + "tradeMode": 1, + "properties": { + "price": { "type": "integer", "minimum": 0, "position": 0 }, + "fee": { "type": "integer", "minimum": 0, "position": 1 }, + "quantity": { "type": "integer", "minimum": 0, "position": 2 }, + "deposit": { "type": "integer", "minimum": 0, "position": 3 }, + "endsAt": { "type": "integer", "minimum": 0, "position": 4 } + }, + "required": [ + "price", + "fee", + "quantity", + "deposit", + "endsAt", + "$createdAt", + "$updatedAt", + "$transferredAt", + "$createdAtBlockHeight" + ], + "propertyConstraints": { + "endsAfterCreation": { "greaterThan": ["endsAt", "$createdAt"] }, + "endsWithinAWeek": { + "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, WEEK_MS] + }, + "listedAfterHeight10": { "greaterThanOrEqual": ["$createdAtBlockHeight", 10] }, + "updatedBeforeEnd": { "lessThanOrEqual": ["$updatedAt", "endsAt"] }, + "transferredBeforeEnd": { "lessThanOrEqual": ["$transferredAt", "endsAt"] } + }, + "additionalProperties": false + }) + } + + const DAY_MS: u64 = 86_400_000; + const WEEK_MS: u64 = 7 * DAY_MS; + /// The block time the timed tests start at. + const NOW: u64 = 1_700_000_000_000; + + /// A block at `time_ms` and Platform `height`. + fn at_block(time_ms: u64, height: u64) -> BlockInfo { + BlockInfo { + time_ms, + height, + core_height: 1000, + ..Default::default() + } + } + + /// The fixture over [`timed_offer_schema`] with an offer created at [`NOW`], + /// block 20, ending a day later. + async fn timed_offer() -> OfferFixture { + let mut fixture = OfferFixture::with_schema(timed_offer_schema()); + fixture.block_info = at_block(NOW, 20); + assert_matches!( + fixture + .create(|document| document.set("endsAt", Value::U64(NOW + DAY_MS))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + fixture + } + /// An offer that meets every rule: (100 + 10) * 2 = 220. fn set_valid_offer(document: &mut Document) { document.set("price", Value::U64(100)); @@ -279,6 +353,9 @@ mod property_constraints_tests { /// processed transition consumes one, including the ones that fail /// with a paid consensus error. next_nonce: IdentityNonce, + /// The block every transition is processed in: its time and heights are + /// the ones a write records. + block_info: BlockInfo, } impl OfferFixture { @@ -324,6 +401,7 @@ mod property_constraints_tests { contract, document: None, next_nonce: 1, + block_info: BlockInfo::default(), } } @@ -340,7 +418,7 @@ mod property_constraints_tests { .process_raw_state_transitions( &[serialized], &platform_state, - &BlockInfo::default(), + &self.block_info, &transaction, platform_version, false, @@ -505,8 +583,18 @@ mod property_constraints_tests { result } - /// Puts the stored offer up for sale at `price`. + /// Puts the stored offer up for sale at `price`, which must succeed. async fn set_price(&mut self, price: Credits) { + assert_matches!( + self.try_set_price(price).await, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "setting the price must succeed" + ); + } + + /// Puts the stored offer up for sale at `price`. On success the fixture's + /// document becomes the priced version. + async fn try_set_price(&mut self, price: Credits) -> StateTransitionExecutionResult { let platform_version = PlatformVersion::latest(); let mut priced = self .document @@ -538,12 +626,14 @@ mod property_constraints_tests { }; self.next_nonce += 1; - assert_matches!( - self.process(&transition), - StateTransitionExecutionResult::SuccessfulExecution { .. }, - "setting the price must succeed" - ); - self.document = Some(priced); + let result = self.process(&transition); + if matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ) { + self.document = Some(priced); + } + result } /// A second funded identity on the fixture's platform. @@ -1465,4 +1555,134 @@ mod property_constraints_tests { ); assert_eq!(fixture.stored_offers().len(), 1); } + + /// The times and heights a create records are its block's: an offer must end + /// after its creation and within a week of it, and be listed from block 10 on. + #[tokio::test] + async fn should_judge_a_create_by_the_time_and_height_of_its_block() { + let mut fixture = OfferFixture::with_schema(timed_offer_schema()); + fixture.block_info = at_block(NOW, 20); + + let result = fixture + .create(|document| document.set("endsAt", Value::U64(NOW))) + .await; + expect_violated( + result, + "endsAfterCreation", + PropertyConstraintViolation::NotMet, + ); + + let result = fixture + .create(|document| document.set("endsAt", Value::U64(NOW + 8 * DAY_MS))) + .await; + expect_violated( + result, + "endsWithinAWeek", + PropertyConstraintViolation::NotMet, + ); + + fixture.block_info = at_block(NOW, 5); + let result = fixture + .create(|document| document.set("endsAt", Value::U64(NOW + DAY_MS))) + .await; + expect_violated( + result, + "listedAfterHeight10", + PropertyConstraintViolation::NotMet, + ); + assert!(fixture.stored_offers().is_empty()); + + fixture.block_info = at_block(NOW, 20); + assert_matches!( + fixture + .create(|document| document.set("endsAt", Value::U64(NOW + DAY_MS))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let stored = fixture.stored_offers(); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].created_at(), Some(NOW)); + } + + /// A replace keeps the creation time and records its own update time: moving + /// the end is measured from the stored `$createdAt`, and a replace after the + /// end breaks `updatedBeforeEnd`. A price update, which records an update + /// time too, is judged the same way. + #[tokio::test] + async fn should_judge_a_replace_and_a_price_update_by_the_update_time() { + let mut fixture = timed_offer().await; + + // Three days on, the end moves to six days after creation + fixture.block_info = at_block(NOW + 3 * DAY_MS, 30); + assert_matches!( + fixture + .replace(|document| document.set("endsAt", Value::U64(NOW + 6 * DAY_MS))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + // Nine days after creation is more than a week, whenever the replace happens + let result = fixture + .replace(|document| document.set("endsAt", Value::U64(NOW + 9 * DAY_MS))) + .await; + expect_violated( + result, + "endsWithinAWeek", + PropertyConstraintViolation::NotMet, + ); + + // After the end, neither a replace nor a price update is accepted + fixture.block_info = at_block(NOW + 7 * DAY_MS, 40); + let result = fixture + .replace(|document| document.set("fee", Value::U64(20))) + .await; + expect_violated( + result, + "updatedBeforeEnd", + PropertyConstraintViolation::NotMet, + ); + let result = fixture.try_set_price(1000).await; + expect_violated( + result, + "updatedBeforeEnd", + PropertyConstraintViolation::NotMet, + ); + + // Before it, the price update is accepted + fixture.block_info = at_block(NOW + 5 * DAY_MS, 40); + fixture.set_price(1000).await; + } + + /// A transfer and a purchase record the transfer's time: after the end each + /// breaks `transferredBeforeEnd`, while the rules reading the creation and + /// update times are not judged again. + #[tokio::test] + async fn should_judge_a_transfer_and_a_purchase_by_the_transfer_time() { + let mut fixture = timed_offer().await; + let (recipient, _, _) = fixture.other_identity(7); + + fixture.block_info = at_block(NOW + 2 * DAY_MS, 30); + let result = fixture.transfer(recipient.id()).await; + expect_violated( + result, + "transferredBeforeEnd", + PropertyConstraintViolation::NotMet, + ); + + fixture.block_info = at_block(NOW + DAY_MS / 2, 30); + fixture.set_price(1000).await; + let buyer = fixture.other_identity(8); + fixture.block_info = at_block(NOW + 2 * DAY_MS, 40); + let result = fixture.purchase_by(&buyer, 1000).await; + expect_violated( + result, + "transferredBeforeEnd", + PropertyConstraintViolation::NotMet, + ); + + fixture.block_info = at_block(NOW + DAY_MS / 2, 40); + assert_matches!( + fixture.transfer(recipient.id()).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } } diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index fa2affdcfec..185f8601bd9 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1047,23 +1047,28 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// document's properties must meet, each a condition: a comparison /// (`equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan`, /// `greaterThanOrEqual`) of two integer expressions built from integer -/// literals, paths of integer or boolean properties (a boolean reading as -/// 1 for true and 0 for false), `add`, `subtract`, `multiply`, -/// `divide`, `modulo` and `power`, and sizes: `length` and `byteLength`, -/// the characters and UTF-8 bytes of a string property, and `count`, the -/// items of an array or byte array property, each 0 for a property the -/// document leaves out; `in`, whether an integer expression -/// takes one of two or more distinct integer values; `equal` or `notEqual` -/// of a string property and a `{ "const": string }` or of two bare paths -/// naming string properties, or `in` of a string property and two or more -/// distinct strings, a string the document leaves out equalling no -/// constant and no other string unless an `ifAbsent` gives it a string -/// default (`{ "ifAbsent": ["status", "open"] }`, whose default an `enum` -/// must list too); `equal`, `notEqual` or `in` of an identifier property -/// (one declaring `refersTo` included) or of `$ownerId`, the document's -/// owner, likewise, with base58 identifier constants or another identifier -/// operand and no default, an identifier the document leaves out equalling -/// none; `present` or `absent` naming a property of any type, whether the +/// literals, paths of integer or boolean properties (a boolean reading as 1 +/// for true and 0 for false), `add`, `subtract`, `multiply`, `divide`, +/// `modulo` and `power`, and sizes: `length` and `byteLength`, the +/// characters and UTF-8 bytes of a string property, and `count`, the items +/// of an array or byte array property, each 0 for a property the document +/// leaves out, and the system times and heights `$createdAt`, `$updatedAt` +/// and `$transferredAt` (block times in milliseconds), each also with +/// `BlockHeight` or `CoreBlockHeight` appended, of the document's creation, +/// last update (create, replace, price update) and last transfer (create, +/// transfer, purchase), which a rule may read only on a type listing them +/// in `required`; `in`, whether an integer expression takes one of two or +/// more distinct integer values; `equal` or `notEqual` of a string property +/// and a `{ "const": string }` or of two bare paths naming string +/// properties, or `in` of a string property and two or more distinct +/// strings, a string the document leaves out equalling no constant and no +/// other string unless an `ifAbsent` gives it a string default +/// (`{ "ifAbsent": ["status", "open"] }`, whose default an `enum` must list +/// too); `equal`, `notEqual` or `in` of an identifier property (one +/// declaring `refersTo` included) or of `$ownerId`, the document's owner, +/// likewise, with base58 identifier constants or another identifier operand +/// and no default, an identifier the document leaves out equalling none; +/// `present` or `absent` naming a property of any type, whether the /// document holds it (the one way to tell a property left out from one set /// to 0); `anyOf` or `allOf` over two or more conditions; or `not` over /// one. In an operand, a property the document leaves out counts as 0, or @@ -1078,39 +1083,47 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// condition guards a later one. The parser checks that every path an /// operand reads names an integer or boolean property, every path a /// `length` or `byteLength` measures a string property, every path a -/// `count` counts an array or byte array property, every path compared -/// with identifiers an identifier property, every path compared with -/// strings a string property (whose `enum`, if it declares one, lists every -/// constant it is compared with), and every path `present` or `absent` -/// tests a property of any type, none transient nor inside a transient -/// object; that every comparison and `in` reads a property or the owner; -/// that nothing is compared with itself; that strings and identifiers are -/// only compared for equality, and never with each other; that no `in` -/// lists a value twice; that an `anyOf` or `allOf` holds none directly of -/// its own kind and a `not` no `not`; that an indexOnly type, whose deletes -/// carry no owner, reads no `$ownerId`; and that no condition or operand -/// nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every -/// parse. Under full validation it holds the limits -/// `SystemLimits::max_property_constraints` (16 rules) and -/// `max_property_constraint_nodes` (32 per rule, every comparison, `in`, -/// listed value, `const`, presence test and logical operator counting as -/// one), and that no `anyOf` or `allOf` lists the same condition twice. -/// `DataContract::validate_document_properties` 0 (extended in place, inert -/// before this version, and taking the document's owner for `$ownerId`) -/// calls `validate_property_constraints` (`validate_property_constraints` -/// 0) after the schema validation, so document create and replace, and any -/// client validating a document, refuse a broken rule with -/// `DocumentPropertyConstraintViolatedError` (10422), naming the rule and -/// why. A transfer and a purchase, which give the document a new owner, -/// are judged against the rules reading `$ownerId` with that owner -/// (`validate_property_constraints_for_new_owner`, beside `distinctFrom` in -/// their structure validation, in place and inert before this version). -/// The rules read no state and change nothing stored. They are fixed when -/// the document type is created: a changed `propertyConstraints` is an -/// incompatible schema change on update. The moderation charters contract -/// declares its first one: a `submittedCharter`'s `rewardSplit` members add -/// up to 100, replacing the charter-specific check, whose error 11001 -/// keeps its place in `BasicError` but is never produced. +/// `count` counts an array or byte array property, every system time or +/// height a rule reads one the type lists in `required` (none on an +/// indexOnly type), every path compared with identifiers an identifier +/// property, every path compared with strings a string property (whose +/// `enum`, if it declares one, lists every constant it is compared with), +/// and every path `present` or `absent` tests a property of any type, none +/// transient nor inside a transient object; that every comparison and `in` +/// reads a property or the owner; that nothing is compared with itself; +/// that strings and identifiers are only compared for equality, and never +/// with each other; that no `in` lists a value twice; that an `anyOf` or +/// `allOf` holds none directly of its own kind and a `not` no `not`; that +/// an indexOnly type, whose deletes carry no owner, reads no `$ownerId`; +/// and that no condition or operand nests deeper than +/// `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every parse. Under full +/// validation it holds the limits `SystemLimits::max_property_constraints` +/// (16 rules) and `max_property_constraint_nodes` (32 per rule, every +/// comparison, `in`, listed value, `const`, presence test and logical +/// operator counting as one), and that no `anyOf` or `allOf` lists the same +/// condition twice. `DataContract::validate_document_properties` 0 +/// (extended in place, inert before this version, and taking the document's +/// owner for `$ownerId`) calls `validate_property_constraints` +/// (`validate_property_constraints` 0) after the schema validation, so +/// document create and replace, and any client validating a document, +/// refuse a broken rule with `DocumentPropertyConstraintViolatedError` +/// (10422), naming the rule and why. A transfer and a purchase, which give +/// the document a new owner, are judged against the rules reading +/// `$ownerId` or the transfer's time and heights, with the new values, and +/// a price update, which sets the update's time and heights, against the +/// rules reading those (`validate_property_constraints_for_system_change`, +/// in their structure validation, in place and inert before this version; +/// the price update's call is new there). `validate_document_properties` +/// takes the document version's system values (`DocumentSystemValues`): +/// consensus gives the writer and the block's time and heights on create, +/// and on replace the stored creation and transfer values with the block's +/// as the update. The rules read no state and change nothing stored. They +/// are fixed when the document type is created: a changed +/// `propertyConstraints` is an incompatible schema change on update. The +/// moderation charters contract declares its first one: a +/// `submittedCharter`'s `rewardSplit` members add up to 100, replacing the +/// charter-specific check, whose error 11001 keeps its place in +/// `BasicError` but is never produced. /// /// 40. **Elected moderation teams moderate from their stored charter**: seating /// writes nothing. Awarding the contest of item 37 writes the winning diff --git a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs index 560ff1b3c42..d8973a7eeee 100644 --- a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs +++ b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs @@ -28,6 +28,7 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; +use std::time::{SystemTime, UNIX_EPOCH}; use dash_sdk::dpp::consensus::basic::document::PropertyConstraintViolation; use dash_sdk::dpp::consensus::basic::BasicError; @@ -37,7 +38,9 @@ use dash_sdk::dpp::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; -use dash_sdk::dpp::data_contract::document_type::property_constraints::PropertyRead; +use dash_sdk::dpp::data_contract::document_type::property_constraints::{ + DocumentSystemValues, PropertyRead, +}; use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; use dash_sdk::dpp::document::{Document, DocumentV0Getters}; use dash_sdk::dpp::platform_value::Value; @@ -58,15 +61,18 @@ const PROPERTY_CONSTRAINTS_KEYWORD: &str = "propertyConstraints"; /// Get the `propertyConstraints` rules of a document type as a JSON array /// /// Each element is -/// `{ "name": string, "rule": object, "reads": [{ "path": string, "kind": string }], "readsOwner": bool }`: +/// `{ "name": string, "rule": object, "reads": [{ "path": string, "kind": string }], "readsOwner": bool, "readsSystem": [string] }`: /// the rule's name (its key in `propertyConstraints`), the rule exactly as the /// document type's schema declares it, every property it reads in declared /// order (`kind` is `"value"` for an integer operand, `"presence"` for /// `present` / `absent`, `"text"` for a string comparison, `"identifier"` for /// an identifier comparison, `"length"` for a `length` or `byteLength` operand /// and `"count"` for a `count` operand; `$ownerId` is no property and is not -/// listed), and whether it reads `$ownerId`, which makes a transfer or a -/// purchase answer to it too. Rules are listed in name order, the order +/// listed), whether it reads `$ownerId`, which makes a transfer or a +/// purchase answer to it too, and the system times and heights it reads +/// (`"$createdAt"`, ...), which make a price update answer to a rule reading +/// the update's and a transfer or purchase one reading the transfer's. Rules +/// are listed in name order, the order /// consensus checks them in. A document type declaring none gives `[]`, and so /// does every document type when the SDK's protocol version is below 14. /// @@ -129,6 +135,9 @@ pub unsafe extern "C" fn dash_sdk_data_contract_get_property_constraints( /// value is typed as it would be sent; the document is then judged by DPP's /// `validate_property_constraints`, the check consensus runs on a create: /// every rule, in name order, evaluated by `PropertyConstraint::violation`. +/// The device clock stands in for the block time the create records +/// (`$createdAt`, `$updatedAt`, `$transferredAt`), and a rule reading a block +/// height is not judged, since the height is unknown until the block. /// Nothing but the rules is checked: not the JSON schema, not the state. /// /// The result is the first rule broken, as @@ -310,23 +319,49 @@ fn property_constraints_json( "rule": rule, "reads": reads, "readsOwner": constraint.reads_owner(), + "readsSystem": constraint + .system_reads() + .into_iter() + .map(|property| property.name()) + .collect::>(), })); } Ok(serde_json::Value::Array(rules)) } +/// The system values a create of a document owned by `owner_id` will have, as +/// far as a client can tell before its block: the device clock stands in for +/// the block time it records as its creation, update and transfer, and the +/// block heights are unknown until the block, so a rule reading one is not +/// judged. +fn system_values_for_create(owner_id: Identifier) -> DocumentSystemValues { + // A clock before the epoch reads as the epoch; milliseconds fit a `u64` + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)) + .unwrap_or(0); + DocumentSystemValues { + created_at: Some(now), + updated_at: Some(now), + transferred_at: Some(now), + ..DocumentSystemValues::owned_by(owner_id) + } +} + /// The first rule of `document_type`'s `propertyConstraints` that `document` -/// breaks, judged as consensus judges a create (its properties, and its owner -/// for `$ownerId`), as the JSON `dash_sdk_data_contract_check_property_constraints` -/// returns: JSON `null` when it meets them all. +/// breaks, judged as consensus judges a create (its properties, its owner for +/// `$ownerId`, and the system times [`system_values_for_create`] estimates), +/// as the JSON `dash_sdk_data_contract_check_property_constraints` returns: +/// JSON `null` when it meets them all. fn property_constraint_violation_json( document_type: DocumentTypeRef<'_>, document: &Document, platform_version: &PlatformVersion, ) -> Result { let data = Value::from(document.properties().clone()); + let system = system_values_for_create(document.owner_id()); let result = document_type - .validate_property_constraints(&data, Some(document.owner_id()), platform_version) + .validate_property_constraints(&data, &system, platform_version) .map_err(|e| { DashSDKError::new( DashSDKErrorCode::ProtocolError, @@ -579,7 +614,8 @@ mod tests { { "path": "status", "kind": "text" }, { "path": "closedAt", "kind": "presence" } ], - "readsOwner": false + "readsOwner": false, + "readsSystem": [] }, { "name": "discountBelowPrice", @@ -588,7 +624,8 @@ mod tests { { "path": "discount", "kind": "value" }, { "path": "price", "kind": "value" } ], - "readsOwner": false + "readsOwner": false, + "readsSystem": [] }, { "name": "perUnitFee", @@ -597,7 +634,8 @@ mod tests { { "path": "price", "kind": "value" }, { "path": "fee", "kind": "value" } ], - "readsOwner": false + "readsOwner": false, + "readsSystem": [] }, { "name": "sellerIsOwner", @@ -606,13 +644,15 @@ mod tests { { "path": "sellerId", "kind": "presence" }, { "path": "sellerId", "kind": "identifier" } ], - "readsOwner": true + "readsOwner": true, + "readsSystem": [] }, { "name": "tieredFee", "rule": declared["tieredFee"], "reads": [{ "path": "fee", "kind": "value" }], - "readsOwner": false + "readsOwner": false, + "readsSystem": [] } ]) ); @@ -852,4 +892,70 @@ mod tests { ); } } + + /// A `listing` type recording its creation time and block height, with a + /// rule on each: it ends after its creation, and is listed from block 10 on. + fn timed_contract_bytes() -> Vec { + let platform_version = PlatformVersion::latest(); + let documents = platform_value!({ + "listing": { + "type": "object", + "properties": { + "endsAt": { "type": "integer", "minimum": 0, "position": 0 } + }, + "required": ["endsAt", "$createdAt", "$createdAtBlockHeight"], + "additionalProperties": false, + "propertyConstraints": { + "endsAfterCreation": { "greaterThan": ["endsAt", "$createdAt"] }, + "listedAfterHeight10": { + "greaterThanOrEqual": ["$createdAtBlockHeight", 10] + } + } + } + }); + DataContractFactory::new(platform_version.protocol_version) + .expect("factory for the protocol version") + .create_with_value_config(Identifier::new(OWNER), 1, documents, None, None) + .expect("listing contract") + .data_contract() + .serialize_to_bytes_with_platform_version(platform_version) + .expect("serialized contract") + } + + /// The pre-check reads the device clock for the times a create records, and + /// leaves a rule reading a block height unjudged, since the height is unknown + /// until the block. + #[test] + fn should_read_the_clock_for_system_times_and_skip_block_heights() { + let sdk = sdk_handle(PlatformVersion::latest()); + let contract = timed_contract_bytes(); + + let rules = rules_of(sdk, &contract, "listing"); + // Ended in 1970, before any create today + let ended = check(sdk, &contract, "listing", json!({ "endsAt": 1 }), OWNER); + // Ends in 2100; the height rule, which it would not meet at block 0, is skipped + let open = check( + sdk, + &contract, + "listing", + json!({ "endsAt": 4_102_444_800_000u64 }), + OWNER, + ); + destroy_mock_sdk_handle(sdk); + + let rules = rules.expect("rules of listing"); + assert_eq!(rules[0]["name"], "endsAfterCreation"); + assert_eq!(rules[0]["readsSystem"], json!(["$createdAt"])); + assert_eq!( + rules[0]["reads"], + json!([{ "path": "endsAt", "kind": "value" }]) + ); + assert_eq!(rules[1]["readsSystem"], json!(["$createdAtBlockHeight"])); + assert_eq!(rules[1]["reads"], json!([])); + + let ended = ended.expect("checked"); + assert_eq!(ended["rule"], "endsAfterCreation"); + assert_eq!(ended["violation"], "NotMet"); + assert_eq!(open.expect("checked"), serde_json::Value::Null); + } } diff --git a/packages/rs-sdk/src/platform/moderation_charters/requests.rs b/packages/rs-sdk/src/platform/moderation_charters/requests.rs index da40ec85cb4..a51d8402385 100644 --- a/packages/rs-sdk/src/platform/moderation_charters/requests.rs +++ b/packages/rs-sdk/src/platform/moderation_charters/requests.rs @@ -248,6 +248,7 @@ mod tests { use super::*; use crate::platform::encrypted_for::{decrypt_property, EncryptedPropertyEnvelope}; use dpp::dashcore::secp256k1::{PublicKey, Secp256k1}; + use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use dpp::data_contract::validate_document::DataContractDocumentValidationMethodsV0; use dpp::identity::contract_bounds::ContractBounds; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -412,7 +413,7 @@ mod tests { .validate_document_properties( &request.document_type_name, Value::from(properties.clone()), - None, + &DocumentSystemValues::default(), platform_version, ) .expect("runs"); diff --git a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs index d2457e3e409..eb1895b232b 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs @@ -18,7 +18,7 @@ use crate::error::{WasmDppError, WasmDppResult}; use dpp::consensus::basic::document::PropertyConstraintViolation; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; -use dpp::data_contract::document_type::property_constraints::PropertyRead; +use dpp::data_contract::document_type::property_constraints::{DocumentSystemValues, PropertyRead}; use dpp::document::{Document, DocumentV0Getters}; use dpp::platform_value::Value; use js_sys::{Array, BigInt, Object, Reflect}; @@ -34,7 +34,9 @@ const DOCUMENT_PROPERTY_CONSTRAINTS_TS: &'static str = r#" * (rules report such a literal as a `bigint`, exactly); * - a string: the dotted path of an integer or boolean property, whose value * it takes (a boolean reads as 1 for true and 0 for false), 0 when the - * document leaves the property out; + * document leaves the property out; or a system time or height the + * document type records by listing it in `required` + * (`PropertyConstraintSystemProperty`); * - `ifAbsent`: a property path and the integer it takes when left out; * - `add` and `multiply` over two or more operands, `subtract`, `divide`, * `modulo` and `power` over exactly two. Arithmetic is exact over 128-bit @@ -115,6 +117,24 @@ export type PropertyConstraintReadKind = | 'length' | 'count'; +/** + * A system time or height a rule reads: the block time in milliseconds + * (`At`), the Platform block height (`AtBlockHeight`) or the Core block height + * (`AtCoreBlockHeight`) of the document's creation, its last update (a create, + * a replace or a price update) and its last transfer (a create, a transfer or + * a purchase). + */ +export type PropertyConstraintSystemProperty = + | '$createdAt' + | '$updatedAt' + | '$transferredAt' + | '$createdAtBlockHeight' + | '$updatedAtBlockHeight' + | '$transferredAtBlockHeight' + | '$createdAtCoreBlockHeight' + | '$updatedAtCoreBlockHeight' + | '$transferredAtCoreBlockHeight'; + /** * A single `propertyConstraints` rule of a document type. */ @@ -127,6 +147,12 @@ export type DocumentPropertyConstraint = { reads: Array<{ path: string; kind: PropertyConstraintReadKind }>; /** Whether the rule reads `$ownerId`: then a transfer or a purchase is judged against it too. */ readsOwner: boolean; + /** + * The system times and heights the rule reads, in declared order. A price + * update is judged against a rule reading the update's, and a transfer or a + * purchase against one reading the transfer's. + */ + readsSystem: PropertyConstraintSystemProperty[]; }; /** @@ -312,15 +338,41 @@ pub(crate) fn property_constraints_for_document_type( &JsValue::from_bool(constraint.reads_owner()), name, )?; + let reads_system = Array::new(); + for property in constraint.system_reads() { + reads_system.push(&JsValue::from_str(property.name())); + } + set_field(&object, "readsSystem", &reads_system, name)?; rules.push(&object); } Ok(rules) } +/// The system values a create or a replace of `document` will have, as far as +/// a client can tell before its block: the owner, and the stored times and +/// heights the write keeps, with the device clock standing in for the block +/// time it records (its update, and its creation and transfer when the +/// document has none yet). The block heights it records are unknown until the +/// block, so a rule reading one is not judged. +fn system_values_for_write(document: &Document) -> DocumentSystemValues { + // Milliseconds since the epoch, a whole number well inside a `u64` + let now = js_sys::Date::now() as u64; + let stored = DocumentSystemValues::of_document(document); + DocumentSystemValues { + created_at: stored.created_at.or(Some(now)), + updated_at: Some(now), + transferred_at: stored.transferred_at.or(Some(now)), + updated_at_block_height: None, + updated_at_core_block_height: None, + ..stored + } +} + /// The first rule of `document_type`'s `propertyConstraints` that `document` /// breaks, in name order, as consensus judges a create or replace: its -/// properties, and its owner for `$ownerId`. `undefined` when it meets them +/// properties, its owner for `$ownerId`, and its system times and heights as +/// [`system_values_for_write`] estimates them. `undefined` when it meets them /// all. pub(crate) fn check_property_constraints( document_type: DocumentTypeRef<'_>, @@ -331,8 +383,9 @@ pub(crate) fn check_property_constraints( return Ok(JsValue::UNDEFINED); } let data = Value::from(document.properties().clone()); + let system = system_values_for_write(document); for (name, constraint) in constraints { - if let Some(violation) = constraint.violation(&data, Some(document.owner_id())) { + if let Some(violation) = constraint.violation(&data, &system) { let object = Object::new(); set_field(&object, "rule", &JsValue::from_str(name), name)?; set_field( diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index be6e7cbcb48..50071335c48 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -874,8 +874,12 @@ impl DataContractWasm { /// The first `propertyConstraints` rule `document` breaks, in name order, /// evaluated with the same code consensus runs on a create or replace: its - /// properties, and its owner for `$ownerId`. `undefined` when it meets - /// every rule of its document type. + /// properties, its owner for `$ownerId`, and for the system times the + /// device clock in place of the block time the write will record (the + /// document's stored creation and transfer times when it has them). A + /// rule reading a block height the write records is not judged, since the + /// height is unknown until the block. `undefined` when it meets every rule + /// of its document type. /// /// A pre-check, so an app can refuse a document before paying for a /// transition consensus would refuse with diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts index 6352f9153ce..c299a7014ab 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts @@ -110,6 +110,7 @@ describe('DataContract: propertyConstraints (v14)', () => { { path: 'closedAt', kind: 'presence' }, ], readsOwner: false, + readsSystem: [], }, { name: 'discountBelowPrice', @@ -119,6 +120,7 @@ describe('DataContract: propertyConstraints (v14)', () => { { path: 'price', kind: 'value' }, ], readsOwner: false, + readsSystem: [], }, { name: 'perUnitFee', @@ -128,6 +130,7 @@ describe('DataContract: propertyConstraints (v14)', () => { { path: 'fee', kind: 'value' }, ], readsOwner: false, + readsSystem: [], }, { name: 'sellerIsOwner', @@ -137,12 +140,14 @@ describe('DataContract: propertyConstraints (v14)', () => { { path: 'sellerId', kind: 'identifier' }, ], readsOwner: true, + readsSystem: [], }, { name: 'tieredFee', rule: schemas.offer.propertyConstraints.tieredFee, reads: [{ path: 'fee', kind: 'value' }], readsOwner: false, + readsSystem: [], }, ]); }); @@ -196,12 +201,14 @@ describe('DataContract: propertyConstraints (v14)', () => { rule: rules.tagsWithinLimit, reads: [{ path: 'tags', kind: 'count' }, { path: 'maxTags', kind: 'value' }], readsOwner: false, + readsSystem: [], }, { name: 'titleBytes', rule: rules.titleBytes, reads: [{ path: 'title', kind: 'length' }], readsOwner: false, + readsSystem: [], }, ]); @@ -224,6 +231,56 @@ describe('DataContract: propertyConstraints (v14)', () => { )).to.deep.include({ rule: 'titleBytes', violation: 'NotMet' }); }); + it('should list system times it reads, and check them with the device clock', () => { + const rules = { + endsAfterCreation: { greaterThan: ['endsAt', '$createdAt'] }, + listedAfterHeight10: { greaterThanOrEqual: ['$createdAtBlockHeight', 10] }, + }; + const contract = buildContract({ + listing: { + type: 'object', + properties: { + endsAt: { type: 'integer', minimum: 0, position: 0 }, + }, + required: ['endsAt', '$createdAt', '$createdAtBlockHeight'], + additionalProperties: false, + propertyConstraints: rules, + }, + }); + + expect(contract.documentTypePropertyConstraints('listing')).to.deep.equal([ + { + name: 'endsAfterCreation', + rule: rules.endsAfterCreation, + reads: [{ path: 'endsAt', kind: 'value' }], + readsOwner: false, + readsSystem: ['$createdAt'], + }, + { + name: 'listedAfterHeight10', + rule: rules.listedAfterHeight10, + reads: [], + readsOwner: false, + readsSystem: ['$createdAtBlockHeight'], + }, + ]); + + const listing = (endsAt: number) => new wasm.Document({ + properties: { endsAt }, + documentTypeName: 'listing', + dataContractId: contract.id, + ownerId, + revision: BigInt(1), + }); + // Ended in 1970: before a create today, by the device clock + expect(contract.checkDocumentPropertyConstraints(listing(1))) + .to.deep.include({ rule: 'endsAfterCreation', violation: 'NotMet' }); + // Ends in 2100; the block height a create records is unknown before its + // block, so the height rule is not judged + expect(contract.checkDocumentPropertyConstraints(listing(4102444800000))) + .to.equal(undefined); + }); + it('should report integer literals past Number.MAX_SAFE_INTEGER exactly, as bigint', () => { const big = 9007199254740993n; // 2 ** 53 + 1, which a number rounds const rules = { @@ -247,12 +304,14 @@ describe('DataContract: propertyConstraints (v14)', () => { rule: rules.balanceBelowCap, reads: [{ path: 'balance', kind: 'value' }], readsOwner: false, + readsSystem: [], }, { name: 'knownTier', rule: rules.knownTier, reads: [{ path: 'tier', kind: 'value' }], readsOwner: false, + readsSystem: [], }, ]; From 37a7785a7bd91b4545afcd4cb17dac1d1977e051 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 05:16:14 +0700 Subject: [PATCH 054/113] fix(wasm-sdk): leave price tier validation to rs-dpp (#5058) Co-authored-by: Claude Opus 5.5 --- .../wasm-sdk/src/state_transitions/token.rs | 89 ++++--------------- 1 file changed, 17 insertions(+), 72 deletions(-) diff --git a/packages/wasm-sdk/src/state_transitions/token.rs b/packages/wasm-sdk/src/state_transitions/token.rs index f06206499c2..061f61e44dd 100644 --- a/packages/wasm-sdk/src/state_transitions/token.rs +++ b/packages/wasm-sdk/src/state_transitions/token.rs @@ -2027,33 +2027,6 @@ fn deserialize_token_set_price_options( ) } -/// Validate a constructed `priceTiers` map. -/// -/// Rejects: -/// - empty maps (caller must specify at least one tier) -/// - a `0` minimum bulk-buy amount (use the flat `price` field for that) -/// -/// A `0` credits value is permitted — it mirrors the lower/consensus -/// `SetPrices` schedule, which allows zero-credit tiers for free -/// direct purchases at that bulk amount. -/// -/// Pure function so it can be unit-tested without a JS runtime. -fn validate_price_tiers(tiers: &BTreeMap) -> Result<(), WasmSdkError> { - if tiers.is_empty() { - return Err(WasmSdkError::invalid_argument( - "'priceTiers' must contain at least one entry", - )); - } - for amount in tiers.keys() { - if *amount == 0 { - return Err(WasmSdkError::invalid_argument( - "'priceTiers' minimum bulk-buy amount must be > 0; use 'price' for a flat single-token price", - )); - } - } - Ok(()) -} - /// Build a `priceTiers` map from already-parsed `(originalKey, amount, credits)` entries. /// /// Detects keys that parse to the same `TokenAmount` (e.g. `"1"` and `"01"`) and rejects @@ -2076,7 +2049,8 @@ fn build_price_tiers( original_keys.insert(amount, key_str); tiers.insert(amount, credits); } - validate_price_tiers(&tiers)?; + // Whether the schedule is valid (at least one tier) is decided once, by rs-dpp's + // structure validation of the transition, not here. Ok(tiers) } @@ -2106,8 +2080,10 @@ fn validate_pricing_mode_selection( /// Extract the optional `priceTiers` field from the raw JS options object. /// /// Returns `Ok(None)` when the field is absent, null, or undefined. -/// Returns `Err` when the field is present but malformed (wrong type, -/// empty, non-numeric keys, non-bigint/integer values, zero amount key, etc.). +/// Returns `Err` when the field is present but cannot be converted: not an +/// object, non-numeric keys, non-bigint/integer values, or two keys that parse to +/// the same token amount. An empty map and a zero amount key pass through; +/// whether the schedule is valid is left to rs-dpp's structure validation. fn extract_price_tiers( options: &JsValue, ) -> Result>, WasmSdkError> { @@ -2751,52 +2727,21 @@ impl WasmSdk { mod tests { use super::*; - /// A single non-zero tier passes validation. - #[test] - fn validate_price_tiers_accepts_single_tier() { - let tiers: BTreeMap = BTreeMap::from([(1u64, 1_000u64)]); - validate_price_tiers(&tiers).expect("single non-zero tier should validate"); - } - - /// Multiple non-zero tiers pass validation. - #[test] - fn validate_price_tiers_accepts_multiple_tiers() { - let tiers: BTreeMap = - BTreeMap::from([(1u64, 1_000u64), (100u64, 900u64), (1000u64, 800u64)]); - validate_price_tiers(&tiers).expect("multi-tier schedule should validate"); - } - - /// An empty tier map is rejected — the caller must specify at least one tier. + /// An empty tier map is passed through as an empty schedule: rs-dpp's structure + /// validation refuses it, not the SDK. #[test] - fn validate_price_tiers_rejects_empty() { - let tiers: BTreeMap = BTreeMap::new(); - let err = validate_price_tiers(&tiers).expect_err("empty tiers should be rejected"); - assert!( - err.message().contains("at least one entry"), - "unexpected error message: {}", - err.message() - ); - } - - /// A `0` minimum bulk-buy amount is rejected — direct callers should use `price`. - #[test] - fn validate_price_tiers_rejects_zero_amount_key() { - let tiers: BTreeMap = BTreeMap::from([(0u64, 1_000u64)]); - let err = validate_price_tiers(&tiers).expect_err("zero amount key should be rejected"); - assert!( - err.message().contains("amount must be > 0"), - "unexpected error message: {}", - err.message() - ); + fn should_pass_an_empty_tier_map_through() { + let tiers = build_price_tiers(Vec::new()).expect("an empty map should build"); + assert!(tiers.is_empty()); } - /// A `0` per-token price is accepted — mirrors lower/consensus `SetPrices`, - /// which permits zero-credit tiers for free direct purchases. + /// A tier at token amount `0` is passed through unchanged: rs-dpp accepts it (a purchase + /// of any amount falls in it), so the SDK does not refuse it either. #[test] - fn validate_price_tiers_accepts_zero_credits() { - let tiers: BTreeMap = - BTreeMap::from([(1u64, 1_000u64), (100u64, 0u64)]); - validate_price_tiers(&tiers).expect("zero credits tier should validate"); + fn should_keep_a_zero_amount_tier() { + let entries = vec![("0".to_string(), 0u64, 1_000u64)]; + let tiers = build_price_tiers(entries).expect("a zero amount tier should build"); + assert_eq!(tiers, BTreeMap::from([(0u64, 1_000u64)])); } /// Distinct token amount keys build a tier map preserving all entries. From 406ca9a6d51491353e5db61c73965e9c33620841 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 05:17:06 +0700 Subject: [PATCH 055/113] docs: list the complete contract language in the keywords overview (#5082) Co-authored-by: Claude Opus 5.5 --- book/src/contract-keywords.md | 279 ++++++++++++++---- book/src/contract-keywords/contract-config.md | 4 +- 2 files changed, 227 insertions(+), 56 deletions(-) diff --git a/book/src/contract-keywords.md b/book/src/contract-keywords.md index 88785d37953..546e8b4fc23 100644 --- a/book/src/contract-keywords.md +++ b/book/src/contract-keywords.md @@ -59,63 +59,234 @@ The document meta-schema has changed three times: Most keywords of v0 took effect at protocol version 1. The exceptions are `tokenCost` (9) and the index keyword `countable` (12). -## Every keyword - -### Document type keywords - -| Keyword | Chapter | -|---|---| -| `$comment`, `$defs`, `$schema`, `additionalProperties`, `dependentRequired`, `description`, `maxProperties`, `minProperties`, `properties`, `required`, `type` | [Document Shape](contract-keywords/document-shape.md) | -| `actionFees` | [Action Fees](contract-keywords/action-fees.md) | -| `canBeDeleted`, `canBeDeletedByModerators`, `canBeDeletedByModeratorsFor` | [Deletion](contract-keywords/deletion.md) | -| `creationRestrictionMode`, `tradeMode`, `transferable` | [Creation, Transfers and Trading](contract-keywords/ownership-and-trading.md) | -| `creatorRefersTo`, `ownerRefersTo` | [Writer and Creator References](contract-keywords/owner-refers-to.md) | -| `documentsAverageable`, `documentsCountable`, `documentsSummable`, `rangeAverageable`, `rangeCountable`, `rangeSummable` | [Counts, Sums and Averages](contract-keywords/aggregates.md) | -| `documentsKeepHistory`, `keepsPricingHistory`, `keepsPurchaseHistory`, `keepsTransferHistory` | [History](contract-keywords/history.md) | -| `documentsMutable`, `immutable`, `immutableAllowSetting` | [Mutability](contract-keywords/mutability.md) | -| `entryPayload`, `indexOnly` | [Index-Only Types](contract-keywords/index-only.md) | -| `indices` | [Indexes](contract-keywords/indexes.md) | -| `propertyConstraints` | [propertyConstraints](contract-keywords/property-constraints.md) | -| `requiresIdentityDecryptionBoundedKey`, `requiresIdentityEncryptionBoundedKey`, `signatureSecurityLevelRequirement` | [Signing and Keys](contract-keywords/signing-keys.md) | -| `tokenCost` | [Token Costs](contract-keywords/token-cost.md) | -| `transient` | [transient](contract-keywords/transient.md) | -| `ttl` | [Time To Live](contract-keywords/ttl.md) | - -### Property keywords - -| Keyword | Chapter | -|---|---| -| `$comment`, `$id`, `$ref`, `additionalProperties`, `byteArray`, `const`, `contains`, `contentMediaType`, `dependentRequired`, `description`, `enum`, `examples`, `exclusiveMaximum`, `exclusiveMinimum`, `format`, `maxItems`, `maxLength`, `maxProperties`, `maximum`, `minItems`, `minLength`, `minProperties`, `minimum`, `multipleOf`, `pattern`, `position`, `properties`, `required`, `type`, `uniqueItems` | [Property Schemas](contract-keywords/property-schemas.md) | -| `distinctFrom` | [distinctFrom](contract-keywords/distinct-from.md) | -| `encryptedFor` | [encryptedFor](contract-keywords/encrypted-for.md) | -| `items` | [Typed Arrays](contract-keywords/typed-arrays.md) | -| `maxBytes` | [maxBytes](contract-keywords/max-bytes.md) | -| `refersTo` | [References](contract-keywords/refers-to.md) | -| `requiredSince` | [requiredSince](contract-keywords/required-since.md) | - -### Inside `refersTo` - -| Keyword | Chapter | -|---|---| -| `contractId`, `contractRequirements`, `documentType`, `identityProperty`, `keyIdProperty`, `keyRequirements`, `propertyAgreement`, `type` | [References](contract-keywords/refers-to.md) | -| `lookup` | [Lookups](contract-keywords/refers-to-lookup.md) | -| `anyOf`, `allOf` | [Expressions](contract-keywords/refers-to-expressions.md) | -| `inList`, `listElement` | [List Elements](contract-keywords/refers-to-list-element.md) | - -### Index keywords - -| Keyword | Chapter | -|---|---| -| `name`, `nullSearchable`, `properties`, `unique` | [Indexes](contract-keywords/indexes.md) | -| `contested` | [Contested Indexes](contract-keywords/contested.md) | -| `averageable`, `countable`, `rangeAverageable`, `rangeCountable`, `rangeSummable`, `summable` | [Counts, Sums and Averages](contract-keywords/aggregates.md) | -| `rankedAverageable`, `rankedCountable`, `rankedSummable` | [Ranked Indexes](contract-keywords/ranked.md) | -| `timeRange` | [Time-Range Indexes](contract-keywords/time-range.md) | -| `preallocated`, `skipIfAbsent`, `terminal` | [Index-Only Types](contract-keywords/index-only.md) | +## The complete language + +Every key a contract can write, grouped by where it goes. **Since** is the protocol version from which the key works. **Read more** links to the key's section in this part and, where there is one, to the chapter with the internals. A dotted name such as `tokenCost..amount` is a key written inside the ones before it. + +### The contract + +| Key | Takes | What it does | Since | Read more | +|---|---|---|---|---| +| `$formatVersion` | `"0"` or `"1"` | The contract's serialization format. `"1"`, the default from 9, carries `groups`, `tokens`, `keywords`, `description` and the timestamps. | 1 | [Contract keys](contract-keywords/contract-config.md#contract-keys) | +| `id` | identifier | The contract's id: a hash of `ownerId` and the identity nonce of the create transition. | 1 | [Contract keys](contract-keywords/contract-config.md#contract-keys) | +| `ownerId` | identifier | The identity that registers the contract, and the only one that may update it. | 1 | [Contract keys](contract-keywords/contract-config.md#contract-keys) | +| `version` | integer | 1 at creation; every update raises it by exactly one. | 1 | [Contract keys](contract-keywords/contract-config.md#contract-keys) | +| `config` | object | Contract-wide settings, [below](#config). | 1 | [config](contract-keywords/contract-config.md#config) | +| `documentSchemas` | object of document types | The document types by name, each written with the [document type keys](#document-type). | 1 | [documentSchemas](contract-keywords/contract-config.md#documentschemas) | +| `schemaDefs` | object | Definitions any property may point at with `$ref`. | 1 | [Document Shape](contract-keywords/document-shape.md#schema-and-defs) | +| `groups` | object | Sets of identities, each member with a voting power, whose approval some token actions need. | 9 | [Data Contracts](data-model/data-contracts.md#what-v1-added) | +| `tokens` | object | The contract's tokens, by position. Their configuration is not covered in this part. | 9 | [Data Contracts](data-model/data-contracts.md#what-v1-added) · [Creating a Basic Token](evo-sdk/tutorials/basic-token.md) | +| `keywords` | up to 50 strings of 3 to 50 bytes | Search keywords, for the keyword search contract. | 9 | [keywords and description](contract-keywords/contract-config.md#keywords-and-description) | +| `description` | string of 3 to 100 bytes | A short description, for the keyword search contract. | 9 | [keywords and description](contract-keywords/contract-config.md#keywords-and-description) | +| `createdAt`, `updatedAt`, `createdAtBlockHeight`, `updatedAtBlockHeight`, `createdAtEpoch`, `updatedAtEpoch` | numbers | When the contract was created and last updated. Set by the platform, never written. | 9 | [Contract keys](contract-keywords/contract-config.md#contract-keys) | +| `contractGroup` | `{ "admins", "name", "description" }` | On the create transition, beside the contract: registers a contract group, a set of contracts the signer owns, with up to 16 `admins`, a `name` of 1 to 64 characters and a `description` of 1 to 256. | 14 | [Contract Groups](data-model/contract-groups.md) | +| `contractGroupMemberships` | up to 16 `{ "contractGroupId", "member" }` | On the create transition: enrols the new contract (`"contract"`), one of its document types (`{ "documentType": ... }`) or one of its tokens (`{ "token": ... }`) in contract groups. | 14 | [Contract Groups](data-model/contract-groups.md) | + +### config + +| Key | Takes | What it does | Since | Read more | +|---|---|---|---|---| +| `canBeDeleted` | boolean, default `false` | Whether the contract may ever be deleted. No transition deletes a contract today. | 1 | [canBeDeleted](contract-keywords/contract-config.md#canbedeleted) | +| `readonly` | boolean, default `false` | `true`: the contract can never be updated. | 1 | [readonly](contract-keywords/contract-config.md#readonly) | +| `keepsHistory` | boolean, default `false` | Drive keeps every version of the contract. | 1 | [keepsHistory](contract-keywords/contract-config.md#keepshistory) | +| `documentsKeepHistoryContractDefault` | boolean, default `false` | `documentsKeepHistory` for a document type that does not say. | 1 | [Document type defaults](contract-keywords/contract-config.md#document-type-defaults) | +| `documentsMutableContractDefault` | boolean, default `true` | `documentsMutable` for a document type that does not say. | 1 | [Document type defaults](contract-keywords/contract-config.md#document-type-defaults) | +| `documentsCanBeDeletedContractDefault` | boolean, default `true` | `canBeDeleted` for a document type that does not say. | 1 | [Document type defaults](contract-keywords/contract-config.md#document-type-defaults) | +| `requiresIdentityEncryptionBoundedKey`, `requiresIdentityDecryptionBoundedKey` | `0` unique, `1` multiple, `2` multiple with a pointer to the latest | Lets identities bind encryption or decryption keys to the whole contract, and says how they are kept. | 1 | [Bounded key requirements](contract-keywords/contract-config.md#bounded-key-requirements) · [Contract Bounds](sdk/identity-keys.md#contract-bounds) | +| `sizedIntegerTypes` | boolean, default `true` | Stores each integer in the smallest width its bounds allow, instead of 8 bytes. | 9 | [sizedIntegerTypes](contract-keywords/contract-config.md#sizedintegertypes) | +| `moderation` | object | Makes the contract moderated: which lists it keeps and who moderates. | 14 | [moderation](contract-keywords/contract-config.md#moderation) · [Contract Moderation](data-model/contract-moderation.md) | +| `moderation.banlist`, `.suspensions`, `.warnings` | boolean, default `false` | Keeps a banlist, a suspension list, a warning list. A banned or suspended identity cannot act on the contract's documents; a warning bars nothing. | 14 | [The Model](data-model/contract-moderation.md#the-model) | +| `moderation.moderators` | `{ "$type": ... }` | Who moderates: `"contractOwner"`, `"appointedModerators"` with `identities` (1 to 16), or `"elected"` with the keys below. | 14 | [moderation](contract-keywords/contract-config.md#moderation) | +| `moderators.seatContestable` | boolean, required when elected | Whether a seated team may later be challenged. | 14 | [Elected Moderation](data-model/contract-moderation.md#elected-moderation) | +| `moderators.challengeCoolDown` | seconds, two weeks to three years | How long a seated team is safe from a challenge after a seat change. Required when the seat is contestable, refused when it is not. | 14 | [Elected Moderation](data-model/contract-moderation.md#elected-moderation) | +| `moderators.moderatedDocumentTypes` | object: document type → abilities | The document types the team moderates, each with its abilities: `ban`, `suspend`, `warn`, `deleteDocuments`. | 14 | [Elected Moderation](data-model/contract-moderation.md#elected-moderation) | +| `moderators.interim` | `{ "$type": ... }` | Who moderates until a team is seated: `"contractOwner"`, `"appointedModerators"`, `"notYetUsable"` (the moderated types cannot be used yet) or `"noModeration"`. | 14 | [Elected Moderation](data-model/contract-moderation.md#elected-moderation) | +| `moderators.joinWindow`, `.voteWindow` | seconds | How long applicants may join an election, and how long masternodes then vote. | 14 | [Elected Moderation](data-model/contract-moderation.md#elected-moderation) | +| `moderators.electionDelay` | seconds | How long after the contract's creation the first election may be called. | 14 | [Elected Moderation](data-model/contract-moderation.md#elected-moderation) | +| `moderators.maxAddedModerators` | 0 to 15, default 0 | How many members the seated leader may add after the election. | 14 | [Elected Moderation](data-model/contract-moderation.md#elected-moderation) | +| `moderators.ownerProtected` | boolean, default `false` | Protects the contract owner from the seated team. | 14 | [Elected Moderation](data-model/contract-moderation.md#elected-moderation) | + +### Document type + +| Key | Takes | What it does | Since | Read more | +|---|---|---|---|---| +| `type` | `"object"` | Required. A document is an object. | 1 | [type](contract-keywords/document-shape.md#type) | +| `properties` | object of 1 to 100 properties | The document's properties, each written with the [property keys](#property). | 1 | [properties](contract-keywords/document-shape.md#properties) | +| `required` | array of names | The properties every document holds. A system time or height listed here is recorded. | 1 | [required](contract-keywords/document-shape.md#required) | +| `additionalProperties` | `false` | Required: a document holds only the declared properties. | 1 | [additionalProperties](contract-keywords/document-shape.md#additionalproperties) | +| `minProperties`, `maxProperties` | integer | How many properties a document holds. | 1 | [minProperties and maxProperties](contract-keywords/document-shape.md#minproperties-and-maxproperties) | +| `dependentRequired` | object | A property that requires others when present. | 1 | [dependentRequired](contract-keywords/document-shape.md#dependentrequired) | +| `$comment`, `description` | string | Notes; consensus ignores them. | 1 | [$comment and description](contract-keywords/document-shape.md#comment-and-description) | +| `$schema`, `$defs` | added by the platform | The meta-schema URL and the contract's `schemaDefs`. A document type writing either is refused. | 1 | [$schema and $defs](contract-keywords/document-shape.md#schema-and-defs) | +| `transient` | array of top-level names | Properties validated on the transition but never stored. | 1 | [transient](contract-keywords/transient.md) · [internals](data-model/documents.md#transient-properties) | +| `documentsMutable` | boolean, default `true` | `false`: documents cannot be replaced. | 1 | [documentsMutable](contract-keywords/mutability.md#documentsmutable) | +| `immutable` | array of top-level names | Properties frozen at creation on a mutable type. | 14 | [immutable](contract-keywords/mutability.md#immutable) · [internals](data-model/documents.md#immutable-properties-on-mutable-document-types) | +| `immutableAllowSetting` | array of names from `immutable` | Immutable properties a replace may still set once, while they have no value. | 14 | [immutableAllowSetting](contract-keywords/mutability.md#immutableallowsetting) | +| `canBeDeleted` | boolean, default `true` | `false`: a document's owner cannot delete it. | 1 | [canBeDeleted](contract-keywords/deletion.md#canbedeleted) | +| `canBeDeletedByModerators` | boolean | The contract's moderators may delete documents of the type. | 14 | [canBeDeletedByModerators](contract-keywords/deletion.md#canbedeletedbymoderators) · [internals](data-model/contract-moderation.md#deleting-documents) | +| `canBeDeletedByModeratorsFor` | seconds | Limits moderator deletion to this long after a document's last change. | 14 | [canBeDeletedByModeratorsFor](contract-keywords/deletion.md#canbedeletedbymoderatorsfor) | +| `ttl` | seconds, 3600 to 31536000 | The platform deletes each document this long after its creation. | 14 | [Time To Live](contract-keywords/ttl.md) · [internals](data-model/document-ttl.md) | +| `creationRestrictionMode` | `0` anyone, `1` contract owner, `2` nobody | Who may create documents. | 1 | [creationRestrictionMode](contract-keywords/ownership-and-trading.md#creationrestrictionmode) | +| `transferable` | `0` never, `1` always | Whether an owner may give a document to another identity. | 1 | [transferable](contract-keywords/ownership-and-trading.md#transferable) | +| `tradeMode` | `0` none, `1` direct purchase | Whether an owner may set a price and anyone buy at it. | 1 | [tradeMode](contract-keywords/ownership-and-trading.md#trademode) | +| `documentsKeepHistory` | boolean, default `false` | Drive keeps every revision of every document. | 1 | [documentsKeepHistory](contract-keywords/history.md#documentskeephistory) | +| `keepsTransferHistory`, `keepsPurchaseHistory`, `keepsPricingHistory` | boolean, default `false` | Records every transfer, purchase or price update in the document history contract. | 13 | [History](contract-keywords/history.md#keepstransferhistory) | +| `signatureSecurityLevelRequirement` | `1` critical, `2` high (default), `3` medium | The weakest key level that may sign a transition on the type. | 1 | [signatureSecurityLevelRequirement](contract-keywords/signing-keys.md#signaturesecuritylevelrequirement) · [Security Level](sdk/identity-keys.md#security-level) | +| `requiresIdentityEncryptionBoundedKey`, `requiresIdentityDecryptionBoundedKey` | `0` unique, `1` multiple, `2` multiple with a pointer to the latest | Lets identities bind encryption or decryption keys to the type, and says how they are kept. | 1 | [Signing and Keys](contract-keywords/signing-keys.md#requiresidentityencryptionboundedkey) · [Contract Bounds](sdk/identity-keys.md#contract-bounds) | +| `ownerRefersTo` | a [`refersTo`](#refersto) declaration | A reference the writer must meet, on types whose documents are never transferred or traded. | 14 | [ownerRefersTo](contract-keywords/owner-refers-to.md#ownerrefersto) · [internals](data-model/documents.md#on-the-writer-or-the-creator-ownerrefersto-creatorrefersto) | +| `creatorRefersTo` | a [`refersTo`](#refersto) declaration | A reference the creator must meet, on types whose documents can be transferred or traded. | 14 | [creatorRefersTo](contract-keywords/owner-refers-to.md#creatorrefersto) | +| `propertyConstraints` | object of named rules, at most 16 | Rules over several properties every created or replaced document meets. See the [operators](#propertyconstraints). | 14 | [propertyConstraints](contract-keywords/property-constraints.md) · [internals](data-model/documents.md#property-constraints-propertyconstraints) | +| `tokenCost` | object keyed by action | Token payments for actions on documents. See the [keys](#tokencost-and-actionfees). | 9 | [Token Costs](contract-keywords/token-cost.md) · [Fees](fees/overview.md#gas-paid-by-the-contract-owner) | +| `actionFees` | object keyed by action | Credit fees for actions on documents, paid to the owner's and moderators' pots. See the [keys](#tokencost-and-actionfees). | 14 | [Action Fees](contract-keywords/action-fees.md) · [Fees](fees/overview.md#document-action-fees) | +| `indices` | array of 1 to 10 indexes | The indexes documents are queried by, each written with the [index keys](#index). | 1 | [Indexes](contract-keywords/indexes.md#indices) · [internals](drive/indexes.md) | +| `documentsCountable` | boolean | Keeps a count of the type's documents. | 12 | [documentsCountable](contract-keywords/aggregates.md#documentscountable) · [internals](drive/document-count-trees.md#primary-key-tree-flags) | +| `documentsSummable` | property name | Keeps the sum of one integer property over the type's documents. | 12 | [documentsSummable](contract-keywords/aggregates.md#documentssummable) · [internals](drive/document-sum-trees.md#primary-key-tree-flags) | +| `documentsAverageable` | property name | Shorthand for `documentsCountable` plus `documentsSummable`. | 12 | [documentsAverageable](contract-keywords/aggregates.md#documentsaverageable) | +| `rangeCountable`, `rangeSummable`, `rangeAverageable` | boolean | Provable counts, sums or averages over ranges of document ids. | 12 | [Document type range keys](contract-keywords/aggregates.md#document-type-rangecountable-rangesummable-rangeaverageable) | +| `indexOnly` | boolean | Documents are never stored whole: the index entries are the rows. | 14 | [indexOnly](contract-keywords/index-only.md#indexonly) · [internals](drive/index-only-document-types.md) | +| `entryPayload` | array of 1 to 16 names | On an index-only type, properties carried in each entry's value instead of a key. | 14 | [entryPayload](contract-keywords/index-only.md#entrypayload) | + +### Property + +| Key | Takes | What it does | Since | Read more | +|---|---|---|---|---| +| `type` | `string`, `integer`, `number`, `boolean`, `object`, `array` | The kind of value. An array is a byte array or a typed array. | 1 | [type](contract-keywords/property-schemas.md#type) | +| `position` | integer | The property's place in the stored document. Required; top-level positions run 0, 1, 2 with no gap. | 1 | [position](contract-keywords/property-schemas.md#position) · [Document Serialization](serialization/document-serialization.md) | +| `minLength`, `maxLength` | integer | A string's length in characters. | 1 | [Strings](contract-keywords/property-schemas.md#strings) | +| `pattern` | regular expression | A string must match it. Needs `maxLength` of at most 50000. | 1 | [Strings](contract-keywords/property-schemas.md#strings) | +| `format` | `date-time`, `date`, `time`, `email`, `idn-email`, `hostname`, `ipv4`, `ipv6`, `uri`, `regex` | A string must have this format. Needs `maxLength` of at most 50000. | 1 | [Strings](contract-keywords/property-schemas.md#strings) | +| `maxBytes` | 1 to 65535 | The most UTF-8 bytes a string may take. | 14 | [maxBytes](contract-keywords/max-bytes.md) · [internals](data-model/documents.md#byte-caps-on-strings-maxbytes) | +| `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` | number | Numeric bounds. `minimum` and `maximum` also decide an integer's stored width. | 1 | [Numbers](contract-keywords/property-schemas.md#numbers) | +| `enum`, `const` | values | The values allowed, or the one value allowed. | 1 | [enum and const](contract-keywords/property-schemas.md#enum-and-const) | +| `byteArray` | `true` | Makes an array a string of bytes, stored raw. | 1 | [Byte arrays and identifiers](contract-keywords/property-schemas.md#byte-arrays-and-identifiers) | +| `contentMediaType` | `"application/x.dash.dpp.identifier"` | Makes a 32-byte array an identifier. | 1 | [Byte arrays and identifiers](contract-keywords/property-schemas.md#byte-arrays-and-identifiers) | +| `minItems`, `maxItems`, `uniqueItems`, `contains` | integer, boolean, schema | A byte array's length in bytes, or a typed array's number of elements (at most 1024); no repeats; an element that matches. | 1 | [Arrays](contract-keywords/property-schemas.md#arrays) | +| `items` | an element schema | Makes an array a typed array whose elements all follow this schema. | 14 | [Typed Arrays](contract-keywords/typed-arrays.md) · [internals](data-model/documents.md#typed-arrays) | +| `properties`, `required`, `additionalProperties`, `minProperties`, `maxProperties`, `dependentRequired` | as on a document type | A nested object's members and its bounds. | 1 | [Objects](contract-keywords/property-schemas.md#objects) | +| `$ref` | `"#/$defs/"` | Uses a definition from the contract's `schemaDefs`. | 1 | [$ref](contract-keywords/property-schemas.md#ref) | +| `$id`, `$comment`, `description`, `examples` | annotations | Notes; consensus ignores them. | 1 | [Annotations](contract-keywords/property-schemas.md#annotations) | +| `requiredSince` | contract version | Lets an update add a required property that older documents may leave out. | 14 | [requiredSince](contract-keywords/required-since.md) · [internals](data-model/data-contracts.md#evolving-a-contract-adding-required-fields) | +| `distinctFrom` | a property path or `"$ownerId"` | An identifier must differ from another identifier of the document, or from the owner. | 14 | [distinctFrom](contract-keywords/distinct-from.md) · [internals](data-model/documents.md#distinct-identifier-properties) | +| `encryptedFor` | `{ "recipient", "recipientKey", "senderKey", "scheme" }` | Declares how an encrypted byte array was made: whose keys, which scheme. | 14 | [encryptedFor](contract-keywords/encrypted-for.md) · [internals](data-model/documents.md#encrypted-properties-encryptedfor) | +| `encryptedFor.recipient` | identifier property path or `"$ownerId"` | The identity the value is encrypted to. | 14 | [encryptedFor](contract-keywords/encrypted-for.md#example) | +| `encryptedFor.recipientKey`, `.senderKey` | integer property paths | The properties holding the recipient's and the sender's key ids. | 14 | [encryptedFor](contract-keywords/encrypted-for.md#example) | +| `encryptedFor.scheme` | `"ecdh-secp256k1-aes256-cbc"` | How the ciphertext is made. | 14 | [The scheme](contract-keywords/encrypted-for.md#the-scheme) | +| `refersTo` | a declaration | What an identifier points at, checked when a document is written. See the [keys](#refersto). | 14 | [References](contract-keywords/refers-to.md) · [internals](data-model/documents.md#document-references-refersto) | + +A typed array's element (`items`) takes `type`, `enum`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`, `minLength`, `maxLength`, `pattern`, `format`, `minItems` and `maxItems` (bytes of a byte array element), `byteArray`, `contentMediaType`, `maxBytes`, `distinctFrom`, `refersTo`, `$comment` and `description`. It takes no `position`, `const`, `uniqueItems` or `examples`. + +### refersTo + +| Key | Takes | What it does | Since | Read more | +|---|---|---|---|---| +| `type` | a target below | What the value points at. | 14 | [Targets](contract-keywords/refers-to.md#targets) | +| `type: "identity"` | | The value is the id of an existing identity. | 14 | [identity](contract-keywords/refers-to.md#identity) | +| `type: "contract"` | | The value is the id of an existing data contract. | 14 | [contract](contract-keywords/refers-to.md#contract) | +| `type: "token"` | | The value is the id of an existing token. | 14 | [token](contract-keywords/refers-to.md#token) | +| `type: "permanentDocument"` | | The value is the id of a document whose type can never lose its documents. | 14 | [permanentDocument](contract-keywords/refers-to.md#permanentdocument) | +| `type: "deletableDocument"` | | The value is the id of a document that can be deleted; checked again on every replace. | 14 | [deletableDocument](contract-keywords/refers-to.md#deletabledocument) | +| `type: "identityPublicKey"` | | The value names an identity key that exists and is not disabled. | 14 | [identityPublicKey](contract-keywords/refers-to.md#identitypublickey) | +| `type: "listElement"` | | The value is one of the identifiers a list on another document holds. | 14 | [List Elements](contract-keywords/refers-to-list-element.md) · [internals](data-model/documents.md#an-element-of-a-list-listelement) | +| `documentType` | document type name | The referenced document type. | 14 | [documentType](contract-keywords/refers-to.md#documenttype) | +| `contractId` | identifier | The contract holding `documentType`, when it is not this one. | 14 | [contractId](contract-keywords/refers-to.md#contractid) | +| `propertyAgreement` | 1 to 10 pairs `{ "": "" }` | Properties of the two documents that must be equal. `$ownerId` here makes a write gate. | 14 | [propertyAgreement](contract-keywords/refers-to.md#propertyagreement) | +| `lookup` | `{ "index", "keys" }` | Finds the document through a unique index, with the value as one part of the key. | 14 | [Lookups](contract-keywords/refers-to-lookup.md) · [internals](data-model/documents.md#resolved-through-a-unique-index-lookup) | +| `lookup.index` | index name | A unique index of `documentType`. | 14 | [Lookups](contract-keywords/refers-to-lookup.md#assembling-the-key) | +| `lookup.keys` | index property → `"."`, `"$ownerId"` or a path | Where each part of the key comes from; `"."` is the value itself. | 14 | [Lookups](contract-keywords/refers-to-lookup.md#assembling-the-key) | +| `inList` | typed array path | The list on the referenced document the value must be in. | 14 | [List Elements](contract-keywords/refers-to-list-element.md) | +| `keyIdProperty` | integer property path | On an identity property: the property holding the key id. | 14 | [keyIdProperty and identityProperty](contract-keywords/refers-to.md#keyidproperty-and-identityproperty) | +| `identityProperty` | `"$ownerId"`, `"$creatorId"` or a path | On a key id property: whose key it is. | 14 | [keyIdProperty and identityProperty](contract-keywords/refers-to.md#keyidproperty-and-identityproperty) | +| `keyRequirements.purpose` | `authentication`, `encryption`, `decryption`, `transfer`, `voting`, `owner` | The key's purpose. | 14 | [keyRequirements](contract-keywords/refers-to.md#keyrequirements) | +| `keyRequirements.boundTo` | document type name | The key must be bound to that document type of this contract. | 14 | [keyRequirements](contract-keywords/refers-to.md#keyrequirements) | +| `contractRequirements.moderation` | `"elected"`, `"electionOpen"` | The contract declares an elected team, or one whose election may be called. | 14 | [contractRequirements](contract-keywords/refers-to.md#contractrequirements) · [Elected Moderation](data-model/contract-moderation.md#elected-moderation) | +| `contractRequirements.minimumAgeSeconds`, `.minimumSecondsSinceUpdate` | seconds | The contract was created, or last changed, at least this long ago. | 14 | [contractRequirements](contract-keywords/refers-to.md#contractrequirements) | +| `contractRequirements.owner` | `"self"`, `"other"` | The contract is owned by the writer, or by someone else. | 14 | [contractRequirements](contract-keywords/refers-to.md#contractrequirements) | +| `contractRequirements.readonly`, `.keepsHistory` | `true` | The contract can never be updated, or keeps history. | 14 | [contractRequirements](contract-keywords/refers-to.md#contractrequirements) | +| `contractRequirements.ownerProtected` | boolean | The contract's elected team does, or does not, protect its owner. | 14 | [contractRequirements](contract-keywords/refers-to.md#contractrequirements) | +| `anyOf`, `allOf` | 2 to 4 operands | In place of `type`: at least one, or every, operand holds. Nest at most 4 deep. | 14 | [Expressions](contract-keywords/refers-to-expressions.md) · [internals](data-model/documents.md#reference-expressions-anyof-allof) | + +### tokenCost and actionFees + +`` is one of `create`, `replace`, `delete`, `transfer`, `update_price` and `purchase`. + +| Key | Takes | What it does | Since | Read more | +|---|---|---|---|---| +| `tokenCost..tokenPosition` | 0 to 65535, required | Which token is charged. | 9 | [Token Costs](contract-keywords/token-cost.md) | +| `tokenCost..amount` | at least 1, required | How many tokens the action costs. | 9 | [Token Costs](contract-keywords/token-cost.md) | +| `tokenCost..contractId` | identifier | The contract whose token is charged, when it is not this one. | 9 | [contractId](contract-keywords/token-cost.md#tokens-of-another-contract-contractid) | +| `tokenCost..effect` | `0` to the contract owner (default), `1` burn | What happens to the tokens paid. | 9 | [effect](contract-keywords/token-cost.md#effect-transfer-or-burn) | +| `tokenCost..gasFeesPaidBy` | `0` document owner (default), `1` contract owner, `2` prefer contract owner | Who the contract owner offers to have pay the gas. Accepted from 9, acted on from 14. | 14 | [gasFeesPaidBy](contract-keywords/token-cost.md#who-pays-the-gas-gasfeespaidby) · [Fees](fees/overview.md#gas-paid-by-the-contract-owner) | +| `tokenCost..optional` | boolean, default `false` | A transition may skip the token and pay in credits. | 14 | [Optional costs](contract-keywords/token-cost.md#optional-costs) · [Fees](fees/overview.md#optional-token-costs) | +| `actionFees.pricing` | `"feeMultiplier"` (default), `"fixed"` | Whether the amounts scale with the epoch's fee multiplier. | 14 | [Action Fees](contract-keywords/action-fees.md#how-it-works) | +| `actionFees..owner` | credits | Paid into the contract owner's pot. | 14 | [The pots and the claim](contract-keywords/action-fees.md#the-pots-and-the-claim) | +| `actionFees..moderators` | credits | Paid into the moderators' pot. Needs `moderation`. | 14 | [The pots and the claim](contract-keywords/action-fees.md#the-pots-and-the-claim) · [Fee Pots](data-model/contract-moderation.md#fee-pots-and-the-claim) | + +### propertyConstraints + +A rule is one condition. Conditions: + +| Key | Takes | Holds when | Since | Read more | +|---|---|---|---|---| +| `equal`, `notEqual` | `[a, b]` | The two sides are equal, or differ: integer expressions, strings or identifiers. | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | +| `lessThan`, `lessThanOrEqual`, `greaterThan`, `greaterThanOrEqual` | `[a, b]` | The integer comparison holds. | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | +| `in` | `[a, [values]]` | `a` takes one of two or more listed integers, strings or identifiers. | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | +| `present`, `absent` | a path | The document holds the property, or leaves it out (or null). | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | +| `anyOf`, `allOf` | two or more conditions | At least one, or every, condition holds, checked in order. | 14 | [Evaluation order](contract-keywords/property-constraints.md#evaluation-order-and-short-circuiting) | +| `not` | a condition | The condition does not hold. | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | + +Expressions: + +| Key | Takes | Value | Since | Read more | +|---|---|---|---|---| +| an integer | `100` | Itself. | 14 | [Expressions](contract-keywords/property-constraints.md#expressions) | +| a path | `"price"`, `"meta.total"` | An integer or boolean property's value; 0 when left out. | 14 | [Expressions](contract-keywords/property-constraints.md#expressions) | +| `add`, `multiply` | two or more operands | The sum or product. | 14 | [Arithmetic](contract-keywords/property-constraints.md#arithmetic) | +| `subtract`, `divide`, `modulo`, `power` | `[a, b]` | The difference, Euclidean quotient or remainder, or power. | 14 | [Arithmetic](contract-keywords/property-constraints.md#arithmetic) | +| `ifAbsent` | `[path, default]` | The property's value, or the default when left out (an integer, or a string for a string property). | 14 | [Expressions](contract-keywords/property-constraints.md#expressions) | +| `length`, `byteLength` | a string path | A string's length in characters, or in UTF-8 bytes. | 14 | [Expressions](contract-keywords/property-constraints.md#expressions) | +| `count` | an array path | The elements of a typed array, or the bytes of a byte array. | 14 | [Expressions](contract-keywords/property-constraints.md#expressions) | +| `$createdAt`, `$updatedAt`, `$transferredAt`, `$createdAtBlockHeight`, `$updatedAtBlockHeight`, `$transferredAtBlockHeight`, `$createdAtCoreBlockHeight`, `$updatedAtCoreBlockHeight`, `$transferredAtCoreBlockHeight` | a path | A time or height the document records, when listed in `required`. | 14 | [Times and heights](contract-keywords/property-constraints.md#times-and-heights) | +| `const` | a string | A string constant, or a base58 identifier, as one side of `equal` or `notEqual`. | 14 | [Strings](contract-keywords/property-constraints.md#strings) | +| `$ownerId` | | The document's owner, as an identifier side. | 14 | [Identifiers and $ownerId](contract-keywords/property-constraints.md#identifiers-and-ownerid) | + +### Index + +| Key | Takes | What it does | Since | Read more | +|---|---|---|---|---| +| `name` | 1 to 32 characters, required | The index's name, unique in the type. | 1 | [name](contract-keywords/indexes.md#name) | +| `properties` | 1 to 10 `{ "": "asc" }` | The indexed properties, in order. A flat index of an index-only type leaves it out. | 1 | [properties](contract-keywords/indexes.md#properties) · [internals](drive/indexes.md) | +| `unique` | boolean | No two documents share the indexed values. | 1 | [unique](contract-keywords/indexes.md#unique) | +| `nullSearchable` | boolean, default `true` | `false` leaves out documents whose indexed values are all null. | 1 | [nullSearchable](contract-keywords/indexes.md#nullsearchable) | +| `contested` | object | Matching values are decided by a masternode vote, not first come. | 1 | [Contested Indexes](contract-keywords/contested.md) · [internals](data-model/contested-documents.md) | +| `contested.resolution` | `0` vote with lock, `1` vote without lock | How the contest is decided. `1` from 14. | 1 | [The keys](contract-keywords/contested.md#the-keys) | +| `contested.fieldMatches` | `[{ "field", "regexPattern" }]` | Which values are contested. | 1 | [The keys](contract-keywords/contested.md#the-keys) | +| `contested.description` | string | A note; consensus ignores it. | 1 | [The keys](contract-keywords/contested.md#the-keys) | +| `countable` | `"notCountable"`, `"countable"`, `"countableAllowingOffset"` or boolean | Keeps a document count per indexed value. | 12 | [countable](contract-keywords/aggregates.md#countable) · [internals](drive/document-count-trees.md#per-index-countable-flag) | +| `summable` | property name | Keeps the sum of an integer property per indexed value. | 12 | [summable](contract-keywords/aggregates.md#summable) · [internals](drive/document-sum-trees.md#per-index-summable-flag) | +| `averageable` | property name | Shorthand for `countable` plus `summable`. | 12 | [averageable](contract-keywords/aggregates.md#averageable) | +| `rangeCountable`, `rangeSummable`, `rangeAverageable` | boolean | Provable counts, sums or averages over ranges of the indexed value. | 12 | [Index range keys](contract-keywords/aggregates.md#index-rangecountable-rangesummable-rangeaverageable) | +| `rankedCountable` | boolean or `{ "at": ... }` | Orders the indexed values by document count, for "top K" queries; `at` names the levels ranked. | 14 | [rankedCountable](contract-keywords/ranked.md#rankedcountable) · [internals](drive/document-ranked-trees.md#contract-grammar) | +| `rankedSummable`, `rankedAverageable` | boolean | Orders them by sum, or by average. | 14 | [Ranked Indexes](contract-keywords/ranked.md#rankedsummable) | +| `timeRange` | `{ "on", "range", "step", "phase", "ttl" }` | Buckets a system timestamp into time windows, for trending queries. | 14 | [Time-Range Indexes](contract-keywords/time-range.md) · [internals](drive/time-range-ttl.md) | +| `timeRange.on` | `"$createdAt"`, `"$updatedAt"`, `"$transferredAt"` | The timestamp to bucket: the index's first property. | 14 | [The keys](contract-keywords/time-range.md#the-keys) | +| `timeRange.range`, `.step` | seconds | Each window's length, and the time between window starts. | 14 | [The keys](contract-keywords/time-range.md#the-keys) | +| `timeRange.phase` | seconds, default 0 | Shifts the window boundaries. | 14 | [The keys](contract-keywords/time-range.md#the-keys) | +| `timeRange.ttl` | seconds, at most one week | Expires the index's entries after their window; on an index-only type, the rows leave this index. | 14 | [The keys](contract-keywords/time-range.md#the-keys) · [internals](drive/time-range-ttl.md#cleanup) | +| `terminal` | property name or list | On an index-only type, what keys each entry in place of the document id. | 14 | [terminal](contract-keywords/index-only.md#terminal) | +| `preallocated` | boolean | On an index-only type, creates the index's trees with the referenced document. | 14 | [preallocated](contract-keywords/index-only.md#preallocated) · [internals](drive/index-only-document-types.md#preallocated-index-paths) | +| `skipIfAbsent` | boolean | On an index-only type, a document without the first property writes no entry. | 14 | [skipIfAbsent](contract-keywords/index-only.md#skipifabsent) · [internals](drive/index-only-document-types.md#conditional-participation-skipifabsent) | ### System properties -`$id`, `$ownerId`, `$revision`, `$creatorId`, and the creation, update and transfer times and block heights: see [System Properties](contract-keywords/system-properties.md). +| Property | Holds | Recorded | Since | Read more | +|---|---|---|---|---| +| `$id` | The document's id. | always | 1 | [$id](contract-keywords/system-properties.md#id) | +| `$ownerId` | The identity that owns the document. | always | 1 | [$ownerId](contract-keywords/system-properties.md#ownerid) | +| `$revision` | 1 at creation, raised by every replace, transfer, price update and purchase. | on types whose documents can change hands or content | 1 | [$revision](contract-keywords/system-properties.md#revision) | +| `$createdAt`, `$updatedAt`, `$transferredAt` | Block times, in milliseconds, of the creation, the last replace or price update, and the last transfer or purchase. | when listed in `required` | 1 | [Timestamps](contract-keywords/system-properties.md#timestamps) | +| `$createdAtBlockHeight`, `$updatedAtBlockHeight`, `$transferredAtBlockHeight` | Platform block heights of the same events. | when listed in `required` | 1 | [Block heights](contract-keywords/system-properties.md#block-heights) | +| `$createdAtCoreBlockHeight`, `$updatedAtCoreBlockHeight`, `$transferredAtCoreBlockHeight` | Core chain block heights of the same events. | when listed in `required` | 1 | [Block heights](contract-keywords/system-properties.md#block-heights) | +| `$creatorId` | The identity that created the document. | on transferable or tradeable types of format-1 contracts | 10 | [$creatorId](contract-keywords/system-properties.md#creatorid) | ## Limits diff --git a/book/src/contract-keywords/contract-config.md b/book/src/contract-keywords/contract-config.md index e167a9b8674..42d952abca7 100644 --- a/book/src/contract-keywords/contract-config.md +++ b/book/src/contract-keywords/contract-config.md @@ -53,7 +53,7 @@ A first version of a small social contract. Its config states the defaults expli | `config` | object | Contract-wide settings: see [`config`](#config) below. Absent means the defaults. | 1 | | `documentSchemas` | object | The document types, by name. See [`documentSchemas`](#documentschemas). | 1 | | `schemaDefs` | object | Definitions every document type may point at with `$ref`. An update may add definitions, not remove them (`IncompatibleDataContractSchemaError`, 10213). | 1 | -| `groups` | object | Groups of identities that act together, each member with a voting power, whose approval some token actions need. See [Contract Groups](../data-model/contract-groups.md). | 9 | +| `groups` | object | Groups of identities that act together, each member with a voting power, whose approval some token actions need. See [Data Contracts](../data-model/data-contracts.md#what-v1-added). Not the same thing as a [contract group](../data-model/contract-groups.md), a set of contracts. | 9 | | `tokens` | object | The contract's tokens, keyed by position `0`, `1`, and so on. Document types may charge them with [`tokenCost`](token-cost.md). | 9 | | `keywords` | array of strings | Search keywords. See [`keywords` and `description`](#keywords-and-description). | 9 | | `description` | string | A short description for search. See [`keywords` and `description`](#keywords-and-description). | 9 | @@ -202,5 +202,5 @@ A declaration keeps at least one list, unless a document type sets `canBeDeleted - [Data Contracts](../data-model/data-contracts.md) for the contract structure and its versions. - [Contract Moderation](../data-model/contract-moderation.md) for the lists, the moderation transition and elected teams. -- [Contract Groups](../data-model/contract-groups.md) for `groups`. +- [Contract Groups](../data-model/contract-groups.md) for contract groups, sets of contracts, which a create transition may register or join (not the contract's `groups`). - [Contract Keywords](../contract-keywords.md) for the document type keywords. From 91a88b011f84596b67c43a40778ea307af62f2f8 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 05:19:04 +0700 Subject: [PATCH 056/113] fix(dpp)!: report contracts refused by parser generation 3 as consensus errors (PV14) (#5076) Co-authored-by: Claude Opus 5.5 --- .../try_from_schema/common/mod.rs | 31 ++- .../try_from_schema/v3/immutable_tests.rs | 7 +- .../try_from_schema/v3/index_only_tests.rs | 42 +-- .../class_methods/try_from_schema/v3/mod.rs | 95 ++++++- .../v3/shared_stage_error_tests.rs | 242 +++++++++++++++++ .../v3/typed_array_test_helpers.rs | 27 +- .../contract_structure_test_harness.rs | 251 ++++++++++++++++++ .../data_contract_common/mod.rs | 5 + .../contract_structure_error_tests.rs | 104 ++++++++ .../data_contract_create/mod.rs | 2 + .../contract_structure_error_tests.rs | 137 ++++++++++ .../data_contract_update/mod.rs | 2 + 12 files changed, 890 insertions(+), 55 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/shared_stage_error_tests.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/contract_structure_test_harness.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/contract_structure_error_tests.rs diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 507b533bfc5..16e4c1a37f6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -2492,8 +2492,12 @@ pub(super) fn apply_index_only( ) -> Result<(), ProtocolError> { use crate::document::property_names::{CREATED_AT, OWNER_ID}; + // Only generation 3 calls this, so no protocol version before 14 sees + // these rules or the class of error they are reported with. let structure_error = |message: String| { - ProtocolError::DataContractError(DataContractError::InvalidContractStructure(message)) + consensus_or_protocol_data_contract_error(DataContractError::InvalidContractStructure( + message, + )) }; if !index_only { @@ -2677,10 +2681,22 @@ pub(super) fn apply_index_only( payload_property, name, ))); } - let max_width = property - .property_type - .max_byte_size(platform_version)? - .unwrap_or(u16::MAX); + // A string whose `maxLength` puts its worst case past `u16::MAX` bytes + // overflows the width computation; it could never fit an entry's value. + let max_width = match property.property_type.max_byte_size(platform_version) { + Ok(max_width) => max_width.unwrap_or(u16::MAX), + Err(ProtocolError::Overflow(_)) => { + return Err(structure_error(format!( + "entryPayload property \"{}\" of indexOnly document type \"{}\" may \ + encode to more than {} bytes, over the {}-byte cap on an entry's value", + payload_property, + name, + u16::MAX, + platform_version.system_limits.max_field_value_size, + ))) + } + Err(error) => return Err(error), + }; if max_width == u16::MAX { return Err(structure_error(format!( "entryPayload property \"{}\" of indexOnly document type \"{}\" must be \ @@ -2847,9 +2863,12 @@ pub(super) fn apply_index_only( // (canonical property, i64-safe integer type, `required` // membership) run for every doctype, indexOnly included. + // `parse_indices` gives every index of an indexOnly type a terminal, + // and the index parser refuses an empty one, so a contract cannot get + // here: this is the parser failing, not the contract. let components = index.terminal_components(); if components.is_empty() { - return Err(structure_error(format!( + return Err(ProtocolError::CorruptedCodeExecution(format!( "index \"{}\" on indexOnly document type \"{}\" has no terminal after \ normalization: internal parser error", index_name, name, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/immutable_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/immutable_tests.rs index 00e613509e3..66ed8a78cc3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/immutable_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/immutable_tests.rs @@ -106,16 +106,19 @@ fn names(entries: &[&str]) -> BTreeSet { entries.iter().map(|entry| entry.to_string()).collect() } -/// The lints surface as `InvalidContractStructure` either directly or, with -/// the `validation` feature on, wrapped as the basic `ContractError`. +/// The lints surface as `InvalidContractStructure`, wrapped as the basic +/// `ContractError` whenever the `validation` feature is on: a bare +/// `ProtocolError::DataContractError` would refuse the transition unpaid. pub(super) fn expect_structure_error( result: Result, needle: &str, ) { let message = match result { + #[cfg(not(feature = "validation"))] Err(ProtocolError::DataContractError(DataContractError::InvalidContractStructure( message, ))) => message, + #[cfg(feature = "validation")] Err(ProtocolError::ConsensusError(boxed)) => match *boxed { ConsensusError::BasicError(BasicError::ContractError( DataContractError::InvalidContractStructure(message), diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/index_only_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/index_only_tests.rs index f7a7e302652..f893bb6553d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/index_only_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/index_only_tests.rs @@ -19,11 +19,11 @@ //! smuggling path — and the happy path is additionally exercised under full //! validation to pin the meta-schema admission. +use super::immutable_tests::expect_structure_error; use super::*; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; -use crate::data_contract::errors::DataContractError; use platform_value::platform_value; /// Parse through this generation with validation mode spelled out. @@ -154,23 +154,6 @@ fn likes_schema_with_index_key(index_position: usize, key: &str, value: Value) - schema } -fn expect_structure_error(result: Result, needle: &str) { - match result { - Err(ProtocolError::DataContractError(DataContractError::InvalidContractStructure( - message, - ))) => { - assert!( - message.contains(needle), - "expected structure error containing {needle:?}, got: {message}" - ); - } - Err(other) => { - panic!("expected InvalidContractStructure containing {needle:?}, got {other}") - } - Ok(_) => panic!("expected rejection containing {needle:?}, but the schema parsed"), - } -} - /// The terminal shares the prefix positions' shape checks, which report /// through the typed index consensus errors rather than a structure error. fn expect_basic_error( @@ -1448,6 +1431,29 @@ fn rejects_an_unbounded_entry_payload_property() { ); } +/// A string whose `maxLength` puts its worst case past `u16::MAX` bytes +/// overflows the width computation, which is a refused contract rather than +/// an internal error. +#[test] +fn rejects_an_entry_payload_string_wider_than_the_width_computation() { + let mut schema = login_response_schema(); + schema + .get_mut("properties") + .expect("properties accessible") + .expect("properties present") + .set_value( + "encryptedPayload", + platform_value!({ "type": "string", "maxLength": 16384, "position": 2 }), + ) + .expect("property applies"); + for full_validation in [false, true] { + expect_structure_error( + parse_with(schema.clone(), PlatformVersion::latest(), full_validation), + "may encode to more than 65535 bytes", + ); + } +} + #[test] fn rejects_an_optional_entry_payload_property() { let mut schema = login_response_schema(); diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 36f2ef75fd6..3533014bd8b 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -20,7 +20,9 @@ use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; -use crate::data_contract::document_type::class_methods::consensus_or_protocol_data_contract_error; +use crate::data_contract::document_type::class_methods::{ + consensus_or_protocol_data_contract_error, consensus_or_protocol_value_error, +}; // Only the ranked key-length rule below names `Index`, and it is validation-only. use crate::data_contract::document_type::action_fees::DocumentActionFees; #[cfg(feature = "validation")] @@ -171,9 +173,14 @@ fn validate_ranked_index_property_key_length( // `None` is only produced by the array and object types, which the // property-type check right after this one rejects outright with the - // error that actually explains the problem. - let Some(worst_case_key_length) = property_type.max_byte_size(platform_version)? else { - return Ok(()); + // error that actually explains the problem. A string whose `maxLength` + // puts its worst case past `u16::MAX` bytes overflows the computation; + // it is past every ceiling, so it is refused like any key over the limit. + let worst_case_key_length = match property_type.max_byte_size(platform_version) { + Ok(Some(length)) => length, + Ok(None) => return Ok(()), + Err(ProtocolError::Overflow(_)) => u16::MAX, + Err(error) => return Err(error), }; if worst_case_key_length <= limit { @@ -248,6 +255,33 @@ const RANKED_INDEX_KEY_LENGTH_CHECK: common::RankedIndexKeyLengthCheck = const RANKED_INDEX_KEY_LENGTH_CHECK: common::RankedIndexKeyLengthCheck = common::no_ranked_index_key_length_check; +/// Reports a contract this generation refuses as a consensus error. +/// +/// Some stages report a broken rule as a bare `ProtocolError::DataContractError`, +/// or a schema value of the wrong shape as a bare `ProtocolError::ValueError`: +/// the core parse and the doctype-level aggregate stages, which generation 3 +/// shares with generations 1 and 2. A node takes a bare error for a failure of +/// its own: the transition carrying the contract is refused without a fee or a +/// nonce bump, and a block carrying it is rejected. As a consensus error it is +/// a paid rejection instead, like a contract failing any other rule. +/// +/// The shared stages keep the bare errors, because generations 1 and 2 still +/// reach them at protocol versions up to 13. A node running this code at those +/// versions must judge a block exactly as a node running the release that +/// shipped them, and that release refuses such a transition unpaid. +/// +/// The mapping is applied once, to everything the generation returns, so a +/// stage added later cannot bring the bare errors back. Every other error +/// passes through unchanged: `CorruptedCodeExecution`, a version mismatch or an +/// arithmetic overflow is the node failing, not the contract. +fn consensus_or_protocol_generation_3_error(error: ProtocolError) -> ProtocolError { + match error { + ProtocolError::DataContractError(error) => consensus_or_protocol_data_contract_error(error), + ProtocolError::ValueError(error) => consensus_or_protocol_value_error(error), + error => error, + } +} + /// Parses a document type schema through the generation-3 grammar: the /// generation-2 doctype-level aggregate keywords, plus the ranked index /// keywords and the tighter index-key ceilings they impose. @@ -304,6 +338,37 @@ fn try_from_schema_generation_3( full_validation: bool, validation_operations: &mut impl Extend, platform_version: &PlatformVersion, +) -> Result { + parse_generation_3( + data_contract_id, + data_contract_system_version, + contract_config_version, + name, + schema, + schema_defs, + token_configurations, + data_contact_config, + full_validation, + validation_operations, + platform_version, + ) + .map_err(consensus_or_protocol_generation_3_error) +} + +/// The body of [`try_from_schema_generation_3`], which maps its bare errors. +#[allow(clippy::too_many_arguments)] +fn parse_generation_3( + data_contract_id: Identifier, + data_contract_system_version: u16, + contract_config_version: u16, + name: &str, + schema: Value, + schema_defs: Option<&BTreeMap>, + token_configurations: &BTreeMap, + data_contact_config: &DataContractConfig, + full_validation: bool, + validation_operations: &mut impl Extend, + platform_version: &PlatformVersion, ) -> Result { // Generation 3 refuses `-` in a document type name, as meta-schema v3 // refuses it in a property name: the path syntax was written for word @@ -1034,6 +1099,8 @@ mod reference_lookup_tests; #[cfg(all(test, feature = "validation"))] mod reference_test_helpers; #[cfg(all(test, feature = "validation"))] +mod shared_stage_error_tests; +#[cfg(all(test, feature = "validation"))] mod transient_tests; #[cfg(all(test, feature = "validation"))] mod typed_array_reference_tests; @@ -1604,6 +1671,26 @@ mod tests { } } + /// A `maxLength` whose worst case overflows `u16` bytes is refused with + /// the same consensus error as any other key over the ceiling, not as an + /// internal overflow. + #[test] + fn ranked_axes_refuse_a_string_too_long_to_size_as_a_consensus_error() { + for extras in [ + count_ranked_extras(), + sum_ranked_extras(), + avg_ranked_extras(), + ] { + let error = parse_bound(ranked_bound_schema(string_property(16384), extras)) + .expect_err("16384 * 4 bytes overflows u16 and must be rejected"); + let msg = format!("{error:?}"); + assert!( + msg.starts_with("ConsensusError(BasicError(InvalidIndexedPropertyConstraintError"), + "the ranked key ceiling's consensus error must be raised; got {msg}" + ); + } + } + /// The Avg axis's 16-byte sort key costs 8 more bytes, and the string /// bound drops to 59 characters with it. #[test] diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/shared_stage_error_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/shared_stage_error_tests.rs new file mode 100644 index 00000000000..6922db95bb5 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/shared_stage_error_tests.rs @@ -0,0 +1,242 @@ +//! The class of error a contract refused in a shared stage is reported with. +//! +//! The doctype-level aggregate stages are shared with generation 2, and the +//! core parse with generations 1 and 2. Generation 3 reports what they refuse +//! as a consensus error, so a transition carrying the contract is a paid +//! rejection; the earlier generations keep the bare +//! `ProtocolError::DataContractError` or `ProtocolError::ValueError` they +//! shipped with, which a node refuses unpaid. + +use super::*; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use assert_matches::assert_matches; +use platform_value::platform_value; + +/// Two bounded integers to sum, one integer that parses as `u64` (a `minimum` +/// and no `maximum`), one bounded integer left out of `required`, and a string. +fn schema_with_doctype_keys(keys: Value) -> Value { + let mut schema = platform_value!({ + "type": "object", + "properties": { + "amount": {"type": "integer", "minimum": 0, "maximum": 1000, "position": 0}, + "fee": {"type": "integer", "minimum": 0, "maximum": 1000, "position": 1}, + "unbounded": {"type": "integer", "minimum": 1, "position": 2}, + "optional": {"type": "integer", "minimum": 0, "maximum": 1000, "position": 3}, + "label": {"type": "string", "maxLength": 20, "position": 4}, + }, + "required": ["amount", "fee", "unbounded"], + "additionalProperties": false, + }); + for (key, value) in keys.into_map().expect("the doctype keys are a map") { + schema + .set_value(key.as_text().expect("a doctype key is text"), value) + .expect("the doctype key applies"); + } + schema +} + +/// Every rule the two aggregate stages enforce, with a fragment of its message. +fn broken_aggregate_rules() -> Vec<(Value, &'static str)> { + vec![ + // `parse_doctype_aggregate_keywords` + ( + platform_value!({"documentsSummable": ""}), + "documentsSummable must be a non-empty string", + ), + ( + platform_value!({"documentsSummable": 5}), + "documentsSummable value must be a string or null", + ), + ( + platform_value!({"documentsAverageable": ""}), + "documentsAverageable must be a non-empty string", + ), + ( + platform_value!({"documentsAverageable": 5}), + "documentsAverageable value must be a string or null", + ), + ( + platform_value!({"documentsAverageable": "amount", "documentsSummable": "fee"}), + "conflicts with documentsSummable", + ), + ( + platform_value!({"documentsAverageable": "amount", "documentsCountable": false}), + "explicitly sets documentsCountable: false", + ), + ( + platform_value!({ + "documentsAverageable": "amount", + "rangeAverageable": true, + "rangeCountable": false, + }), + "conflicts with explicit rangeCountable: false", + ), + ( + platform_value!({ + "documentsAverageable": "amount", + "rangeAverageable": true, + "rangeSummable": false, + }), + "conflicts with explicit rangeSummable: false", + ), + ( + platform_value!({"rangeAverageable": true}), + "requires documentsAverageable", + ), + ( + platform_value!({"rangeSummable": true}), + "rangeSummable: true requires documentsSummable", + ), + // `apply_doctype_aggregates` + ( + platform_value!({ + "documentsSummable": "amount", + "indices": [ + {"name": "byLabel", "properties": [{"label": "asc"}], "summable": "fee"}, + ], + }), + "must name the same property", + ), + ( + platform_value!({"documentsSummable": "missing"}), + "does not exist on that document type", + ), + ( + platform_value!({"documentsSummable": "unbounded"}), + "whose values fit in i64", + ), + ( + platform_value!({"documentsSummable": "optional"}), + "listed in the document type's `required` array", + ), + ] +} + +fn parse_dispatched( + schema: Value, + platform_version: &PlatformVersion, + full_validation: bool, +) -> Result { + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available on this platform version"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "payment", + schema, + None, + &BTreeMap::new(), + &config, + full_validation, + &mut vec![], + platform_version, + ) +} + +#[test] +fn should_report_every_broken_aggregate_rule_as_a_consensus_error() { + for full_validation in [false, true] { + for (keys, needle) in broken_aggregate_rules() { + let result = parse_dispatched( + schema_with_doctype_keys(keys), + PlatformVersion::latest(), + full_validation, + ); + match result { + Err(ProtocolError::ConsensusError(error)) => match *error { + ConsensusError::BasicError(BasicError::ContractError(error)) => assert!( + error.to_string().contains(needle), + "full_validation={full_validation}: expected {needle:?}, got: {error}" + ), + other => panic!( + "full_validation={full_validation}: expected a contract error \ + containing {needle:?}, got {other}" + ), + }, + other => panic!( + "full_validation={full_validation}: expected a consensus error \ + containing {needle:?}, got {other:?}" + ), + } + } + } +} + +/// Generation 2 is left as it shipped: a node running this code at protocol +/// version 13 has to refuse such a transition exactly as the release that +/// shipped protocol version 13 does. +#[test] +fn should_keep_reporting_broken_aggregate_rules_as_bare_errors_at_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13 exists"); + for (keys, needle) in broken_aggregate_rules() { + match parse_dispatched(schema_with_doctype_keys(keys), platform_version, false) { + Err(ProtocolError::DataContractError(error)) => assert!( + error.to_string().contains(needle), + "expected {needle:?}, got: {error}" + ), + other => panic!("expected a bare error containing {needle:?}, got {other:?}"), + } + } +} + +/// Schema values of the wrong shape the core parse reads: a `position` past +/// `u32`, read under full validation after the meta-schema admitted it, and a +/// `tokenCost` amount that is no integer, read on the non-validating path, +/// where no meta-schema runs first. +fn malformed_core_values() -> Vec<(&'static str, Value, bool)> { + vec![ + ( + "a position past u32", + platform_value!({ + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": 0, + "maximum": 1000, + "position": 4294967296u64, + }, + }, + "required": ["amount"], + "additionalProperties": false, + }), + true, + ), + ( + "a tokenCost amount that is no integer", + schema_with_doctype_keys(platform_value!({ + "tokenCost": {"create": {"tokenPosition": 0, "amount": true}}, + })), + false, + ), + ] +} + +#[test] +fn should_report_a_malformed_core_value_as_a_consensus_error() { + for (what, schema, full_validation) in malformed_core_values() { + match parse_dispatched(schema, PlatformVersion::latest(), full_validation) { + Err(ProtocolError::ConsensusError(error)) => assert_matches!( + *error, + ConsensusError::BasicError(BasicError::ValueError(_)), + "{what}" + ), + other => panic!("{what}: expected a consensus value error, got {other:?}"), + } + } +} + +/// The core parse is left as it shipped for generations 1 and 2. +#[test] +fn should_keep_reporting_a_malformed_core_value_as_a_bare_error_at_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13 exists"); + for (what, schema, full_validation) in malformed_core_values() { + assert_matches!( + parse_dispatched(schema, platform_version, full_validation), + Err(ProtocolError::ValueError(_)), + "{what}" + ); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_test_helpers.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_test_helpers.rs index 5b3bf7a49bf..18af562ec86 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_test_helpers.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_test_helpers.rs @@ -6,7 +6,6 @@ use super::*; use crate::consensus::basic::json_schema_error::JsonSchemaError; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; -use crate::data_contract::errors::DataContractError; /// Parse through the real dispatcher, which picks the parser generation out /// of the platform version's `try_from_schema` table value (generation 2 at @@ -46,27 +45,5 @@ pub(super) fn expect_json_schema_error( } } -/// The parser's structure errors surface as `InvalidContractStructure` -/// either directly or, with the `validation` feature on, wrapped as the basic -/// `ContractError`. -pub(super) fn expect_structure_error( - result: Result, - needle: &str, -) { - let message = match result { - Err(ProtocolError::DataContractError(DataContractError::InvalidContractStructure( - message, - ))) => message, - Err(ProtocolError::ConsensusError(boxed)) => match *boxed { - ConsensusError::BasicError(BasicError::ContractError( - DataContractError::InvalidContractStructure(message), - )) => message, - other => panic!("expected InvalidContractStructure, got {other:?}"), - }, - other => panic!("expected InvalidContractStructure, got {other:?}"), - }; - assert!( - message.contains(needle), - "expected {needle:?} in the error, got: {message}" - ); -} +/// The parser's structure errors, shared with the other generation 3 suites. +pub(super) use super::immutable_tests::expect_structure_error; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/contract_structure_test_harness.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/contract_structure_test_harness.rs new file mode 100644 index 00000000000..74f764321fe --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/contract_structure_test_harness.rs @@ -0,0 +1,251 @@ +//! Runs a data contract create or update carrying a document type that breaks a structural rule +//! of the document type parser through `check_tx` and block processing. +//! +//! From protocol version 14 such a rule is a consensus error: `check_tx` refuses the transition +//! with it, and a block charges the owner and bumps its nonce. At protocol version 13 some of +//! these rules are reported as an error of the parser's own: `check_tx` fails, and a block records +//! an internal error, which leaves the owner untouched and keeps the transition out of any block. + +use crate::execution::check_tx::CheckTxLevel; +use crate::platform_types::platform::PlatformRef; +use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::TempPlatform; +use assert_matches::assert_matches; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::basic::BasicError; +use dpp::consensus::ConsensusError; +use dpp::data_contract::errors::DataContractError; +use dpp::identity::identity_nonce::IDENTITY_NONCE_VALUE_FILTER; +use dpp::identity::{IdentityPublicKey, SecurityLevel}; +use dpp::platform_value::{platform_value, Value}; +use dpp::prelude::Identifier; +use dpp::serialization::PlatformSerializable; +use dpp::state_transition::data_contract_create_transition::accessors::DataContractCreateTransitionAccessorsV0; +use dpp::state_transition::data_contract_update_transition::accessors::DataContractUpdateTransitionAccessorsV0; +use dpp::state_transition::StateTransition; +use dpp::ProtocolError; +use platform_version::version::PlatformVersion; +use simple_signer::signer::SimpleSigner; +use std::collections::BTreeMap; + +/// The fragment of the parser's message for a summed property that parses as `u64`. +pub(in crate::execution) const SUMMED_U64_MESSAGE: &str = + "must be an integer type whose values fit in i64"; + +/// A document type summing `amount`, which has a `minimum` and no `maximum`, so it parses as an +/// unsigned 64-bit integer, which a sum tree cannot hold. The rule is shared with protocol +/// versions 12 and 13. +pub(in crate::execution) fn summed_u64_schema() -> Value { + platform_value!({ + "type": "object", + "documentsSummable": "amount", + "properties": { + "amount": { + "type": "integer", + "minimum": 1, + "position": 0, + }, + }, + "required": ["amount"], + "additionalProperties": false, + }) +} + +/// The fragment of the parser's message for a `terminal` outside an indexOnly type. +pub(in crate::execution) const TERMINAL_WITHOUT_INDEX_ONLY_MESSAGE: &str = + "which is only allowed on indexOnly document types"; + +/// A document type that is not indexOnly but gives an index a `terminal`, one of the indexOnly +/// rules only protocol version 14 has. +pub(in crate::execution) fn terminal_without_index_only_schema() -> Value { + platform_value!({ + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 20, + "position": 0, + }, + }, + "required": ["label"], + "indices": [ + {"name": "byLabel", "properties": [{"label": "asc"}], "terminal": "$ownerId"}, + ], + "additionalProperties": false, + }) +} + +/// What `check_tx` and one block made of a transition. +pub(in crate::execution) struct Outcome { + /// The consensus errors `check_tx` refused the transition with, or its own error. + pub check_tx: Result, String>, + pub block: StateTransitionExecutionResult, + pub nonce_before: Option, + pub nonce_after: Option, + pub balance_before: Option, + pub balance_after: Option, +} + +/// Edits the document schemas of the contract a create or update transition carries and signs +/// the transition again. A rule the parser enforces cannot be broken by a contract built in +/// memory, so the schema goes into the serialized contract instead. +pub(in crate::execution) async fn resign_with_schemas( + state_transition: &mut StateTransition, + edit_schemas: impl FnOnce(&mut BTreeMap), + key: &IdentityPublicKey, + signer: &SimpleSigner, +) -> Vec { + match state_transition { + StateTransition::DataContractCreate(create) => { + let mut serialized_contract = create.data_contract().clone(); + edit_schemas(serialized_contract.document_schemas_mut()); + create.set_data_contract(serialized_contract); + } + StateTransition::DataContractUpdate(update) => { + let mut serialized_contract = update.data_contract().clone(); + edit_schemas(serialized_contract.document_schemas_mut()); + update.set_data_contract(serialized_contract); + } + _ => panic!("expected a data contract create or update transition"), + } + state_transition + .sign_external( + key, + signer, + None:: Result>, + ) + .await + .expect("expected to sign the transition again"); + state_transition + .serialize_to_bytes() + .expect("expected to serialize the transition") +} + +/// Runs the transition through `check_tx`, then through one block, reading the nonce with +/// `fetch_nonce` and the owner's balance before and after the block. +pub(in crate::execution) fn check_and_process( + platform: &TempPlatform, + owner_id: Identifier, + transition_bytes: Vec, + fetch_nonce: impl Fn(&TempPlatform) -> Option, + platform_version: &PlatformVersion, +) -> Outcome { + let platform_state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + let check_tx = platform + .check_tx( + &transition_bytes, + CheckTxLevel::FirstTimeCheck, + &platform_ref, + platform_version, + ) + .map(|result| result.errors) + .map_err(|error| error.to_string()); + + let fetch_balance = || { + platform + .drive + .fetch_identity_balance(owner_id.to_buffer(), None, platform_version) + .expect("expected to fetch the owner's balance") + }; + let nonce_before = fetch_nonce(platform); + let balance_before = fetch_balance(); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[transition_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("block processing must return a result for the transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + + Outcome { + check_tx, + block: processing_result + .execution_results() + .first() + .expect("expected one execution result") + .clone(), + nonce_before, + nonce_after: fetch_nonce(platform), + balance_before, + balance_after: fetch_balance(), + } +} + +/// A paid rejection: `check_tx` refuses the transition with the parser's +/// `InvalidContractStructure`, and the block charges the owner for it and bumps the nonce to +/// `bumped_nonce`, the nonce the transition was signed with. +pub(in crate::execution) fn assert_paid_contract_structure_error( + outcome: &Outcome, + needle: &str, + bumped_nonce: u64, +) { + assert_matches!( + outcome.check_tx.as_deref(), + Ok([ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message) + ))]) if message.contains(needle), + "check_tx: {:?}", + outcome.check_tx + ); + assert_matches!( + &outcome.block, + StateTransitionExecutionResult::PaidConsensusError { error: ConsensusError::BasicError( + BasicError::ContractError(DataContractError::InvalidContractStructure(message)) + ), .. } if message.contains(needle), + "block: {:?}", + outcome.block + ); + // The stored nonce keeps the nonces skipped below it in its high bits. + assert_eq!( + outcome + .nonce_after + .map(|nonce| nonce & IDENTITY_NONCE_VALUE_FILTER), + Some(bumped_nonce), + "the rejection bumps the nonce" + ); + assert!( + outcome.balance_after < outcome.balance_before, + "the rejection is charged: {:?} -> {:?}", + outcome.balance_before, + outcome.balance_after + ); +} + +/// An unpaid refusal: `check_tx` fails with the parser's own error, and the block records an +/// internal error and leaves the owner untouched. +pub(in crate::execution) fn assert_unpaid_internal_error(outcome: &Outcome, needle: &str) { + assert_matches!( + &outcome.check_tx, + Err(message) if message.contains(needle), + "check_tx: {:?}", + outcome.check_tx + ); + assert_matches!( + &outcome.block, + StateTransitionExecutionResult::InternalError(message) if message.contains(needle), + "block: {:?}", + outcome.block + ); + assert_eq!(outcome.nonce_after, outcome.nonce_before); + assert_eq!(outcome.balance_after, outcome.balance_before); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/mod.rs index 89c40f4e7f6..d0bcc07455d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/mod.rs @@ -1,2 +1,7 @@ /// Validation of the reference declarations a contract's document types carry. pub mod data_contract_reference_validation; + +/// Runs a contract breaking a structural rule of the document type parser through `check_tx` +/// and block processing, for the create and update suites. +#[cfg(test)] +pub(in crate::execution) mod contract_structure_test_harness; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs new file mode 100644 index 00000000000..f8654178c6b --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs @@ -0,0 +1,104 @@ +//! Registering a contract whose document type breaks a structural rule of the parser, through +//! `check_tx` and block processing. + +use crate::execution::validation::state_transition::state_transitions::data_contract_common::contract_structure_test_harness::{ + assert_paid_contract_structure_error, assert_unpaid_internal_error, check_and_process, + resign_with_schemas, summed_u64_schema, terminal_without_index_only_schema, Outcome, + SUMMED_U64_MESSAGE, TERMINAL_WITHOUT_INDEX_ONLY_MESSAGE, +}; +use crate::execution::validation::state_transition::state_transitions::tests::setup_identity; +use crate::test::helpers::setup::TestPlatformBuilder; +use dpp::dash_to_credits; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::platform_value::Value; +use dpp::state_transition::data_contract_create_transition::methods::DataContractCreateTransitionMethodsV0; +use dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; +use dpp::tests::fixtures::get_data_contract_fixture; +use platform_version::version::{PlatformVersion, ProtocolVersion}; + +/// Registers a contract whose only document type is `item`, with `schema`. +async fn register_contract_with_schema( + schema: Value, + protocol_version: ProtocolVersion, +) -> Outcome { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected the protocol version"); + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let (identity, signer, key) = setup_identity(&mut platform, 5077, dash_to_credits!(1.0)); + + let data_contract = + get_data_contract_fixture(Some(identity.id()), 1, platform_version.protocol_version) + .data_contract_owned(); + + let mut state_transition = DataContractCreateTransition::new_from_data_contract( + data_contract, + 1, + &identity.clone().into_partial_identity_info(), + key.id(), + &signer, + platform_version, + None, + ) + .await + .expect("expected to create the contract create transition"); + let transition_bytes = resign_with_schemas( + &mut state_transition, + |schemas| { + schemas.clear(); + schemas.insert("item".to_string(), schema); + }, + &key, + &signer, + ) + .await; + + let owner_id = identity.id(); + check_and_process( + &platform, + owner_id, + transition_bytes, + |platform| { + platform + .drive + .fetch_identity_nonce(owner_id.to_buffer(), true, None, platform_version) + .expect("expected to fetch the identity nonce") + }, + platform_version, + ) +} + +#[tokio::test] +async fn should_refuse_a_summed_u64_property_with_a_paid_consensus_error() { + let outcome = register_contract_with_schema( + summed_u64_schema(), + PlatformVersion::latest().protocol_version, + ) + .await; + + assert_eq!(outcome.nonce_before, Some(0)); + assert_paid_contract_structure_error(&outcome, SUMMED_U64_MESSAGE, 1); +} + +#[tokio::test] +async fn should_refuse_a_terminal_outside_an_index_only_type_with_a_paid_consensus_error() { + let outcome = register_contract_with_schema( + terminal_without_index_only_schema(), + PlatformVersion::latest().protocol_version, + ) + .await; + + assert_eq!(outcome.nonce_before, Some(0)); + assert_paid_contract_structure_error(&outcome, TERMINAL_WITHOUT_INDEX_ONLY_MESSAGE, 1); +} + +#[tokio::test] +async fn should_keep_refusing_a_summed_u64_property_unpaid_at_protocol_version_13() { + let outcome = register_contract_with_schema(summed_u64_schema(), 13).await; + + assert_unpaid_internal_error(&outcome, SUMMED_U64_MESSAGE); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs index 50a5dee44ed..ac5840d1f16 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs @@ -2,6 +2,8 @@ mod advanced_structure; mod basic_structure; #[cfg(test)] mod contract_group_tests; +#[cfg(test)] +mod contract_structure_error_tests; mod identity_nonce; mod state; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/contract_structure_error_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/contract_structure_error_tests.rs new file mode 100644 index 00000000000..8aa9856b707 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/contract_structure_error_tests.rs @@ -0,0 +1,137 @@ +//! Updating a contract with a new document type that breaks a structural rule of the parser, +//! through `check_tx` and block processing. + +use crate::execution::validation::state_transition::state_transitions::data_contract_common::contract_structure_test_harness::{ + assert_paid_contract_structure_error, assert_unpaid_internal_error, check_and_process, + resign_with_schemas, summed_u64_schema, terminal_without_index_only_schema, Outcome, + SUMMED_U64_MESSAGE, TERMINAL_WITHOUT_INDEX_ONLY_MESSAGE, +}; +use crate::execution::validation::state_transition::state_transitions::tests::setup_identity; +use crate::test::helpers::setup::TestPlatformBuilder; +use dpp::block::block_info::BlockInfo; +use dpp::dash_to_credits; +use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::platform_value::Value; +use dpp::state_transition::data_contract_update_transition::methods::DataContractUpdateTransitionMethodsV0; +use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransition; +use dpp::tests::fixtures::get_data_contract_fixture; +use drive::util::storage_flags::StorageFlags; +use platform_version::version::{PlatformVersion, ProtocolVersion}; + +/// The contract nonce the update is signed with. +const UPDATE_NONCE: u64 = 2; + +/// Updates a stored contract, adding a document type `item` with `schema`. +async fn update_contract_adding_schema( + schema: Value, + protocol_version: ProtocolVersion, +) -> Outcome { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected the protocol version"); + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let (identity, signer, key) = setup_identity(&mut platform, 5078, dash_to_credits!(1.0)); + + let mut data_contract = + get_data_contract_fixture(None, 0, platform_version.protocol_version).data_contract_owned(); + data_contract.set_owner_id(identity.id()); + platform + .drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + + let mut updated_data_contract = data_contract.clone(); + updated_data_contract.set_version(2); + + let mut state_transition = DataContractUpdateTransition::new_from_data_contract( + updated_data_contract, + &identity.clone().into_partial_identity_info(), + key.id(), + UPDATE_NONCE, + 0, + &signer, + platform_version, + None, + ) + .await + .expect("expected to create the contract update transition"); + let transition_bytes = resign_with_schemas( + &mut state_transition, + |schemas| { + schemas.insert("item".to_string(), schema); + }, + &key, + &signer, + ) + .await; + + let owner_id = identity.id(); + let contract_id = data_contract.id(); + check_and_process( + &platform, + owner_id, + transition_bytes, + |platform| { + platform + .drive + .fetch_identity_contract_nonce( + owner_id.to_buffer(), + contract_id.to_buffer(), + true, + None, + platform_version, + ) + .expect("expected to fetch the contract nonce") + }, + platform_version, + ) +} + +#[tokio::test] +async fn should_refuse_an_update_adding_a_summed_u64_property_with_a_paid_consensus_error() { + let outcome = update_contract_adding_schema( + summed_u64_schema(), + PlatformVersion::latest().protocol_version, + ) + .await; + + assert_eq!(outcome.nonce_before, None); + assert_paid_contract_structure_error(&outcome, SUMMED_U64_MESSAGE, UPDATE_NONCE); +} + +#[tokio::test] +async fn should_refuse_an_update_adding_a_terminal_outside_an_index_only_type_with_a_paid_consensus_error( +) { + let outcome = update_contract_adding_schema( + terminal_without_index_only_schema(), + PlatformVersion::latest().protocol_version, + ) + .await; + + assert_eq!(outcome.nonce_before, None); + assert_paid_contract_structure_error( + &outcome, + TERMINAL_WITHOUT_INDEX_ONLY_MESSAGE, + UPDATE_NONCE, + ); +} + +#[tokio::test] +async fn should_keep_refusing_an_update_adding_a_summed_u64_property_unpaid_at_protocol_version_13() +{ + let outcome = update_contract_adding_schema(summed_u64_schema(), 13).await; + + assert_unpaid_internal_error(&outcome, SUMMED_U64_MESSAGE); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs index 922da9217cc..c636dc271b9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs @@ -1,4 +1,6 @@ mod basic_structure; +#[cfg(test)] +mod contract_structure_error_tests; mod identity_contract_nonce; mod state; From c5e80c98f49c55600cf2a205c85b78f23ca03e85 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 05:20:46 +0700 Subject: [PATCH 057/113] fix(drive-abci): sign a locked block when a later proposal left no execution context (#5079) Co-authored-by: Claude Opus 5.5 --- .../src/abci/handler/extend_vote.rs | 110 +++++++++++++----- .../test_cases/vote_extension_round_tests.rs | 77 ++++++++++++ 2 files changed, 156 insertions(+), 31 deletions(-) diff --git a/packages/rs-drive-abci/src/abci/handler/extend_vote.rs b/packages/rs-drive-abci/src/abci/handler/extend_vote.rs index 3c7147ec658..9e21163caa5 100644 --- a/packages/rs-drive-abci/src/abci/handler/extend_vote.rs +++ b/packages/rs-drive-abci/src/abci/handler/extend_vote.rs @@ -25,45 +25,51 @@ where round, } = request; let block_execution_context_guard = app.block_execution_context().read().unwrap(); - let block_execution_context = - block_execution_context_guard - .as_ref() - .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( - "block execution context must be set in block begin handler for extend votes", - )))?; // Verify Tenderdash that it called this handler correctly - let block_state_info = &block_execution_context.block_state_info(); - - if !block_state_info.matches_current_block(height as u64, round as u32, block_hash.clone())? { - // Tenderdash signs again a block it locked in an earlier round without processing it in - // this round. A block's withdrawal transactions do not depend on the round, so sign the - // ones we built when we accepted it. - if let Some(vote_extensions) = app - .unsigned_withdrawal_txs_by_round() - .read() - .expect("poisoned only after a panic, which stops the node") - .get(height as u64, round as u32, &block_hash) + if let Some(block_execution_context) = block_execution_context_guard.as_ref() { + if block_execution_context + .block_state_info() + .matches_current_block(height as u64, round as u32, block_hash.clone())? { - return Ok(proto::ResponseExtendVote { - vote_extensions: vote_extensions.to_vec(), - }); + // Extend votes with unsigned withdrawal transactions + // we only want to sign the hash of the transaction + let vote_extensions = block_execution_context + .unsigned_withdrawal_transactions() + .into(); + + return Ok(proto::ResponseExtendVote { vote_extensions }); } + } - return Err(AbciError::RequestForWrongBlockReceived(format!( - "received extend votes request for height: {} round: {}, block: {}; expected height: {} round: {}, block: {}", - height, round, hex::encode(block_hash), - block_state_info.height(), block_state_info.round(), block_state_info.block_hash().map(hex::encode).unwrap_or("None".to_string()) - )).into()); + // Tenderdash signs again a block it locked in an earlier round without processing it in + // this round. That round's proposal has replaced the block execution context meanwhile, or + // left none when it was rejected before execution. A block's withdrawal transactions do not + // depend on the round, so sign the ones we built when we accepted it. + if let Some(vote_extensions) = app + .unsigned_withdrawal_txs_by_round() + .read() + .expect("poisoned only after a panic, which stops the node") + .get(height as u64, round as u32, &block_hash) + { + return Ok(proto::ResponseExtendVote { + vote_extensions: vote_extensions.to_vec(), + }); } - // Extend votes with unsigned withdrawal transactions - // we only want to sign the hash of the transaction - let vote_extensions = block_execution_context - .unsigned_withdrawal_transactions() - .into(); + let block_execution_context = + block_execution_context_guard + .as_ref() + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "block execution context must be set in block begin handler for extend votes", + )))?; + let block_state_info = &block_execution_context.block_state_info(); - Ok(proto::ResponseExtendVote { vote_extensions }) + Err(AbciError::RequestForWrongBlockReceived(format!( + "received extend votes request for height: {} round: {}, block: {}; expected height: {} round: {}, block: {}", + height, round, hex::encode(block_hash), + block_state_info.height(), block_state_info.round(), block_state_info.block_hash().map(hex::encode).unwrap_or("None".to_string()) + )).into()) } #[cfg(test)] @@ -287,4 +293,46 @@ mod tests { "a block this node has not accepted must not be signed" ); } + + /// A later round's proposal rejected before execution leaves no block execution context, and + /// Tenderdash can still sign the block it locked in an earlier round. + #[test] + fn should_sign_a_block_accepted_in_an_earlier_round_without_a_block_execution_context() { + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc(); + + let app = FullAbciApplication::::new(&platform.platform); + + let kept_extensions: Vec = + (&unsigned_withdrawal_transactions(1000)).into(); + app.unsigned_withdrawal_txs_by_round + .write() + .unwrap() + .insert(10, 0, [0xAA; 32], kept_extensions.clone()); + + let response = extend_vote::<_, MockCoreRPCLike>( + &app, + proto::RequestExtendVote { + hash: vec![0xAA; 32], + height: 10, + round: 1, + }, + ) + .expect("extend_vote should sign the kept withdrawals"); + assert_eq!(response.vote_extensions, kept_extensions); + + let result = extend_vote::<_, MockCoreRPCLike>( + &app, + proto::RequestExtendVote { + hash: vec![0xBB; 32], + height: 10, + round: 1, + }, + ); + assert!( + result.is_err(), + "a block this node has not accepted must not be signed" + ); + } } diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs index 1b2d3d59453..576709ad200 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs @@ -305,4 +305,81 @@ mod tests { "votes for the accepted round must still be verified" ); } + + /// A round 1 proposal rejected before it is executed leaves this node without a block + /// execution context. Tenderdash can still precommit the round 0 block it locked, in round 1 + /// and without processing it again, and it must be given that block's withdrawals. + #[tokio::test] + async fn should_sign_a_locked_block_after_a_later_proposal_was_rejected_before_execution() { + let mut platform = TestPlatformBuilder::new() + .with_config(config()) + .build_with_mock_rpc(); + let outcome = run_chain(&mut platform).await; + + queue_withdrawal_transaction(&outcome); + + let round_0_core_height = outcome + .abci_app + .platform + .state + .load() + .last_committed_core_height(); + let round_0 = proposal(&outcome, 0, round_0_core_height, ROUND_0_BLOCK); + let mut rejected_round_1 = proposal(&outcome, 1, round_0_core_height + 1, ROUND_1_BLOCK); + // A protocol version this node does not run is refused before the block is executed + rejected_round_1.version = Some(Consensus { + block: 0, + app: PlatformVersion::latest().protocol_version as u64 + 1, + }); + + let response = outcome + .abci_app + .process_proposal(round_0.clone()) + .expect("expected to process the round 0 proposal"); + assert_eq!(response.status, ProposalStatus::Accept as i32); + let round_0_extensions = extend_vote(&outcome, &round_0); + + let response = outcome + .abci_app + .process_proposal(rejected_round_1.clone()) + .expect("expected to process the round 1 proposal"); + assert_eq!(response.status, ProposalStatus::Reject as i32); + + assert!( + outcome + .abci_app + .block_execution_context + .read() + .unwrap() + .is_none(), + "test premise: the rejected proposal left no block execution context" + ); + assert_eq!( + round_0_extensions.len(), + 1, + "test premise: the queued withdrawal transaction is signed at this height" + ); + + let round_0_relocked_in_round_1 = RequestProcessProposal { + round: 1, + ..round_0 + }; + assert_eq!( + extend_vote(&outcome, &round_0_relocked_in_round_1), + round_0_extensions, + "the locked block must be signed with the withdrawals built when it was accepted" + ); + + assert!( + outcome + .abci_app + .extend_vote(RequestExtendVote { + hash: rejected_round_1.hash.clone(), + height: rejected_round_1.height, + round: rejected_round_1.round, + }) + .is_err(), + "the rejected block must not be signed" + ); + } } From e7aed01fee57357e95aa6b661ee6a3941b8fa9a6 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 05:22:44 +0700 Subject: [PATCH 058/113] feat(platform)!: contains in propertyConstraints rules (PV14) (#5083) Co-authored-by: Claude Opus 5.5 --- .../contract-keywords/property-constraints.md | 12 +- book/src/data-model/documents.md | 1 + packages/js-evo-sdk/README.md | 2 +- .../document/v3/document-meta.json | 18 +- .../class_methods/try_from_schema/mod.rs | 97 +++++-- .../v3/property_constraints_tests.rs | 194 +++++++++++++- .../document_type/property_constraints/mod.rs | 185 ++++++++++++- .../property_constraints/tests.rs | 249 +++++++++++++++++- .../tests/document/property_constraints.rs | 118 +++++++++ .../rs-platform-version/src/version/v14.rs | 39 +-- .../src/data_contract/property_constraints.rs | 7 +- .../document_type_property_constraints.rs | 11 +- .../unit/DocumentPropertyConstraints.spec.ts | 42 +++ 13 files changed, 918 insertions(+), 57 deletions(-) diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index 96af5a85356..31da07040ff 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -73,6 +73,7 @@ A rule is a condition: a JSON object with exactly one key. | `equal`, `notEqual` | `[left, right]` | The two sides are equal, or differ. The sides are two integer expressions, or a string property and a string constant or another string property, or an identifier property and an identifier constant, another identifier property or `$ownerId` | | `lessThan`, `lessThanOrEqual`, `greaterThan`, `greaterThanOrEqual` | `[left, right]` | The left integer expression compares with the right one this way. Integers only | | `in` | `[expression, [v1, v2, ...]]` | The expression takes one of the listed values: two or more, no two alike, all integers or all strings. With strings, the expression is a string property, or an identifier property or `$ownerId` with the strings as base58 identifiers | +| `contains` | `["path", value]` | The typed array property at the path holds an element equal to the value: an integer expression among integers; a string constant, a string property or an `ifAbsent` string default among strings; an identifier constant, an identifier property or `$ownerId` among identifiers. An array the document leaves out holds nothing, and a string or identifier property it leaves out is among no elements | | `present` | `"path"` | The document holds the property, with a value other than null | | `absent` | `"path"` | The document leaves the property out, or sets it to null | | `anyOf` | `[c1, c2, ...]` | At least one of two or more conditions holds | @@ -83,6 +84,14 @@ Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan An `in` says what an `anyOf` of `equal` comparisons says, in far fewer nodes: `{ "in": ["fee", [0, 10, 25, 50]] }` is 6 nodes where the `anyOf` is 13. +A `contains` looks the other way round, for one value among an array's elements: + +- `{ "not": { "contains": ["labels", { "const": "used" }] } }` refuses a `"used"` label; +- `{ "contains": ["participants", "$ownerId"] }` holds the owner to the participants, and since it reads `$ownerId`, a transfer or purchase to someone else is refused; +- `{ "contains": ["tiers", "quantity"] }` holds the quantity to one of the tiers the document lists. + +The kind of the array's elements decides what the value is: a `{ "const": "sale" }` is a string among strings and a base58 identifier among identifiers. + ## Expressions An integer expression is one of: @@ -186,7 +195,7 @@ The meta-schema checks the shape (`JsonSchemaError`, 10101): The parser then checks the rules against the document type (`InvalidContractStructure`, 10231): -- every path an integer expression reads names an integer or boolean property; every path `length` or `byteLength` measures names a string property, and every path `count` counts an array or byte array property; every path compared with a string names a string property; every path compared with an identifier names an identifier property; every path `present` or `absent` tests names a property of any type, an object included; +- every path an integer expression reads names an integer or boolean property; every path `length` or `byteLength` measures names a string property, and every path `count` counts an array or byte array property; every path a `contains` looks in names a typed array property whose elements are integers, strings or identifiers, of the kind of the value looked for (a string constant among them in the elements' `enum` when they declare one); every path compared with a string names a string property; every path compared with an identifier names an identifier property; every path `present` or `absent` tests names a property of any type, an object included; - no rule reads a property that is `transient` or inside a transient object, since a stored document could never be held to it; - every comparison and `in` reads at least one property: a comparison of constants would hold for every document or for none; - strings and identifiers are compared only with `equal`, `notEqual` and `in`; a string is never compared with an identifier; a property is never compared with itself; @@ -210,6 +219,7 @@ A rule within 32 nodes is never deep enough to reach the 64-level bound. Nodes a | An `equal` or `notEqual` of strings or identifiers | 3: the comparison and its two sides | | An `in` over integers | 1, plus its expression, plus 1 per value | | An `in` over strings or identifiers | 2, plus 1 per value | +| `contains` | 2, plus the value it looks for | | `present`, `absent` | 1 | | `anyOf`, `allOf` | 1, plus their conditions | | `not` | 1, plus its condition | diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 9d871d794eb..c07829ddbd9 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -713,6 +713,7 @@ The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeas - `{ "in": [expression, [values]] }`, holding if the integer expression takes one of two or more distinct integer values. It says what an `anyOf` of `equal`s says, in one node per value instead of three, so a set of up to 30 values fits the node limit where the `anyOf` fits 10. A value is a literal, never a path or an expression; - a string comparison: `{ "equal": [path, { "const": "closed" }] }` or `notEqual`, with the constant on either side; `{ "notEqual": ["fromCurrency", "toCurrency"] }`, two bare paths that both name string properties, which compares their strings; or `{ "in": [path, ["open", "pending"]] }`, whose values are two or more distinct strings. The path names a string property, typically one with an `enum`. A string on its own is a path, so a constant is written as `{ "const": ... }`, while the values an `in` lists are literals and need no wrapper. Strings are only compared for equality, never ordered or used in arithmetic. A string property the document leaves out equals no constant and no other string property, not even one also left out, so `notEqual` holds for it and `equal` and `in` do not, unless `{ "ifAbsent": [path, "open"] }` gives it a string default, which it then reads as (it may stand wherever the bare path does, and makes the comparison one of strings); `present` and `absent` test it directly. When the property declares an `enum`, every constant compared with it must be one of the enum's values, so a misspelling is refused at registration rather than making the rule quietly never hold; - an identifier comparison, the same three forms for identifier properties, those declaring `refersTo` included: `{ "equal": ["paymentToken", { "const": "" }] }` or `notEqual`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. Constants are base58 identifiers of 32 bytes, checked at registration, and compared by their bytes, whatever form the document gives the identifier in. An identifier property the document leaves out equals no identifier, not even another one left out; identifiers take no `ifAbsent` default and are never ordered. `$ownerId`, the document's owner, is an identifier operand too: `{ "equal": ["authorId", "$ownerId"] }` holds the author to the owner, and `{ "in": ["$ownerId", ["", ...]] }` lets only the identities listed own a document of the type. It is no property, so `present` or an integer operand refuses it, and comparing it with itself is refused. Since a transfer and a purchase give the document a new owner, each is judged against the rules reading `$ownerId`, with the new owner, and refused when it would break one; an indexOnly type refuses such a rule, since its deletes carry no owner; +- `{ "contains": [path, value] }`, holding if the typed array property at the path holds an element equal to the value, looked for as the array's elements are: an integer expression among integers, a string constant, string property or `ifAbsent` string default among strings, an identifier constant, identifier property or `$ownerId` among identifiers. `{ "not": { "contains": ["labels", { "const": "used" }] } }` refuses a `"used"` label, and `{ "contains": ["participants", "$ownerId"] }` holds the owner to the participants (so a transfer or purchase to a non-participant is refused). An array the document leaves out holds nothing, a string or identifier property it leaves out is among no elements, and a string constant must be one of the elements' `enum` values when they declare one; - `{ "present": path }`, holding if the document holds the property, and `{ "absent": path }`, holding if it leaves it out (a property set to null counts as left out). An operand reads a property the document leaves out as 0, so only these tell "not given" from "given as 0". They may name a property of any type, an object or a member of one included, since they read no value; - `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; - `{ "allOf": [...] }`, holding if every one of two or more conditions holds; diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 1832771c498..fc8e440c653 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -425,7 +425,7 @@ From protocol version 14 a document type can declare rules its documents' proper } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0), or a size: `{ "length": path }` and `{ "byteLength": path }` give the characters and UTF-8 bytes of a string property, and `{ "count": path }` the items of an array or the bytes of a byte array, so `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit (a size read is reported with kind `length` or `count`). A type that lists `$createdAt`, `$updatedAt` or `$transferredAt` (or any of them with `BlockHeight` or `CoreBlockHeight` appended) in `required` may read it too: `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week, and since a price update sets `$updatedAt` and a transfer or purchase `$transferredAt`, each is judged against the rules reading those. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `{ "contains": ["participants", "$ownerId"] }` holds when a typed array property has an element equal to the value, looked for as the array's elements are (an integer expression, a string or an identifier), so `{ "not": { "contains": ["labels", { "const": "used" }] } }` refuses a label; the array is reported as a read of kind `elements`. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0), or a size: `{ "length": path }` and `{ "byteLength": path }` give the characters and UTF-8 bytes of a string property, and `{ "count": path }` the items of an array or the bytes of a byte array, so `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit (a size read is reported with kind `length` or `count`). A type that lists `$createdAt`, `$updatedAt` or `$transferredAt` (or any of them with `BlockHeight` or `CoreBlockHeight` appended) in `required` may read it too: `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week, and since a price update sets `$updatedAt` and a transfer or purchase `$transferredAt`, each is judged against the rules reading those. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index b80409fad81..813bc2247ea 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -40,7 +40,7 @@ } }, "propertyConstraint": { - "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right (equal and notEqual may instead compare the path of a string property with a const string or with the path of another string property, and likewise the path of an identifier property with a const base58 identifier or another identifier property), in listing an expression and the values it may take, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", + "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right (equal and notEqual may instead compare the path of a string property with a const string or with the path of another string property, and likewise the path of an identifier property with a const base58 identifier or another identifier property), in listing an expression and the values it may take, contains listing a typed array property and a value its elements must include, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", "type": "object", "properties": { "equal": { @@ -89,6 +89,20 @@ "items": false, "minItems": 2 }, + "contains": { + "description": "Holds if the typed array property at the path listed first holds an element equal to the value listed second: an integer expression among integers, a const string or a string property (or an ifAbsent giving one a default) among strings, a const base58 identifier, an identifier property or $ownerId among identifiers, as the array's elements are. An array the document leaves out holds nothing, and so does a string or identifier property it leaves out", + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/propertyConstraintPath" + }, + { + "$ref": "#/$defs/propertyConstraintExpression" + } + ], + "items": false, + "minItems": 2 + }, "present": { "description": "Holds if the document holds the property at this path, of any type, an object included, with a value other than null. Unlike an operand, which reads a property the document leaves out as 0, it tells a property left out from one set to 0", "$ref": "#/$defs/propertyConstraintPath" @@ -2080,7 +2094,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, a system time or height the document type records by listing it in required ($createdAt, $updatedAt and $transferredAt, block times in milliseconds, and each with BlockHeight or CoreBlockHeight appended, the Platform and Core block heights: those of the create, of the last create, replace or price update, and of the last create, transfer or purchase), or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out ({ \"lessThanOrEqual\": [{ \"count\": \"tags\" }, \"maxTags\"] }). A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property length or byteLength measures a string property, every property count counts an array or byte array property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every const, every present or absent, every arithmetic operator and every operand, a size included; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. Likewise a transfer or a purchase is judged against the rules reading the transfer's time and heights, and a price update against those reading the update's, since each sets them; an indexOnly type reads no system time or height. The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, contains listing the path of a typed array property and the value one of its elements must equal (an integer expression among integers, a const string or string property among strings, a const base58 identifier, identifier property or $ownerId among identifiers; an array left out holds nothing, and a constant must be one of the elements' enum values when they declare one), present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, a system time or height the document type records by listing it in required ($createdAt, $updatedAt and $transferredAt, block times in milliseconds, and each with BlockHeight or CoreBlockHeight appended, the Platform and Core block heights: those of the create, of the last create, replace or price update, and of the last create, transfer or purchase), or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out ({ \"lessThanOrEqual\": [{ \"count\": \"tags\" }, \"maxTags\"] }). A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property length or byteLength measures a string property, every property count counts an array or byte array property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every contains and its array, every const, every present or absent, every arithmetic operator and every operand, a size included; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. Likewise a transfer or a purchase is judged against the rules reading the transfer's time and heights, and a price update against those reading the update's, since each sets them; an indexOnly type reads no system time or height. The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index c0245710302..12215cc56e5 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -2,7 +2,7 @@ use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::class_methods::apply_required_since::apply_required_since; use crate::data_contract::document_type::class_methods::parse_typed_array::parse_typed_array; use crate::data_contract::document_type::property_constraints::{ - parse_property_constraints, EqualityKind, PropertyRead, + parse_property_constraints, ElementKind, EqualityKind, PropertyRead, }; use crate::data_contract::document_type::reference_lookup::{ MAX_LOOKUP_INDEX_NAME_LENGTH, MAX_LOOKUP_KEYS, MAX_LOOKUP_PATH_LENGTH, @@ -1949,13 +1949,20 @@ fn apply_property_constraints_v0( platform_version: &PlatformVersion, ) -> Result<(), DataContractError> { let flattened_properties = &document_type.flattened_properties; + let equality_kind = |property_type: &DocumentPropertyType| match property_type { + DocumentPropertyType::String(_) => Some(EqualityKind::Text), + property_type if property_type.is_identifier() => Some(EqualityKind::Identifier), + _ => None, + }; + // A typed array compares as its elements do, in a `contains`; anywhere else + // the reads below refuse an array where a string or an identifier belongs let property_kind = |path: &str| match flattened_properties .get(path) .map(|property| &property.property_type) { - Some(DocumentPropertyType::String(_)) => Some(EqualityKind::Text), - Some(property_type) if property_type.is_identifier() => Some(EqualityKind::Identifier), - _ => None, + Some(DocumentPropertyType::TypedArray(array)) => equality_kind(&array.item_type), + Some(property_type) => equality_kind(property_type), + None => None, }; let constraints = parse_property_constraints(&document_type.schema, document_type_name, &property_kind)?; @@ -1973,6 +1980,7 @@ fn apply_property_constraints_v0( PropertyRead::Text | PropertyRead::Identifier => "compares", PropertyRead::Length => "measures", PropertyRead::Count => "counts the items of", + PropertyRead::Elements(_) => "looks in", }; match read { PropertyRead::Value => match document_type @@ -2107,6 +2115,53 @@ fn apply_property_constraints_v0( ))); } }, + PropertyRead::Elements(kind) => match document_type + .flattened_properties + .get(path) + .map(|property| &property.property_type) + { + Some(DocumentPropertyType::TypedArray(array)) => { + let element_type = array.item_type.as_ref(); + let (holds, kind_name) = match kind { + ElementKind::Integer => ( + element_type.is_integer() + || matches!( + element_type, + DocumentPropertyType::U128 | DocumentPropertyType::I128 + ), + "an integer", + ), + ElementKind::Text => ( + matches!(element_type, DocumentPropertyType::String(_)), + "a string", + ), + ElementKind::Identifier => { + (element_type.is_identifier(), "an identifier") + } + }; + if !holds { + return Err(structure_error(format!( + "rule \"{name}\" looks in \"{path}\" for {kind_name}, but its \ + elements have type {}: contains looks for an integer, a string \ + or an identifier among elements of that type", + element_type.name() + ))); + } + } + Some(other) => { + return Err(structure_error(format!( + "rule \"{name}\" looks in \"{path}\", which has type {}, not an \ + array with items", + other.name() + ))); + } + None => { + return Err(structure_error(format!( + "rule \"{name}\" looks in \"{path}\", which is not an array property \ + of the document type (a nested one is named by its dotted path)" + ))); + } + }, PropertyRead::Identifier => match document_type .flattened_properties .get(path) @@ -2218,6 +2273,11 @@ fn enum_admits(schema: &Value, path: &str, value: &str) -> Result resolve_schema(schema, items)?, + None => property_schema, + }; let Some(Value::Array(members)) = property_schema.get(property_names::ENUM) else { return Ok(true); }; @@ -2230,19 +2290,7 @@ fn schema_at_path<'a>( schema: &'a Value, path: &str, ) -> Result>, DataContractError> { - fn resolve<'a>( - root_schema: &'a Value, - value: &'a Value, - ) -> Result, DataContractError> { - let map = value.to_btree_ref_string_map()?; - match map.get_optional_str(property_names::REF)? { - Some(schema_ref) => { - Ok(resolve_uri(root_schema, schema_ref)?.to_btree_ref_string_map()?) - } - None => Ok(map), - } - } - let mut current = resolve(schema, schema)?; + let mut current = resolve_schema(schema, schema)?; for segment in path.split('.') { let Some(properties) = current.get(property_names::PROPERTIES) else { return Ok(None); @@ -2250,11 +2298,24 @@ fn schema_at_path<'a>( let Some(next) = properties.to_btree_ref_string_map()?.get(segment).copied() else { return Ok(None); }; - current = resolve(schema, next)?; + current = resolve_schema(schema, next)?; } Ok(Some(current)) } +/// The schema `value` is within the document type schema `schema`, its `$ref` +/// followed. +fn resolve_schema<'a>( + schema: &'a Value, + value: &'a Value, +) -> Result, DataContractError> { + let map = value.to_btree_ref_string_map()?; + match map.get_optional_str(property_names::REF)? { + Some(schema_ref) => Ok(resolve_uri(schema, schema_ref)?.to_btree_ref_string_map()?), + None => Ok(map), + } +} + /// Whether the property at the dotted `path` of `schema` is declared as an /// integer with `minimum` at least 0 and `maximum` at most `u32::MAX`, read /// from the schema rather than from the parsed type so that the answer does diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index 52aa7cc85ca..efce6452026 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -14,7 +14,7 @@ use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; use crate::data_contract::document_type::property_constraints::{ - DocumentSystemValues, PropertyConstraint, PropertyRead, SystemProperty, + DocumentSystemValues, ElementKind, PropertyConstraint, PropertyRead, SystemProperty, }; use crate::data_contract::methods::validate_update::DataContractUpdateValidationMethodsV0; use crate::data_contract::DataContract; @@ -1566,3 +1566,195 @@ fn should_read_the_system_times_and_heights_the_type_records() { ); } } + +/// A `listing` type with typed arrays of strings (with an `enum`), of +/// identifiers, of integers and of booleans, a byte array, a string, an +/// integer and an identifier, declaring `rules`, `transient` listed transient. +fn listing_schema(rules: serde_json::Value, transient: Option<&str>) -> Value { + let mut schema = json!({ + "type": "object", + "properties": { + "labels": { + "type": "array", + "maxItems": 5, + "items": { "type": "string", "maxLength": 10, "enum": ["sale", "new", "used"] }, + "position": 0 + }, + "members": { + "type": "array", + "maxItems": 5, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 1 + }, + "scores": { + "type": "array", + "maxItems": 5, + "items": { "type": "integer", "minimum": 0, "maximum": 100 }, + "position": 2 + }, + "flags": { + "type": "array", + "maxItems": 2, + "items": { "type": "boolean" }, + "position": 3 + }, + "signature": { "type": "array", "byteArray": true, "maxItems": 65, "position": 4 }, + "status": { "type": "string", "maxLength": 10, "position": 5 }, + "pick": { "type": "integer", "minimum": 0, "maximum": 100, "position": 6 }, + "buyerId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 7 + } + }, + "additionalProperties": false, + "propertyConstraints": rules + }); + if let Some(transient) = transient { + schema["transient"] = json!([transient]); + } + schema_value(schema) +} + +/// `contains` looks among the elements of a typed array of integers, strings +/// or identifiers, on both paths, for what the array's elements are. +#[test] +fn should_look_among_the_elements_of_typed_arrays() { + let rules = json!({ + "onSale": { "contains": ["labels", { "const": "sale" }] }, + "labelledAsStatus": { "contains": ["labels", "status"] }, + "ownerIsMember": { "contains": ["members", "$ownerId"] }, + "buyerIsMember": { "contains": ["members", "buyerId"] }, + "pickScored": { "not": { "contains": ["scores", "pick"] } } + }); + for full_validation in [true, false] { + let document_type = parse_dispatched( + listing_schema(rules.clone(), None), + PlatformVersion::latest(), + full_validation, + ) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let constraints = document_type.property_constraints(); + assert_eq!( + constraints["onSale"].property_reads(), + [("labels", PropertyRead::Elements(ElementKind::Text))] + ); + assert_eq!( + constraints["labelledAsStatus"].property_reads(), + [ + ("labels", PropertyRead::Elements(ElementKind::Text)), + ("status", PropertyRead::Text) + ] + ); + assert!(constraints["ownerIsMember"].reads_owner()); + assert_eq!( + constraints["buyerIsMember"].property_reads(), + [ + ("members", PropertyRead::Elements(ElementKind::Identifier)), + ("buyerId", PropertyRead::Identifier) + ] + ); + assert_eq!( + constraints["pickScored"].property_reads(), + [ + ("scores", PropertyRead::Elements(ElementKind::Integer)), + ("pick", PropertyRead::Value) + ] + ); + } +} + +/// The array must be a typed array whose elements are of the kind looked for, +/// stored, and a constant one of the elements' enum values; what is looked +/// for is held to its own kind's checks. +#[test] +fn should_hold_contains_to_arrays_of_the_kind_looked_for() { + for (rule, needle) in [ + ( + json!({ "contains": ["labels", { "const": "old" }] }), + "rule \"rule\" compares \"labels\" with \"old\", which is not one of its enum values", + ), + ( + json!({ "contains": ["flags", 1] }), + "rule \"rule\" looks in \"flags\" for an integer, but its elements have type boolean", + ), + ( + json!({ "contains": ["status", { "const": "sale" }] }), + "rule \"rule\" looks in \"status\", which has type string, not an array with items", + ), + ( + json!({ "contains": ["signature", 64] }), + "rule \"rule\" looks in \"signature\", which has type byteArray, not an array with \ + items", + ), + ( + json!({ "contains": ["missing", 1] }), + "rule \"rule\" looks in \"missing\", which is not an array property of the document \ + type", + ), + ( + json!({ "contains": ["scores", "status"] }), + "reads \"status\", which has type string, not integer or boolean", + ), + ( + json!({ "contains": ["labels", "buyerId"] }), + "compares \"buyerId\" with a string, but it has type identifier, not string", + ), + ( + json!({ "contains": ["members", "status"] }), + "compares \"status\" with an identifier, but it has type string, not identifier", + ), + ] { + for full_validation in [true, false] { + expect_structure_error( + parse_dispatched( + listing_schema(json!({ "rule": rule.clone() }), None), + PlatformVersion::latest(), + full_validation, + ), + needle, + ); + } + } + + // A transient array is never stored, so no rule may look in one + for full_validation in [true, false] { + expect_structure_error( + parse_dispatched( + listing_schema( + json!({ "rule": { "contains": ["labels", { "const": "sale" }] } }), + Some("labels"), + ), + PlatformVersion::latest(), + full_validation, + ), + "looks in \"labels\", which is transient or inside a transient object", + ); + } + + // The meta-schema checks the shape when registering + for rules in [ + json!({ "rule": { "contains": ["labels"] } }), + json!({ "rule": { "contains": ["labels", "status", "pick"] } }), + json!({ "rule": { "contains": "labels" } }), + ] { + let registered = parse_dispatched( + listing_schema(rules.clone(), None), + PlatformVersion::latest(), + true, + ); + assert!( + registered.as_ref().is_err_and(is_json_schema_error), + "{rules}: the meta-schema should refuse it, got {registered:?}" + ); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index dd5e6e5b977..88e89c30539 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -4,8 +4,9 @@ //! expressions, a test of whether an integer expression takes one of listed //! values (`in`), a comparison of a string or an identifier property with //! constants (`equal`, `notEqual`, `in`) or with another property of its kind -//! (`equal`, `notEqual`), a test of whether the document holds a property -//! (`present`, `absent`), or `anyOf`, `allOf` or `not` over conditions. +//! (`equal`, `notEqual`), a test of whether an array property holds a value +//! (`contains`), a test of whether the document holds a property (`present`, +//! `absent`), or `anyOf`, `allOf` or `not` over conditions. //! //! ```json //! "propertyConstraints": { @@ -99,6 +100,7 @@ const NOT: &str = "not"; const PRESENT: &str = "present"; const ABSENT: &str = "absent"; const IN: &str = "in"; +const CONTAINS: &str = "contains"; const LENGTH: &str = "length"; const BYTE_LENGTH: &str = "byteLength"; const COUNT: &str = "count"; @@ -617,13 +619,29 @@ pub enum PropertyRead { Length, /// By its size, in a `count` operand: an array or byte array property. Count, + /// By its elements, which a `contains` looks among for a value of the + /// kind given: a typed array property with elements of that kind. + Elements(ElementKind), +} + +/// What a `contains` looks for among an array's elements, which decides the +/// elements the array must have. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ElementKind { + /// An integer, the value of an integer expression. + Integer, + /// A string. + Text, + /// An identifier. + Identifier, } /// What a comparison of equality compares when it is not integers: strings or /// identifiers. [`parse_property_constraints`] asks it of every bare path on -/// either side of an `equal` or `notEqual`, and of an `in`'s operand, since -/// the declaration alone does not tell a string property, an identifier -/// property or an integer one apart. +/// either side of an `equal` or `notEqual`, of an `in`'s operand, and of the +/// array a `contains` looks in (the kind of its elements), since the +/// declaration alone does not tell a string property, an identifier property +/// or an integer one apart. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EqualityKind { /// A string property. @@ -659,6 +677,24 @@ impl TextProperty { } } +/// What a `contains` looks for among an array property's elements, of the +/// kind of its elements. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContainsNeedle { + /// The value of an integer expression, among integers. + Integer(ConstraintExpression), + /// A string constant, among strings. + TextConstant(String), + /// The string a string property holds, or its `ifAbsent` default, among + /// strings; one the document leaves out without a default is among none. + TextProperty(TextProperty), + /// An identifier constant, among identifiers. + IdentifierConstant(Identifier), + /// The identifier at the dotted path, or `$ownerId`, among identifiers; + /// one the document leaves out is among none. + IdentifierProperty(String), +} + /// A rule of `propertyConstraints`, or a condition inside one: a comparison of /// two integer expressions, a test of whether an integer expression takes one /// of listed values, a comparison of a string property with string constants, @@ -725,6 +761,13 @@ pub enum PropertyConstraint { path: String, values: BTreeSet, }, + /// `contains`: the typed array property at the dotted path `array` holds + /// an element equal to `needle`, `{ "contains": ["tags", { "const": "sale" }] }`. + /// An array the document leaves out holds nothing. + Contains { + array: String, + needle: ContainsNeedle, + }, /// `present`: the document holds the property at the dotted path. One it /// leaves out, or sets to null, is absent, as it is for an operand. Unlike /// an operand, it tells a property left out from one set to 0, and it may @@ -823,6 +866,40 @@ impl PropertyConstraint { Ok(identifier_value(data, owner_id, path) .is_some_and(|value| values.contains(&value))) } + PropertyConstraint::Contains { array, needle } => { + let elements = match data.get_optional_value_at_path(array) { + Ok(Some(Value::Array(elements))) => elements.as_slice(), + _ => &[], + }; + Ok(match needle { + ContainsNeedle::Integer(expression) => { + let value = expression.evaluate(data, system)?; + elements.iter().any(|element| { + element.is_integer() && element.to_integer::().ok() == Some(value) + }) + } + ContainsNeedle::TextConstant(value) => elements + .iter() + .any(|element| element.as_text() == Some(value.as_str())), + ContainsNeedle::TextProperty(property) => { + property.value(data).is_some_and(|value| { + elements + .iter() + .any(|element| element.as_text() == Some(value)) + }) + } + ContainsNeedle::IdentifierConstant(value) => elements + .iter() + .any(|element| element.to_identifier().ok() == Some(*value)), + ContainsNeedle::IdentifierProperty(path) => { + identifier_value(data, owner_id, path).is_some_and(|value| { + elements + .iter() + .any(|element| element.to_identifier().ok() == Some(value)) + }) + } + }) + } PropertyConstraint::Present(path) => Ok(is_present(data, path)), PropertyConstraint::Absent(path) => Ok(!is_present(data, path)), PropertyConstraint::AnyOf(conditions) => { @@ -874,7 +951,8 @@ impl PropertyConstraint { /// The nodes of the rule, counted against /// `SystemLimits::max_property_constraint_nodes`: every comparison and /// logical operator, every `in` and each value it lists, every string - /// constant, every `present` or `absent` with the property it names, every + /// constant, every `contains` with its array and what it looks for, every + /// `present` or `absent` with the property it names, every /// arithmetic operator and every operand (an integer value, a property with /// or without `ifAbsent`, a size or a system property). pub fn node_count(&self) -> usize { @@ -890,6 +968,16 @@ impl PropertyConstraint { | PropertyConstraint::IdentifierCompareProperties { .. } => 2, PropertyConstraint::TextIn { values, .. } => 1 + values.len(), PropertyConstraint::IdentifierIn { values, .. } => 1 + values.len(), + // The array, and what is looked for among its elements + PropertyConstraint::Contains { needle, .. } => { + 1 + match needle { + ContainsNeedle::Integer(expression) => expression.node_count(), + ContainsNeedle::TextConstant(_) + | ContainsNeedle::TextProperty(_) + | ContainsNeedle::IdentifierConstant(_) + | ContainsNeedle::IdentifierProperty(_) => 1, + } + } PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => 0, PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { conditions.iter().map(PropertyConstraint::node_count).sum() @@ -920,6 +1008,10 @@ impl PropertyConstraint { /// judged against it too. pub fn reads_owner(&self) -> bool { match self { + PropertyConstraint::Contains { + needle: ContainsNeedle::IdentifierProperty(path), + .. + } => path == OWNER_ID, PropertyConstraint::IdentifierCompare { path, .. } | PropertyConstraint::IdentifierIn { path, .. } => path == OWNER_ID, PropertyConstraint::IdentifierCompareProperties { left, right, .. } => { @@ -934,6 +1026,7 @@ impl PropertyConstraint { | PropertyConstraint::TextCompare { .. } | PropertyConstraint::TextCompareProperties { .. } | PropertyConstraint::TextIn { .. } + | PropertyConstraint::Contains { .. } | PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => false, } @@ -968,6 +1061,10 @@ impl PropertyConstraint { right.collect_system_reads(reads); } PropertyConstraint::In { operand, .. } => operand.collect_system_reads(reads), + PropertyConstraint::Contains { + needle: ContainsNeedle::Integer(expression), + .. + } => expression.collect_system_reads(reads), PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { for condition in conditions { condition.collect_system_reads(reads); @@ -980,6 +1077,7 @@ impl PropertyConstraint { | PropertyConstraint::IdentifierCompare { .. } | PropertyConstraint::IdentifierCompareProperties { .. } | PropertyConstraint::IdentifierIn { .. } + | PropertyConstraint::Contains { .. } | PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => {} } @@ -1029,11 +1127,16 @@ impl PropertyConstraint { } } PropertyConstraint::Not(condition) => condition.collect_text_properties(properties), + PropertyConstraint::Contains { + needle: ContainsNeedle::TextProperty(property), + .. + } => properties.push(property), PropertyConstraint::Compare { .. } | PropertyConstraint::In { .. } | PropertyConstraint::IdentifierCompare { .. } | PropertyConstraint::IdentifierCompareProperties { .. } | PropertyConstraint::IdentifierIn { .. } + | PropertyConstraint::Contains { .. } | PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => {} } @@ -1055,12 +1158,18 @@ impl PropertyConstraint { } } PropertyConstraint::Not(condition) => condition.collect_text_constants(constants), + // Checked against the enum of the array's elements + PropertyConstraint::Contains { + array, + needle: ContainsNeedle::TextConstant(value), + } => constants.push((array, value)), PropertyConstraint::Compare { .. } | PropertyConstraint::In { .. } | PropertyConstraint::TextCompareProperties { .. } | PropertyConstraint::IdentifierCompare { .. } | PropertyConstraint::IdentifierCompareProperties { .. } | PropertyConstraint::IdentifierIn { .. } + | PropertyConstraint::Contains { .. } | PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => {} } @@ -1091,6 +1200,7 @@ impl PropertyConstraint { | PropertyConstraint::IdentifierCompare { .. } | PropertyConstraint::IdentifierCompareProperties { .. } | PropertyConstraint::IdentifierIn { .. } + | PropertyConstraint::Contains { .. } | PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => return None, PropertyConstraint::AnyOf(conditions) => (ANY_OF, conditions), @@ -1151,6 +1261,29 @@ impl PropertyConstraint { } } } + PropertyConstraint::Contains { array, needle } => match needle { + ContainsNeedle::Integer(expression) => { + reads.push((array, PropertyRead::Elements(ElementKind::Integer))); + expression.collect_property_reads(reads); + } + ContainsNeedle::TextConstant(_) => { + reads.push((array, PropertyRead::Elements(ElementKind::Text))) + } + ContainsNeedle::TextProperty(property) => { + reads.push((array, PropertyRead::Elements(ElementKind::Text))); + reads.push((&property.path, PropertyRead::Text)); + } + ContainsNeedle::IdentifierConstant(_) => { + reads.push((array, PropertyRead::Elements(ElementKind::Identifier))) + } + ContainsNeedle::IdentifierProperty(path) => { + reads.push((array, PropertyRead::Elements(ElementKind::Identifier))); + // `$ownerId` is the document's owner, no property of it + if path != OWNER_ID { + reads.push((path, PropertyRead::Identifier)); + } + } + }, PropertyConstraint::Present(path) | PropertyConstraint::Absent(path) => { reads.push((path, PropertyRead::Presence)) } @@ -1269,7 +1402,7 @@ fn single_entry(value: &Value) -> Option<(&str, &Value)> { /// Every key a condition object may hold, for the errors. fn condition_keys() -> String { format!( - "a comparison ({}), in, present, absent, anyOf, allOf or not", + "a comparison ({}), in, contains, present, absent, anyOf, allOf or not", ConstraintComparison::ALL .map(ConstraintComparison::wire_name) .join(", ") @@ -1406,6 +1539,44 @@ fn parse_condition( PropertyConstraint::In { operand, values } } } + CONTAINS => { + let Some([array, needle]) = body.as_array().map(Vec::as_slice) else { + return Err(format!( + "at {at} must list an array property path and the value looked for among \ + its elements" + )); + }; + // `$ownerId` and the system times are values, never arrays + let Some(array) = array.as_text().filter(|path| !path.starts_with('$')) else { + return Err(format!("at {at}[0] must name an array property path")); + }; + let base = at.len(); + at.push_str("[1]"); + // The kind of the array's elements decides what a const spells, and + // what is checked against the parsed document type + let needle = match property_kind(array) { + Some(EqualityKind::Text) => match text_side(needle, at)? { + TextSide::Constant(value) => ContainsNeedle::TextConstant(value), + TextSide::Property(property) => ContainsNeedle::TextProperty(property), + }, + Some(EqualityKind::Identifier) => match identifier_side(needle, at)? { + IdentifierSide::Constant(value) => ContainsNeedle::IdentifierConstant(value), + IdentifierSide::Property(path) => ContainsNeedle::IdentifierProperty(path), + }, + None if is_const(needle) => { + return Err(format!( + "at {at} is a const, but {array} holds no strings or identifiers: an \ + integer is written as itself" + )); + } + None => ContainsNeedle::Integer(parse_expression(needle, at, depth + 1)?), + }; + at.truncate(base); + PropertyConstraint::Contains { + array: array.to_string(), + needle, + } + } // What the path names is checked against the parsed document type PRESENT | ABSENT => { let Some(path) = body.as_text() else { diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index 2cfdb80de03..bcf602b5574 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -2,12 +2,14 @@ use super::*; use platform_value::platform_value; use platform_version::version::PLATFORM_VERSIONS; -/// The paths the unit tests treat as string properties. -const STRING_PROPERTIES: [&str; 4] = ["status", "from", "to", "meta.state"]; +/// The paths the unit tests treat as string properties, `labels` an array of +/// strings, as a document type's parse reports one. +const STRING_PROPERTIES: [&str; 5] = ["status", "from", "to", "meta.state", "labels"]; /// The paths the unit tests treat as identifier properties; every path neither lists /// is an integer one. -const IDENTIFIER_PROPERTIES: [&str; 3] = ["buyerId", "sellerId", "meta.ownerRef"]; +/// `members` is an array of identifiers. +const IDENTIFIER_PROPERTIES: [&str; 4] = ["buyerId", "sellerId", "meta.ownerRef", "members"]; fn property_kind(path: &str) -> Option { if STRING_PROPERTIES.contains(&path) { @@ -1588,8 +1590,8 @@ fn should_parse_present_and_absent() { ( platform_value!({ "exists": "discount" }), "rule \"rule\" names \"exists\", which is not a comparison (equal, notEqual, \ - lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual), in, present, absent, \ - anyOf, allOf or not", + lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual), in, contains, present, \ + absent, anyOf, allOf or not", ), ] { expect_refusal(platform_value!({ "rule": condition }), needle); @@ -2590,3 +2592,240 @@ fn should_take_the_system_values_of_a_create_and_of_a_document() { } ); } + +// ── contains ──────────────────────────────────────────────────────────── + +/// What a `contains` looks for is read as the array's elements are: a const +/// and a bare path among strings or identifiers, an integer expression +/// otherwise. +#[test] +fn should_parse_contains_by_the_kind_of_the_array() { + let member = Identifier::new([4; 32]); + for (rule, needle, reads, nodes) in [ + ( + platform_value!({ "contains": ["labels", { "const": "sale" }] }), + ContainsNeedle::TextConstant("sale".to_string()), + vec![("labels", PropertyRead::Elements(ElementKind::Text))], + 3, + ), + ( + platform_value!({ "contains": ["labels", "status"] }), + ContainsNeedle::TextProperty(TextProperty { + path: "status".to_string(), + if_absent: None, + }), + vec![ + ("labels", PropertyRead::Elements(ElementKind::Text)), + ("status", PropertyRead::Text), + ], + 3, + ), + ( + platform_value!({ "contains": ["labels", { "ifAbsent": ["status", "sale"] }] }), + ContainsNeedle::TextProperty(TextProperty { + path: "status".to_string(), + if_absent: Some("sale".to_string()), + }), + vec![ + ("labels", PropertyRead::Elements(ElementKind::Text)), + ("status", PropertyRead::Text), + ], + 3, + ), + ( + platform_value!({ + "contains": ["members", { "const": member.to_string(Encoding::Base58) }] + }), + ContainsNeedle::IdentifierConstant(member), + vec![("members", PropertyRead::Elements(ElementKind::Identifier))], + 3, + ), + ( + platform_value!({ "contains": ["members", "buyerId"] }), + ContainsNeedle::IdentifierProperty("buyerId".to_string()), + vec![ + ("members", PropertyRead::Elements(ElementKind::Identifier)), + ("buyerId", PropertyRead::Identifier), + ], + 3, + ), + ( + platform_value!({ "contains": ["members", "$ownerId"] }), + ContainsNeedle::IdentifierProperty("$ownerId".to_string()), + vec![("members", PropertyRead::Elements(ElementKind::Identifier))], + 3, + ), + ( + platform_value!({ "contains": ["scores", { "add": ["bonus", 1] }] }), + ContainsNeedle::Integer(ConstraintExpression::Add(vec![ + property("bonus"), + ConstraintExpression::Value(1), + ])), + vec![ + ("scores", PropertyRead::Elements(ElementKind::Integer)), + ("bonus", PropertyRead::Value), + ], + 5, + ), + ] { + let parsed = parse_rule_value(rule.clone()); + let PropertyConstraint::Contains { + array, + needle: parsed_needle, + } = &parsed + else { + panic!("{rule:?}: expected a contains, got {parsed:?}"); + }; + assert_eq!(array, reads[0].0, "{rule:?}"); + assert_eq!(parsed_needle, &needle, "{rule:?}"); + assert_eq!(parsed.property_reads(), reads, "{rule:?}"); + assert_eq!(parsed.node_count(), nodes, "{rule:?}"); + } + + // The owner read makes a transfer answer to it; a const is checked against + // the elements' enum; a default against the property's + let owner_rule = parse_rule_value(platform_value!({ "contains": ["members", "$ownerId"] })); + assert!(owner_rule.reads_owner()); + assert!(owner_rule.reads_change(SystemChange::Transfer)); + let sale = parse_rule_value(platform_value!({ "contains": ["labels", { "const": "sale" }] })); + assert_eq!(sale.text_constants(), [("labels", "sale")]); + let defaulted = parse_rule_value(platform_value!({ + "contains": ["labels", { "ifAbsent": ["status", "sale"] }] + })); + assert_eq!(defaulted.text_defaults(), [("status", "sale")]); + // A system value looked for among integers is read like any operand + let created = parse_rule_value(platform_value!({ "contains": ["scores", "$createdAt"] })); + assert_eq!(created.system_reads(), [SystemProperty::CreatedAt]); +} + +#[test] +fn should_refuse_a_malformed_contains() { + for (rule, needle) in [ + ( + platform_value!({ "contains": ["labels"] }), + "at contains must list an array property path and the value looked for among its \ + elements", + ), + ( + platform_value!({ "contains": [5, 1] }), + "at contains[0] must name an array property path", + ), + ( + platform_value!({ "contains": ["$ownerId", 1] }), + "at contains[0] must name an array property path", + ), + ( + platform_value!({ "contains": ["scores", { "const": "10" }] }), + "at contains[1] is a const, but scores holds no strings or identifiers", + ), + ( + platform_value!({ "contains": ["members", { "const": "not base58" }] }), + "which is not a base58 identifier of 32 bytes", + ), + ( + platform_value!({ "contains": ["labels", 5] }), + "at contains[1] must be the path of a string property", + ), + ( + platform_value!({ "contains": ["scores", { "divide": ["bonus", 0] }] }), + "divides by 0", + ), + ] { + expect_refusal(platform_value!({ "rule": rule }), needle); + } +} + +/// A `contains` holds when an element equals what it looks for, whatever form +/// the document gives an identifier in; an array, a string or an identifier +/// the document leaves out holds or matches nothing; a fault in the integer it +/// looks for breaks the rule. +#[test] +fn should_look_for_a_value_among_the_elements() { + let text = + |values: &[&str]| Value::Array(values.iter().map(|value| Value::from(*value)).collect()); + let none = DocumentSystemValues::default(); + + let sale = parse_rule_value(platform_value!({ "contains": ["labels", { "const": "sale" }] })); + assert_eq!( + sale.holds(&data(&[("labels", text(&["new", "sale"]))]), &none), + Ok(true) + ); + assert_eq!( + sale.holds(&data(&[("labels", text(&["new"]))]), &none), + Ok(false) + ); + assert_eq!(sale.holds(&data(&[]), &none), Ok(false)); + assert_eq!( + sale.holds(&data(&[("labels", Value::Null)]), &none), + Ok(false) + ); + + let own_status = parse_rule_value(platform_value!({ + "contains": ["labels", { "ifAbsent": ["status", "sale"] }] + })); + let listing = |status: Option<&str>| { + let mut entries = vec![("labels", text(&["new", "sale"]))]; + if let Some(status) = status { + entries.push(("status", Value::from(status))); + } + data(&entries) + }; + assert_eq!(own_status.holds(&listing(Some("new")), &none), Ok(true)); + assert_eq!(own_status.holds(&listing(Some("used")), &none), Ok(false)); + // Left out, the status takes its default + assert_eq!(own_status.holds(&listing(None), &none), Ok(true)); + + let [a, b, c] = [[1u8; 32], [2; 32], [3; 32]]; + let members = Value::Array(vec![Value::Identifier(a), Value::Bytes32(b)]); + let owner_is_member = + parse_rule_value(platform_value!({ "contains": ["members", "$ownerId"] })); + let group = data(&[("members", members.clone())]); + for (owner, expected) in [ + (Some(Identifier::new(a)), true), + (Some(Identifier::new(b)), true), + (Some(Identifier::new(c)), false), + (None, false), + ] { + let system = DocumentSystemValues { + owner_id: owner, + ..Default::default() + }; + assert_eq!( + owner_is_member.holds(&group, &system), + Ok(expected), + "{owner:?}" + ); + } + let buyer_is_member = parse_rule_value(platform_value!({ "contains": ["members", "buyerId"] })); + assert_eq!( + buyer_is_member.holds( + &data(&[ + ("members", members.clone()), + ("buyerId", Value::Identifier(b)) + ]), + &none + ), + Ok(true) + ); + // A buyer left out is a member of no group + assert_eq!(buyer_is_member.holds(&group, &none), Ok(false)); + + let next_score = parse_rule_value(platform_value!({ + "contains": ["scores", { "add": ["bonus", 1] }] + })); + let scores = |bonus: u64| { + data(&[ + ("scores", Value::Array(vec![Value::U8(3), Value::U64(10)])), + ("bonus", Value::U64(bonus)), + ]) + }; + assert_eq!(next_score.holds(&scores(9), &none), Ok(true)); + assert_eq!(next_score.holds(&scores(1), &none), Ok(false)); + let per_unit = parse_rule_value(platform_value!({ + "contains": ["scores", { "divide": [100, "bonus"] }] + })); + assert_eq!( + per_unit.violation(&data(&[("bonus", Value::U64(0))]), &none), + Some(PropertyConstraintViolation::DivisionByZero) + ); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index f5ef1b5917a..5f353062978 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -330,6 +330,61 @@ mod property_constraints_tests { fixture } + /// A mutable, transferable `offer` type with the integers + /// [`set_valid_offer`] fills and three typed arrays, of `labels`, of + /// `members` and of `tiers`, with three rules looking among them: + /// `notUsed` (no `"used"` label), `ownerIsMember` (the owner is a member, + /// when members are listed) and `quantityListed` (the quantity is one of the + /// tiers, when tiers are listed). + fn listed_offer_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "transferable": 1, + "properties": { + "price": { "type": "integer", "minimum": 0, "position": 0 }, + "fee": { "type": "integer", "minimum": 0, "position": 1 }, + "quantity": { "type": "integer", "minimum": 0, "position": 2 }, + "deposit": { "type": "integer", "minimum": 0, "position": 3 }, + "labels": { + "type": "array", + "maxItems": 4, + "items": { "type": "string", "maxLength": 10, "enum": ["new", "used", "sale"] }, + "position": 4 + }, + "members": { + "type": "array", + "maxItems": 4, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 5 + }, + "tiers": { + "type": "array", + "maxItems": 4, + "items": { "type": "integer", "minimum": 0 }, + "position": 6 + } + }, + "required": ["price", "fee", "quantity", "deposit"], + "propertyConstraints": { + "notUsed": { "not": { "contains": ["labels", { "const": "used" }] } }, + "ownerIsMember": { + "anyOf": [{ "absent": "members" }, { "contains": ["members", "$ownerId"] }] + }, + "quantityListed": { + "anyOf": [{ "absent": "tiers" }, { "contains": ["tiers", "quantity"] }] + } + }, + "additionalProperties": false + }) + } + /// An offer that meets every rule: (100 + 10) * 2 = 220. fn set_valid_offer(document: &mut Document) { document.set("price", Value::U64(100)); @@ -1685,4 +1740,67 @@ mod property_constraints_tests { StateTransitionExecutionResult::SuccessfulExecution { .. } ); } + + /// `contains` read by real writes: a `"used"` label, an owner missing from + /// the members and a quantity missing from the tiers are each refused with + /// the rule they break; a transfer, which changes the owner, is judged + /// against `ownerIsMember` and refused to a non-member, accepted to a member. + #[tokio::test] + async fn should_judge_contains_on_create_and_transfer() { + let mut fixture = OfferFixture::with_schema(listed_offer_schema()); + let (member, _, _) = fixture.other_identity(7); + let (outsider, _, _) = fixture.other_identity(8); + let owner = fixture.identity.id(); + let labels = |values: &[&str]| { + Value::Array(values.iter().map(|value| Value::from(*value)).collect()) + }; + let members = |ids: &[Identifier]| { + Value::Array( + ids.iter() + .map(|id| Value::Identifier(id.to_buffer())) + .collect(), + ) + }; + + let result = fixture + .create(|document| document.set("labels", labels(&["new", "used"]))) + .await; + expect_violated(result, "notUsed", PropertyConstraintViolation::NotMet); + + let result = fixture + .create(|document| document.set("members", members(&[member.id()]))) + .await; + expect_violated(result, "ownerIsMember", PropertyConstraintViolation::NotMet); + + // The quantity is 2 + let result = fixture + .create(|document| { + document.set("tiers", Value::Array(vec![Value::U64(1), Value::U64(5)])) + }) + .await; + expect_violated( + result, + "quantityListed", + PropertyConstraintViolation::NotMet, + ); + assert!(fixture.stored_offers().is_empty()); + + assert_matches!( + fixture + .create(|document| { + document.set("labels", labels(&["new", "sale"])); + document.set("members", members(&[owner, member.id()])); + document.set("tiers", Value::Array(vec![Value::U64(2), Value::U64(10)])); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let result = fixture.transfer(outsider.id()).await; + expect_violated(result, "ownerIsMember", PropertyConstraintViolation::NotMet); + assert_matches!( + fixture.transfer(member.id()).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } } diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 185f8601bd9..d710aa21f9e 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1068,23 +1068,28 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// declaring `refersTo` included) or of `$ownerId`, the document's owner, /// likewise, with base58 identifier constants or another identifier operand /// and no default, an identifier the document leaves out equalling none; -/// `present` or `absent` naming a property of any type, whether the -/// document holds it (the one way to tell a property left out from one set -/// to 0); `anyOf` or `allOf` over two or more conditions; or `not` over -/// one. In an operand, a property the document leaves out counts as 0, or -/// as the value of an `ifAbsent` operand naming it. Arithmetic is exact -/// `i128`: `divide` and `modulo` are Euclidean (the remainder is never -/// negative), and an overflow, a zero divisor, a negative exponent or a -/// value that is not an integer refuses the document rather than wrapping. -/// Conditions are checked in declared order and no further than the outcome -/// needs (`anyOf` stops at the first that holds, `allOf` at the first that -/// fails), a fault in one that is checked refuses the document whatever the -/// others say, and `not` never turns a fault into a pass, so an earlier -/// condition guards a later one. The parser checks that every path an -/// operand reads names an integer or boolean property, every path a -/// `length` or `byteLength` measures a string property, every path a -/// `count` counts an array or byte array property, every system time or -/// height a rule reads one the type lists in `required` (none on an +/// `contains`, whether a typed array property holds an element equal to an +/// integer expression, a string or an identifier operand (a constant, a +/// property, or `$ownerId`), as its elements are, an array the document +/// leaves out holding nothing; `present` or `absent` naming a property of +/// any type, whether the document holds it (the one way to tell a property +/// left out from one set to 0); `anyOf` or `allOf` over two or more +/// conditions; or `not` over one. In an operand, a property the document +/// leaves out counts as 0, or as the value of an `ifAbsent` operand naming +/// it. Arithmetic is exact `i128`: `divide` and `modulo` are Euclidean (the +/// remainder is never negative), and an overflow, a zero divisor, a +/// negative exponent or a value that is not an integer refuses the document +/// rather than wrapping. Conditions are checked in declared order and no +/// further than the outcome needs (`anyOf` stops at the first that holds, +/// `allOf` at the first that fails), a fault in one that is checked refuses +/// the document whatever the others say, and `not` never turns a fault into +/// a pass, so an earlier condition guards a later one. The parser checks +/// that every path an operand reads names an integer or boolean property, +/// every path a `length` or `byteLength` measures a string property, every +/// path a `count` counts an array or byte array property, every array a +/// `contains` looks in a typed array of the kind it looks for (a string +/// constant in the elements' `enum` when they declare one), every system +/// time or height a rule reads one the type lists in `required` (none on an /// indexOnly type), every path compared with identifiers an identifier /// property, every path compared with strings a string property (whose /// `enum`, if it declares one, lists every constant it is compared with), diff --git a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs index d8973a7eeee..513e30ab8c3 100644 --- a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs +++ b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs @@ -66,9 +66,9 @@ const PROPERTY_CONSTRAINTS_KEYWORD: &str = "propertyConstraints"; /// document type's schema declares it, every property it reads in declared /// order (`kind` is `"value"` for an integer operand, `"presence"` for /// `present` / `absent`, `"text"` for a string comparison, `"identifier"` for -/// an identifier comparison, `"length"` for a `length` or `byteLength` operand -/// and `"count"` for a `count` operand; `$ownerId` is no property and is not -/// listed), whether it reads `$ownerId`, which makes a transfer or a +/// an identifier comparison, `"length"` for a `length` or `byteLength` operand, +/// `"count"` for a `count` operand and `"elements"` for the array a `contains` +/// looks in; `$ownerId` is no property and is not listed), whether it reads `$ownerId`, which makes a transfer or a /// purchase answer to it too, and the system times and heights it reads /// (`"$createdAt"`, ...), which make a price update answer to a rule reading /// the update's and a transfer or purchase one reading the transfer's. Rules @@ -396,6 +396,7 @@ fn read_kind_name(read: PropertyRead) -> &'static str { PropertyRead::Identifier => "identifier", PropertyRead::Length => "length", PropertyRead::Count => "count", + PropertyRead::Elements(_) => "elements", } } diff --git a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs index eb1895b232b..db1c19679f7 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs @@ -85,6 +85,9 @@ export type PropertyConstraintEqualityOperand = * - `in`: an integer expression and two or more distinct integers, or a * string or identifier property (or `$ownerId`) and two or more distinct * strings or base58 identifiers; + * - `contains`: a typed array property and the value one of its elements must + * equal, an integer expression, a string or an identifier operand as its + * elements are; an array the document leaves out holds nothing; * - `present` / `absent`: whether the document holds a property of any type; * - `anyOf` / `allOf` over two or more conditions, `not` over one. Conditions * are checked in order and no further than the outcome needs. @@ -97,6 +100,7 @@ export type PropertyConstraintCondition = | { greaterThan: [PropertyConstraintExpression, PropertyConstraintExpression] } | { greaterThanOrEqual: [PropertyConstraintExpression, PropertyConstraintExpression] } | { in: [PropertyConstraintExpression, Array] | [string | { ifAbsent: [path: string, value: string] }, string[]] } + | { contains: [path: string, PropertyConstraintExpression | PropertyConstraintEqualityOperand] } | { present: string } | { absent: string } | { anyOf: PropertyConstraintCondition[] } @@ -107,7 +111,8 @@ export type PropertyConstraintCondition = * How a rule reads a property: `value` as an integer operand, `presence` in * `present` or `absent`, `text` compared with strings, `identifier` compared * with identifiers, `length` by the size of a string (`length` or - * `byteLength`), `count` by the items of an array or byte array. + * `byteLength`), `count` by the items of an array or byte array, `elements` + * by the elements a `contains` looks among. */ export type PropertyConstraintReadKind = | 'value' @@ -115,7 +120,8 @@ export type PropertyConstraintReadKind = | 'text' | 'identifier' | 'length' - | 'count'; + | 'count' + | 'elements'; /** * A system time or height a rule reads: the block time in milliseconds @@ -211,6 +217,7 @@ fn read_kind_name(read: PropertyRead) -> &'static str { PropertyRead::Identifier => "identifier", PropertyRead::Length => "length", PropertyRead::Count => "count", + PropertyRead::Elements(_) => "elements", } } diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts index c299a7014ab..43c3f469959 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts @@ -281,6 +281,48 @@ describe('DataContract: propertyConstraints (v14)', () => { .to.equal(undefined); }); + it('should report the array a contains looks in, and check it', () => { + const rules = { + notUsed: { not: { contains: ['labels', { const: 'used' }] } }, + }; + const contract = buildContract({ + listing: { + type: 'object', + properties: { + labels: { + type: 'array', + maxItems: 4, + items: { type: 'string', maxLength: 10, enum: ['new', 'used'] }, + position: 0, + }, + }, + additionalProperties: false, + propertyConstraints: rules, + }, + }); + + expect(contract.documentTypePropertyConstraints('listing')).to.deep.equal([ + { + name: 'notUsed', + rule: rules.notUsed, + reads: [{ path: 'labels', kind: 'elements' }], + readsOwner: false, + readsSystem: [], + }, + ]); + + const listing = (labels: string[]) => new wasm.Document({ + properties: { labels }, + documentTypeName: 'listing', + dataContractId: contract.id, + ownerId, + revision: BigInt(1), + }); + expect(contract.checkDocumentPropertyConstraints(listing(['new']))).to.equal(undefined); + expect(contract.checkDocumentPropertyConstraints(listing(['new', 'used']))) + .to.deep.include({ rule: 'notUsed', violation: 'NotMet' }); + }); + it('should report integer literals past Number.MAX_SAFE_INTEGER exactly, as bigint', () => { const big = 9007199254740993n; // 2 ** 53 + 1, which a number rounds const rules = { From da7cc2d99146de6ddc27b6a5be327d3213fdea83 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 05:46:39 +0700 Subject: [PATCH 059/113] fix(drive-abci): sign vote extensions only for blocks this node accepted (#5084) Co-authored-by: Claude Opus 5.5 --- .../src/abci/handler/extend_vote.rs | 209 ++++++++---------- .../test_cases/vote_extension_round_tests.rs | 179 ++++++++------- 2 files changed, 191 insertions(+), 197 deletions(-) diff --git a/packages/rs-drive-abci/src/abci/handler/extend_vote.rs b/packages/rs-drive-abci/src/abci/handler/extend_vote.rs index 9e21163caa5..e2330ef39d6 100644 --- a/packages/rs-drive-abci/src/abci/handler/extend_vote.rs +++ b/packages/rs-drive-abci/src/abci/handler/extend_vote.rs @@ -1,11 +1,8 @@ use crate::abci::app::{BlockExecutionApplication, PlatformApplication, TransactionalApplication}; use crate::abci::AbciError; -use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::block_execution_context::v0::BlockExecutionContextV0Getters; -use crate::execution::types::block_state_info::v0::{ - BlockStateInfoV0Getters, BlockStateInfoV0Methods, -}; +use crate::execution::types::block_state_info::v0::BlockStateInfoV0Getters; use crate::rpc::core::CoreRPCLike; use tenderdash_abci::proto::abci as proto; @@ -24,28 +21,13 @@ where height, round, } = request; - let block_execution_context_guard = app.block_execution_context().read().unwrap(); - - // Verify Tenderdash that it called this handler correctly - if let Some(block_execution_context) = block_execution_context_guard.as_ref() { - if block_execution_context - .block_state_info() - .matches_current_block(height as u64, round as u32, block_hash.clone())? - { - // Extend votes with unsigned withdrawal transactions - // we only want to sign the hash of the transaction - let vote_extensions = block_execution_context - .unsigned_withdrawal_transactions() - .into(); - - return Ok(proto::ResponseExtendVote { vote_extensions }); - } - } - // Tenderdash signs again a block it locked in an earlier round without processing it in - // this round. That round's proposal has replaced the block execution context meanwhile, or - // left none when it was rejected before execution. A block's withdrawal transactions do not - // depend on the round, so sign the ones we built when we accepted it. + // Extend votes with the unsigned withdrawal transactions of a block this node accepted, kept + // by `process_proposal` for every block it accepts at this height. The block execution + // context is not enough: it may belong to a proposal this node rejected, and Tenderdash signs + // again a block it locked in an earlier round without processing it in this round, whose + // proposal has replaced the context meanwhile or, when rejected before execution, left none. + // A block's withdrawal transactions do not depend on the round. if let Some(vote_extensions) = app .unsigned_withdrawal_txs_by_round() .read() @@ -57,19 +39,32 @@ where }); } - let block_execution_context = - block_execution_context_guard - .as_ref() - .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( - "block execution context must be set in block begin handler for extend votes", - )))?; - let block_state_info = &block_execution_context.block_state_info(); + let last_processed = match app + .block_execution_context() + .read() + .expect("poisoned only after a panic, which stops the node") + .as_ref() + { + Some(block_execution_context) => { + let block_state_info = block_execution_context.block_state_info(); + format!( + "height: {} round: {}, block: {}", + block_state_info.height(), + block_state_info.round(), + block_state_info + .block_hash() + .map(hex::encode) + .unwrap_or("None".to_string()) + ) + } + None => "none".to_string(), + }; Err(AbciError::RequestForWrongBlockReceived(format!( - "received extend votes request for height: {} round: {}, block: {}; expected height: {} round: {}, block: {}", - height, round, hex::encode(block_hash), - block_state_info.height(), block_state_info.round(), block_state_info.block_hash().map(hex::encode).unwrap_or("None".to_string()) - )).into()) + "received extend votes request for height: {} round: {}, block: {}, which this node has not accepted; last processed proposal: {}", + height, round, hex::encode(block_hash), last_processed + )) + .into()) } #[cfg(test)] @@ -143,8 +138,8 @@ mod tests { assert!(result.is_err()); let err_string = result.unwrap_err().to_string(); assert!( - err_string.contains("block execution context must be set"), - "Expected block execution context error, got: {}", + err_string.contains("which this node has not accepted; last processed proposal: none"), + "Expected not accepted block error, got: {}", err_string ); } @@ -238,6 +233,18 @@ mod tests { round: 0, }; + // `process_proposal` leaves the context of a proposal it rejects after executing it + assert!( + extend_vote::<_, MockCoreRPCLike>(&app, request.clone()).is_err(), + "a block this node has not accepted must not be signed, even when it is the context's" + ); + + // and keeps the withdrawals of one it accepts + app.unsigned_withdrawal_txs_by_round + .write() + .unwrap() + .insert(10, 0, [0xAA; 32], Vec::new()); + let response = extend_vote::<_, MockCoreRPCLike>(&app, request).expect("extend_vote should succeed"); @@ -246,93 +253,61 @@ mod tests { } /// Tenderdash signs a block it locked in an earlier round again in a later round, without - /// processing it there: the withdrawals kept for that block are signed. + /// processing it there. That round's proposal has replaced the block execution context or, + /// when rejected before execution, left none: either way the withdrawals kept for that block + /// are signed. #[test] fn should_sign_a_block_accepted_in_an_earlier_round_with_its_kept_withdrawals() { let platform = TestPlatformBuilder::new() .with_latest_protocol_version() .build_with_mock_rpc(); - let app = FullAbciApplication::::new(&platform.platform); - - let context = - make_test_block_execution_context(10, 0, Some([0xAA; 32]), &platform.platform); - app.block_execution_context - .write() - .unwrap() - .replace(context); - let kept_extensions: Vec = (&unsigned_withdrawal_transactions(1000)).into(); - app.unsigned_withdrawal_txs_by_round - .write() - .unwrap() - .insert(10, 0, [0xAA; 32], kept_extensions.clone()); - - let response = extend_vote::<_, MockCoreRPCLike>( - &app, - proto::RequestExtendVote { - hash: vec![0xAA; 32], - height: 10, - round: 1, - }, - ) - .expect("extend_vote should sign the kept withdrawals"); - assert_eq!(response.vote_extensions, kept_extensions); - - let result = extend_vote::<_, MockCoreRPCLike>( - &app, - proto::RequestExtendVote { - hash: vec![0xBB; 32], - height: 10, - round: 1, - }, - ); - assert!( - result.is_err(), - "a block this node has not accepted must not be signed" - ); - } - /// A later round's proposal rejected before execution leaves no block execution context, and - /// Tenderdash can still sign the block it locked in an earlier round. - #[test] - fn should_sign_a_block_accepted_in_an_earlier_round_without_a_block_execution_context() { - let platform = TestPlatformBuilder::new() - .with_latest_protocol_version() - .build_with_mock_rpc(); - - let app = FullAbciApplication::::new(&platform.platform); - - let kept_extensions: Vec = - (&unsigned_withdrawal_transactions(1000)).into(); - app.unsigned_withdrawal_txs_by_round - .write() - .unwrap() - .insert(10, 0, [0xAA; 32], kept_extensions.clone()); - - let response = extend_vote::<_, MockCoreRPCLike>( - &app, - proto::RequestExtendVote { - hash: vec![0xAA; 32], - height: 10, - round: 1, - }, - ) - .expect("extend_vote should sign the kept withdrawals"); - assert_eq!(response.vote_extensions, kept_extensions); - - let result = extend_vote::<_, MockCoreRPCLike>( - &app, - proto::RequestExtendVote { - hash: vec![0xBB; 32], - height: 10, - round: 1, - }, - ); - assert!( - result.is_err(), - "a block this node has not accepted must not be signed" - ); + for context in [ + Some(make_test_block_execution_context( + 10, + 1, + Some([0xBB; 32]), + &platform.platform, + )), + None, + ] { + let app = FullAbciApplication::::new(&platform.platform); + let has_context = context.is_some(); + *app.block_execution_context.write().unwrap() = context; + + app.unsigned_withdrawal_txs_by_round + .write() + .unwrap() + .insert(10, 0, [0xAA; 32], kept_extensions.clone()); + + let response = extend_vote::<_, MockCoreRPCLike>( + &app, + proto::RequestExtendVote { + hash: vec![0xAA; 32], + height: 10, + round: 1, + }, + ) + .unwrap_or_else(|e| { + panic!("extend_vote should sign the kept withdrawals (context: {has_context}): {e}") + }); + assert_eq!(response.vote_extensions, kept_extensions); + + let result = extend_vote::<_, MockCoreRPCLike>( + &app, + proto::RequestExtendVote { + hash: vec![0xBB; 32], + height: 10, + round: 1, + }, + ); + assert!( + result.is_err(), + "a block this node has not accepted must not be signed (context: {has_context})" + ); + } } } diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs index 576709ad200..432f9199176 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs @@ -12,6 +12,7 @@ mod tests { use dpp::block::extended_block_info::v0::ExtendedBlockInfoV0Setters; use dpp::dashcore::hashes::Hash; use drive_abci::config::{PlatformConfig, PlatformTestConfig}; + use drive_abci::execution::types::block_execution_context::v0::BlockExecutionContextV0Getters; use drive_abci::platform_types::platform::Platform; use drive_abci::platform_types::platform_state::PlatformStateV0Methods; use drive_abci::rpc::core::MockCoreRPCLike; @@ -54,6 +55,26 @@ mod tests { run_chain_for_strategy(platform, 2, strategy(), config(), 15, &mut None, &mut None).await } + /// Runs the chain and queues one withdrawal transaction for the next height. Returns the + /// outcome with the round 0 proposal for that height, at the last chain-locked core height. + async fn run_chain_with_queued_withdrawal( + platform: &mut Platform, + ) -> (ChainExecutionOutcome<'_>, RequestProcessProposal) { + let outcome = run_chain(platform).await; + + queue_withdrawal_transaction(&outcome); + + let round_0_core_height = outcome + .abci_app + .platform + .state + .load() + .last_committed_core_height(); + let round_0 = proposal(&outcome, 0, round_0_core_height, ROUND_0_BLOCK); + + (outcome, round_0) + } + /// Puts one untied withdrawal transaction in the queue the next height dequeues from, and /// moves the committed app hash with it. Only the queue entry is written: there is no /// matching withdrawal document, the transaction index counter is not advanced, and the @@ -141,7 +162,7 @@ mod tests { } } - /// The vote extensions this node signs when it precommits `proposal`, which it processed last + /// The vote extensions this node signs when it precommits `proposal` fn extend_vote( outcome: &ChainExecutionOutcome, proposal: &RequestProcessProposal, @@ -157,6 +178,42 @@ mod tests { .vote_extensions } + /// Whether this node signs a precommit for `proposal` + fn signs(outcome: &ChainExecutionOutcome, proposal: &RequestProcessProposal) -> bool { + outcome + .abci_app + .extend_vote(RequestExtendVote { + hash: proposal.hash.clone(), + height: proposal.height, + round: proposal.round, + }) + .is_ok() + } + + /// Processes `proposal`, which this node must accept, and returns the vote extensions it + /// signs when it precommits it + fn accept( + outcome: &ChainExecutionOutcome, + proposal: &RequestProcessProposal, + ) -> Vec { + let response = outcome + .abci_app + .process_proposal(proposal.clone()) + .expect("expected to process the proposal"); + assert_eq!(response.status, ProposalStatus::Accept as i32); + + extend_vote(outcome, proposal) + } + + /// Processes `proposal`, which this node must reject + fn reject(outcome: &ChainExecutionOutcome, proposal: &RequestProcessProposal) { + let response = outcome + .abci_app + .process_proposal(proposal.clone()) + .expect("expected to process the proposal"); + assert_eq!(response.status, ProposalStatus::Reject as i32); + } + /// Another validator's precommit for `proposal`, carrying `vote_extensions` fn verify( outcome: &ChainExecutionOutcome, @@ -184,25 +241,15 @@ mod tests { let mut platform = TestPlatformBuilder::new() .with_config(config()) .build_with_mock_rpc(); - let outcome = run_chain(&mut platform).await; - - queue_withdrawal_transaction(&outcome); + let (outcome, round_0) = run_chain_with_queued_withdrawal(&mut platform).await; + let round_1 = proposal( + &outcome, + 1, + round_0.core_chain_locked_height + 1, + ROUND_1_BLOCK, + ); - let round_0_core_height = outcome - .abci_app - .platform - .state - .load() - .last_committed_core_height(); - let round_0 = proposal(&outcome, 0, round_0_core_height, ROUND_0_BLOCK); - let round_1 = proposal(&outcome, 1, round_0_core_height + 1, ROUND_1_BLOCK); - - let response = outcome - .abci_app - .process_proposal(round_0.clone()) - .expect("expected to process the round 0 proposal"); - assert_eq!(response.status, ProposalStatus::Accept as i32); - let round_0_extensions = extend_vote(&outcome, &round_0); + let round_0_extensions = accept(&outcome, &round_0); // Before round 1 is processed, a round 1 precommit carrying the same validator's round 0 // extensions still verifies in Tenderdash and matches the only proposal this node @@ -218,12 +265,7 @@ mod tests { "a round 0 precommit whose extensions were stripped must be rejected" ); - let response = outcome - .abci_app - .process_proposal(round_1.clone()) - .expect("expected to process the round 1 proposal"); - assert_eq!(response.status, ProposalStatus::Accept as i32); - let round_1_extensions = extend_vote(&outcome, &round_1); + let round_1_extensions = accept(&outcome, &round_1); assert_eq!( round_0_extensions.len(), @@ -259,40 +301,39 @@ mod tests { let mut platform = TestPlatformBuilder::new() .with_config(config()) .build_with_mock_rpc(); - let outcome = run_chain(&mut platform).await; - - queue_withdrawal_transaction(&outcome); - - let round_0_core_height = outcome - .abci_app - .platform - .state - .load() - .last_committed_core_height(); - let round_0 = proposal(&outcome, 0, round_0_core_height, ROUND_0_BLOCK); - let mut rejected_round_1 = proposal(&outcome, 1, round_0_core_height + 1, ROUND_1_BLOCK); + let (outcome, round_0) = run_chain_with_queued_withdrawal(&mut platform).await; + let mut rejected_round_1 = proposal( + &outcome, + 1, + round_0.core_chain_locked_height + 1, + ROUND_1_BLOCK, + ); // Bytes that decode to no state transition make the proposal unacceptable rejected_round_1.txs = vec![vec![0u8; 10]]; - let response = outcome - .abci_app - .process_proposal(round_0.clone()) - .expect("expected to process the round 0 proposal"); - assert_eq!(response.status, ProposalStatus::Accept as i32); - let round_0_extensions = extend_vote(&outcome, &round_0); + let round_0_extensions = accept(&outcome, &round_0); + reject(&outcome, &rejected_round_1); - let response = outcome + // The rejected proposal was executed, and its context replaced the accepted round's + let rejected_extensions: Vec = outcome .abci_app - .process_proposal(rejected_round_1.clone()) - .expect("expected to process the round 1 proposal"); - assert_eq!(response.status, ProposalStatus::Reject as i32); - let rejected_extensions = extend_vote(&outcome, &rejected_round_1); + .block_execution_context + .read() + .unwrap() + .as_ref() + .expect("the rejected proposal left its block execution context") + .unsigned_withdrawal_transactions() + .into(); assert_eq!( rejected_extensions.len(), 1, "test premise: the rejected proposal built a withdrawal transaction" ); + assert!( + !signs(&outcome, &rejected_round_1), + "a proposal this node rejected must not be signed" + ); assert_eq!( verify(&outcome, &rejected_round_1, rejected_extensions), @@ -314,36 +355,21 @@ mod tests { let mut platform = TestPlatformBuilder::new() .with_config(config()) .build_with_mock_rpc(); - let outcome = run_chain(&mut platform).await; - - queue_withdrawal_transaction(&outcome); - - let round_0_core_height = outcome - .abci_app - .platform - .state - .load() - .last_committed_core_height(); - let round_0 = proposal(&outcome, 0, round_0_core_height, ROUND_0_BLOCK); - let mut rejected_round_1 = proposal(&outcome, 1, round_0_core_height + 1, ROUND_1_BLOCK); + let (outcome, round_0) = run_chain_with_queued_withdrawal(&mut platform).await; + let mut rejected_round_1 = proposal( + &outcome, + 1, + round_0.core_chain_locked_height + 1, + ROUND_1_BLOCK, + ); // A protocol version this node does not run is refused before the block is executed rejected_round_1.version = Some(Consensus { block: 0, app: PlatformVersion::latest().protocol_version as u64 + 1, }); - let response = outcome - .abci_app - .process_proposal(round_0.clone()) - .expect("expected to process the round 0 proposal"); - assert_eq!(response.status, ProposalStatus::Accept as i32); - let round_0_extensions = extend_vote(&outcome, &round_0); - - let response = outcome - .abci_app - .process_proposal(rejected_round_1.clone()) - .expect("expected to process the round 1 proposal"); - assert_eq!(response.status, ProposalStatus::Reject as i32); + let round_0_extensions = accept(&outcome, &round_0); + reject(&outcome, &rejected_round_1); assert!( outcome @@ -371,14 +397,7 @@ mod tests { ); assert!( - outcome - .abci_app - .extend_vote(RequestExtendVote { - hash: rejected_round_1.hash.clone(), - height: rejected_round_1.height, - round: rejected_round_1.round, - }) - .is_err(), + !signs(&outcome, &rejected_round_1), "the rejected block must not be signed" ); } From 6d58d7e55e32f62e5f3130c01b979497087e27ae Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 05:56:18 +0700 Subject: [PATCH 060/113] feat(platform)!: startsWith and endsWith in propertyConstraints rules (PV14) (#5085) Co-authored-by: Claude Opus 5.5 --- .../contract-keywords/property-constraints.md | 7 +- book/src/data-model/documents.md | 1 + packages/js-evo-sdk/README.md | 2 +- .../document/v3/document-meta.json | 12 +- .../class_methods/try_from_schema/mod.rs | 33 +++- .../v3/property_constraints_tests.rs | 90 +++++++++ .../document_type/property_constraints/mod.rs | 175 +++++++++++++++++- .../property_constraints/tests.rs | 154 ++++++++++++++- .../tests/document/property_constraints.rs | 71 +++++++ .../rs-platform-version/src/version/v14.rs | 40 ++-- .../document_type_property_constraints.rs | 4 + .../unit/DocumentPropertyConstraints.spec.ts | 35 ++++ 12 files changed, 593 insertions(+), 31 deletions(-) diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index 31da07040ff..640ab7138f1 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -73,6 +73,7 @@ A rule is a condition: a JSON object with exactly one key. | `equal`, `notEqual` | `[left, right]` | The two sides are equal, or differ. The sides are two integer expressions, or a string property and a string constant or another string property, or an identifier property and an identifier constant, another identifier property or `$ownerId` | | `lessThan`, `lessThanOrEqual`, `greaterThan`, `greaterThanOrEqual` | `[left, right]` | The left integer expression compares with the right one this way. Integers only | | `in` | `[expression, [v1, v2, ...]]` | The expression takes one of the listed values: two or more, no two alike, all integers or all strings. With strings, the expression is a string property, or an identifier property or `$ownerId` with the strings as base58 identifiers | +| `startsWith`, `endsWith` | `[text, affix]` | The first string starts, or ends, with the second, byte for byte with no case folding. Each side is a string constant, a string property or an `ifAbsent` string default, at least one a property and never the same one twice. A string property left out without a default takes no string, and the condition does not hold for it | | `contains` | `["path", value]` | The typed array property at the path holds an element equal to the value: an integer expression among integers; a string constant, a string property or an `ifAbsent` string default among strings; an identifier constant, an identifier property or `$ownerId` among identifiers. An array the document leaves out holds nothing, and a string or identifier property it leaves out is among no elements | | `present` | `"path"` | The document holds the property, with a value other than null | | `absent` | `"path"` | The document leaves the property out, or sets it to null | @@ -84,6 +85,8 @@ Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan An `in` says what an `anyOf` of `equal` comparisons says, in far fewer nodes: `{ "in": ["fee", [0, 10, 25, 50]] }` is 6 nodes where the `anyOf` is 13. +`startsWith` and `endsWith` test a string's ends: `{ "startsWith": ["url", { "const": "https://" }] }` holds a link to https, `{ "endsWith": ["url", { "const": ".dash" }] }` to a domain, and `{ "startsWith": ["path", "parentPath"] }` holds a reply's path under its parent's. A constant tested against a property that declares an `enum` must start or end one of its values. + A `contains` looks the other way round, for one value among an array's elements: - `{ "not": { "contains": ["labels", { "const": "used" }] } }` refuses a `"used"` label; @@ -195,7 +198,7 @@ The meta-schema checks the shape (`JsonSchemaError`, 10101): The parser then checks the rules against the document type (`InvalidContractStructure`, 10231): -- every path an integer expression reads names an integer or boolean property; every path `length` or `byteLength` measures names a string property, and every path `count` counts an array or byte array property; every path a `contains` looks in names a typed array property whose elements are integers, strings or identifiers, of the kind of the value looked for (a string constant among them in the elements' `enum` when they declare one); every path compared with a string names a string property; every path compared with an identifier names an identifier property; every path `present` or `absent` tests names a property of any type, an object included; +- every path an integer expression reads names an integer or boolean property; every path `length` or `byteLength` measures names a string property, and every path `count` counts an array or byte array property; every path a `contains` looks in names a typed array property whose elements are integers, strings or identifiers, of the kind of the value looked for (a string constant among them in the elements' `enum` when they declare one); every path compared with a string, or tested by `startsWith` or `endsWith`, names a string property, and a constant tested against one with an `enum` starts or ends one of its values; every path compared with an identifier names an identifier property; every path `present` or `absent` tests names a property of any type, an object included; - no rule reads a property that is `transient` or inside a transient object, since a stored document could never be held to it; - every comparison and `in` reads at least one property: a comparison of constants would hold for every document or for none; - strings and identifiers are compared only with `equal`, `notEqual` and `in`; a string is never compared with an identifier; a property is never compared with itself; @@ -216,7 +219,7 @@ A rule within 32 nodes is never deep enough to reach the 64-level bound. Nodes a | Part of a rule | Nodes | |---|---| | A comparison of integers | 1, plus its two sides | -| An `equal` or `notEqual` of strings or identifiers | 3: the comparison and its two sides | +| An `equal` or `notEqual` of strings or identifiers, a `startsWith` or an `endsWith` | 3: the condition and its two sides | | An `in` over integers | 1, plus its expression, plus 1 per value | | An `in` over strings or identifiers | 2, plus 1 per value | | `contains` | 2, plus the value it looks for | diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index c07829ddbd9..4072de6a515 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -713,6 +713,7 @@ The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeas - `{ "in": [expression, [values]] }`, holding if the integer expression takes one of two or more distinct integer values. It says what an `anyOf` of `equal`s says, in one node per value instead of three, so a set of up to 30 values fits the node limit where the `anyOf` fits 10. A value is a literal, never a path or an expression; - a string comparison: `{ "equal": [path, { "const": "closed" }] }` or `notEqual`, with the constant on either side; `{ "notEqual": ["fromCurrency", "toCurrency"] }`, two bare paths that both name string properties, which compares their strings; or `{ "in": [path, ["open", "pending"]] }`, whose values are two or more distinct strings. The path names a string property, typically one with an `enum`. A string on its own is a path, so a constant is written as `{ "const": ... }`, while the values an `in` lists are literals and need no wrapper. Strings are only compared for equality, never ordered or used in arithmetic. A string property the document leaves out equals no constant and no other string property, not even one also left out, so `notEqual` holds for it and `equal` and `in` do not, unless `{ "ifAbsent": [path, "open"] }` gives it a string default, which it then reads as (it may stand wherever the bare path does, and makes the comparison one of strings); `present` and `absent` test it directly. When the property declares an `enum`, every constant compared with it must be one of the enum's values, so a misspelling is refused at registration rather than making the rule quietly never hold; - an identifier comparison, the same three forms for identifier properties, those declaring `refersTo` included: `{ "equal": ["paymentToken", { "const": "" }] }` or `notEqual`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. Constants are base58 identifiers of 32 bytes, checked at registration, and compared by their bytes, whatever form the document gives the identifier in. An identifier property the document leaves out equals no identifier, not even another one left out; identifiers take no `ifAbsent` default and are never ordered. `$ownerId`, the document's owner, is an identifier operand too: `{ "equal": ["authorId", "$ownerId"] }` holds the author to the owner, and `{ "in": ["$ownerId", ["", ...]] }` lets only the identities listed own a document of the type. It is no property, so `present` or an integer operand refuses it, and comparing it with itself is refused. Since a transfer and a purchase give the document a new owner, each is judged against the rules reading `$ownerId`, with the new owner, and refused when it would break one; an indexOnly type refuses such a rule, since its deletes carry no owner; +- `{ "startsWith": [text, affix] }` and `{ "endsWith": [text, affix] }`, holding if the first string starts or ends with the second, byte for byte with no case folding: each side a `{ "const": ... }`, a string property or an `ifAbsent` string default, at least one a property, never the same one twice. `{ "startsWith": ["url", { "const": "https://" }] }` holds a link to https, and `{ "startsWith": ["path", "parentPath"] }` a path under its parent's. A string property left out without a default takes no string, and the condition does not hold for it; a constant tested against a property that declares an `enum` must start or end one of its values; - `{ "contains": [path, value] }`, holding if the typed array property at the path holds an element equal to the value, looked for as the array's elements are: an integer expression among integers, a string constant, string property or `ifAbsent` string default among strings, an identifier constant, identifier property or `$ownerId` among identifiers. `{ "not": { "contains": ["labels", { "const": "used" }] } }` refuses a `"used"` label, and `{ "contains": ["participants", "$ownerId"] }` holds the owner to the participants (so a transfer or purchase to a non-participant is refused). An array the document leaves out holds nothing, a string or identifier property it leaves out is among no elements, and a string constant must be one of the elements' `enum` values when they declare one; - `{ "present": path }`, holding if the document holds the property, and `{ "absent": path }`, holding if it leaves it out (a property set to null counts as left out). An operand reads a property the document leaves out as 0, so only these tell "not given" from "given as 0". They may name a property of any type, an object or a member of one included, since they read no value; - `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index fc8e440c653..454cc0c2793 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -425,7 +425,7 @@ From protocol version 14 a document type can declare rules its documents' proper } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `{ "contains": ["participants", "$ownerId"] }` holds when a typed array property has an element equal to the value, looked for as the array's elements are (an integer expression, a string or an identifier), so `{ "not": { "contains": ["labels", { "const": "used" }] } }` refuses a label; the array is reported as a read of kind `elements`. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0), or a size: `{ "length": path }` and `{ "byteLength": path }` give the characters and UTF-8 bytes of a string property, and `{ "count": path }` the items of an array or the bytes of a byte array, so `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit (a size read is reported with kind `length` or `count`). A type that lists `$createdAt`, `$updatedAt` or `$transferredAt` (or any of them with `BlockHeight` or `CoreBlockHeight` appended) in `required` may read it too: `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week, and since a price update sets `$updatedAt` and a transfer or purchase `$transferredAt`, each is judged against the rules reading those. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `{ "startsWith": ["url", { "const": "https://" }] }` and `endsWith` test a string property's start or end, byte for byte, against a constant or another string property. `{ "contains": ["participants", "$ownerId"] }` holds when a typed array property has an element equal to the value, looked for as the array's elements are (an integer expression, a string or an identifier), so `{ "not": { "contains": ["labels", { "const": "used" }] } }` refuses a label; the array is reported as a read of kind `elements`. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0), or a size: `{ "length": path }` and `{ "byteLength": path }` give the characters and UTF-8 bytes of a string property, and `{ "count": path }` the items of an array or the bytes of a byte array, so `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit (a size read is reported with kind `length` or `count`). A type that lists `$createdAt`, `$updatedAt` or `$transferredAt` (or any of them with `BlockHeight` or `CoreBlockHeight` appended) in `required` may read it too: `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week, and since a price update sets `$updatedAt` and a transfer or purchase `$transferredAt`, each is judged against the rules reading those. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 813bc2247ea..84cd8b635a5 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -40,7 +40,7 @@ } }, "propertyConstraint": { - "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right (equal and notEqual may instead compare the path of a string property with a const string or with the path of another string property, and likewise the path of an identifier property with a const base58 identifier or another identifier property), in listing an expression and the values it may take, contains listing a typed array property and a value its elements must include, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", + "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right (equal and notEqual may instead compare the path of a string property with a const string or with the path of another string property, and likewise the path of an identifier property with a const base58 identifier or another identifier property), in listing an expression and the values it may take, startsWith or endsWith listing two strings, the first starting or ending with the second, contains listing a typed array property and a value its elements must include, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", "type": "object", "properties": { "equal": { @@ -89,6 +89,14 @@ "items": false, "minItems": 2 }, + "startsWith": { + "description": "Holds if the string listed first starts with the string listed second, byte for byte, with no case folding: each a const string or a string property (or an ifAbsent giving one a default), at least one a property, never the same one twice. A string property the document leaves out without a default takes no string, and the condition does not hold for it", + "$ref": "#/$defs/propertyConstraintOperandPair" + }, + "endsWith": { + "description": "Holds if the string listed first ends with the string listed second, as startsWith does at the start", + "$ref": "#/$defs/propertyConstraintOperandPair" + }, "contains": { "description": "Holds if the typed array property at the path listed first holds an element equal to the value listed second: an integer expression among integers, a const string or a string property (or an ifAbsent giving one a default) among strings, a const base58 identifier, an identifier property or $ownerId among identifiers, as the array's elements are. An array the document leaves out holds nothing, and so does a string or identifier property it leaves out", "type": "array", @@ -2094,7 +2102,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, contains listing the path of a typed array property and the value one of its elements must equal (an integer expression among integers, a const string or string property among strings, a const base58 identifier, identifier property or $ownerId among identifiers; an array left out holds nothing, and a constant must be one of the elements' enum values when they declare one), present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, a system time or height the document type records by listing it in required ($createdAt, $updatedAt and $transferredAt, block times in milliseconds, and each with BlockHeight or CoreBlockHeight appended, the Platform and Core block heights: those of the create, of the last create, replace or price update, and of the last create, transfer or purchase), or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out ({ \"lessThanOrEqual\": [{ \"count\": \"tags\" }, \"maxTags\"] }). A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property length or byteLength measures a string property, every property count counts an array or byte array property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every contains and its array, every const, every present or absent, every arithmetic operator and every operand, a size included; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. Likewise a transfer or a purchase is judged against the rules reading the transfer's time and heights, and a price update against those reading the update's, since each sets them; an indexOnly type reads no system time or height. The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, startsWith or endsWith listing two strings, a const or a string property each, at least one a property, and holding if the first starts or ends with the second, byte for byte (a constant looked for in a string property that declares enum must start or end one of its values), contains listing the path of a typed array property and the value one of its elements must equal (an integer expression among integers, a const string or string property among strings, a const base58 identifier, identifier property or $ownerId among identifiers; an array left out holds nothing, and a constant must be one of the elements' enum values when they declare one), present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, a system time or height the document type records by listing it in required ($createdAt, $updatedAt and $transferredAt, block times in milliseconds, and each with BlockHeight or CoreBlockHeight appended, the Platform and Core block heights: those of the create, of the last create, replace or price update, and of the last create, transfer or purchase), or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out ({ \"lessThanOrEqual\": [{ \"count\": \"tags\" }, \"maxTags\"] }). A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property length or byteLength measures a string property, every property count counts an array or byte array property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every contains and its array, every const, every present or absent, every arithmetic operator and every operand, a size included; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. Likewise a transfer or a purchase is judged against the rules reading the transfer's time and heights, and a price update against those reading the update's, since each sets them; an indexOnly type reads no system time or height. The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 12215cc56e5..c96bb9605ec 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -2,7 +2,7 @@ use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::class_methods::apply_required_since::apply_required_since; use crate::data_contract::document_type::class_methods::parse_typed_array::parse_typed_array; use crate::data_contract::document_type::property_constraints::{ - parse_property_constraints, ElementKind, EqualityKind, PropertyRead, + parse_property_constraints, AffixPosition, ElementKind, EqualityKind, PropertyRead, }; use crate::data_contract::document_type::reference_lookup::{ MAX_LOOKUP_INDEX_NAME_LENGTH, MAX_LOOKUP_KEYS, MAX_LOOKUP_PATH_LENGTH, @@ -2227,6 +2227,22 @@ fn apply_property_constraints_v0( ))); } } + // A constant a string property must start or end with, when the property + // declares an `enum`, must fit one of its values, or the test never holds + for (path, affix, position) in constraint.text_affixes() { + if !enum_any(&document_type.schema, path, |member| { + position.holds(member, affix) + })? { + let tests = match position { + AffixPosition::Start => "starts with", + AffixPosition::End => "ends with", + }; + return Err(structure_error(format!( + "rule \"{name}\" tests whether \"{path}\" {tests} \"{affix}\", which none \ + of its enum values does" + ))); + } + } for (path, default) in constraint.text_defaults() { if !enum_admits(&document_type.schema, path, default)? { return Err(structure_error(format!( @@ -2270,6 +2286,17 @@ fn apply_property_constraints_v0( /// type's, may hold `value`: always, unless it declares an `enum` that does not /// list it. fn enum_admits(schema: &Value, path: &str, value: &str) -> Result { + enum_any(schema, path, |member| member == value) +} + +/// Whether the string property at the dotted `path` of `schema` (or the +/// elements of the typed array there) may hold a value `admits`: always, +/// unless it declares an `enum`, one of whose values must then pass. +fn enum_any( + schema: &Value, + path: &str, + admits: impl Fn(&str) -> bool, +) -> Result { let Some(property_schema) = schema_at_path(schema, path)? else { return Ok(true); }; @@ -2281,7 +2308,9 @@ fn enum_admits(schema: &Value, path: &str, value: &str) -> Result &'static str { + match self { + AffixPosition::Start => STARTS_WITH, + AffixPosition::End => ENDS_WITH, + } + } + + /// Whether `text` starts or ends with `affix`, byte for byte: no case + /// folding or normalization, and every string starts and ends with the + /// empty one. + pub fn holds(self, text: &str, affix: &str) -> bool { + match self { + AffixPosition::Start => text.starts_with(affix), + AffixPosition::End => text.ends_with(affix), + } + } +} + +/// A side of a `startsWith` or `endsWith`: a string constant, or a string +/// property with or without an `ifAbsent` default. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TextOperand { + /// A `{ "const": string }`. + Constant(String), + /// A string property. + Property(TextProperty), +} + +impl TextOperand { + /// The string it takes for a document whose properties are `data`, `None` + /// for a property left out without a default. + fn value<'a>(&'a self, data: &'a Value) -> Option<&'a str> { + match self { + TextOperand::Constant(value) => Some(value), + TextOperand::Property(property) => property.value(data), + } + } +} + /// What a `contains` looks for among an array property's elements, of the /// kind of its elements. #[derive(Debug, Clone, PartialEq, Eq)] @@ -761,6 +815,16 @@ pub enum PropertyConstraint { path: String, values: BTreeSet, }, + /// `startsWith` or `endsWith`: the string `text` takes starts or ends, as + /// `position` says, with the one `affix` takes, + /// `{ "startsWith": ["url", { "const": "https://" }] }`. A string property + /// the document leaves out without a default takes no string, and the + /// condition does not hold for it. + TextAffix { + position: AffixPosition, + text: TextOperand, + affix: TextOperand, + }, /// `contains`: the typed array property at the dotted path `array` holds /// an element equal to `needle`, `{ "contains": ["tags", { "const": "sale" }] }`. /// An array the document leaves out holds nothing. @@ -866,6 +930,14 @@ impl PropertyConstraint { Ok(identifier_value(data, owner_id, path) .is_some_and(|value| values.contains(&value))) } + PropertyConstraint::TextAffix { + position, + text, + affix, + } => Ok(matches!( + (text.value(data), affix.value(data)), + (Some(text), Some(affix)) if position.holds(text, affix) + )), PropertyConstraint::Contains { array, needle } => { let elements = match data.get_optional_value_at_path(array) { Ok(Some(Value::Array(elements))) => elements.as_slice(), @@ -964,6 +1036,7 @@ impl PropertyConstraint { // The property and the constant, as a comparison of a path with a value PropertyConstraint::TextCompare { .. } | PropertyConstraint::TextCompareProperties { .. } + | PropertyConstraint::TextAffix { .. } | PropertyConstraint::IdentifierCompare { .. } | PropertyConstraint::IdentifierCompareProperties { .. } => 2, PropertyConstraint::TextIn { values, .. } => 1 + values.len(), @@ -1026,6 +1099,7 @@ impl PropertyConstraint { | PropertyConstraint::TextCompare { .. } | PropertyConstraint::TextCompareProperties { .. } | PropertyConstraint::TextIn { .. } + | PropertyConstraint::TextAffix { .. } | PropertyConstraint::Contains { .. } | PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => false, @@ -1074,6 +1148,7 @@ impl PropertyConstraint { PropertyConstraint::TextCompare { .. } | PropertyConstraint::TextCompareProperties { .. } | PropertyConstraint::TextIn { .. } + | PropertyConstraint::TextAffix { .. } | PropertyConstraint::IdentifierCompare { .. } | PropertyConstraint::IdentifierCompareProperties { .. } | PropertyConstraint::IdentifierIn { .. } @@ -1105,6 +1180,33 @@ impl PropertyConstraint { .collect() } + /// Every string constant a `startsWith` or `endsWith` looks for in a string + /// property, with the property's path and where it is looked for, in + /// declared order: a property that declares an `enum` must have a value + /// the constant could start or end, or the condition would never hold. + pub fn text_affixes(&self) -> Vec<(&str, &str, AffixPosition)> { + let mut affixes = Vec::new(); + self.collect_text_affixes(&mut affixes); + affixes + } + + fn collect_text_affixes<'a>(&'a self, affixes: &mut Vec<(&'a str, &'a str, AffixPosition)>) { + match self { + PropertyConstraint::TextAffix { + position, + text: TextOperand::Property(property), + affix: TextOperand::Constant(value), + } => affixes.push((&property.path, value, *position)), + PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { + for condition in conditions { + condition.collect_text_affixes(affixes); + } + } + PropertyConstraint::Not(condition) => condition.collect_text_affixes(affixes), + _ => {} + } + } + /// Every string property the rule's string comparisons read, in declared /// order. fn text_properties(&self) -> Vec<&TextProperty> { @@ -1127,6 +1229,13 @@ impl PropertyConstraint { } } PropertyConstraint::Not(condition) => condition.collect_text_properties(properties), + PropertyConstraint::TextAffix { text, affix, .. } => { + for side in [text, affix] { + if let TextOperand::Property(property) = side { + properties.push(property); + } + } + } PropertyConstraint::Contains { needle: ContainsNeedle::TextProperty(property), .. @@ -1166,6 +1275,7 @@ impl PropertyConstraint { PropertyConstraint::Compare { .. } | PropertyConstraint::In { .. } | PropertyConstraint::TextCompareProperties { .. } + | PropertyConstraint::TextAffix { .. } | PropertyConstraint::IdentifierCompare { .. } | PropertyConstraint::IdentifierCompareProperties { .. } | PropertyConstraint::IdentifierIn { .. } @@ -1197,6 +1307,7 @@ impl PropertyConstraint { | PropertyConstraint::TextCompare { .. } | PropertyConstraint::TextCompareProperties { .. } | PropertyConstraint::TextIn { .. } + | PropertyConstraint::TextAffix { .. } | PropertyConstraint::IdentifierCompare { .. } | PropertyConstraint::IdentifierCompareProperties { .. } | PropertyConstraint::IdentifierIn { .. } @@ -1261,6 +1372,13 @@ impl PropertyConstraint { } } } + PropertyConstraint::TextAffix { text, affix, .. } => { + for side in [text, affix] { + if let TextOperand::Property(property) = side { + reads.push((&property.path, PropertyRead::Text)); + } + } + } PropertyConstraint::Contains { array, needle } => match needle { ContainsNeedle::Integer(expression) => { reads.push((array, PropertyRead::Elements(ElementKind::Integer))); @@ -1402,7 +1520,8 @@ fn single_entry(value: &Value) -> Option<(&str, &Value)> { /// Every key a condition object may hold, for the errors. fn condition_keys() -> String { format!( - "a comparison ({}), in, contains, present, absent, anyOf, allOf or not", + "a comparison ({}), in, startsWith, endsWith, contains, present, absent, anyOf, allOf \ + or not", ConstraintComparison::ALL .map(ConstraintComparison::wire_name) .join(", ") @@ -1539,6 +1658,45 @@ fn parse_condition( PropertyConstraint::In { operand, values } } } + STARTS_WITH | ENDS_WITH => { + let Some([text, affix]) = body.as_array().map(Vec::as_slice) else { + return Err(format!( + "at {at} must list two strings: the one tested, then the one it must {} with", + if key == STARTS_WITH { "start" } else { "end" } + )); + }; + let text = text_operand(text, &format!("{at}[0]"))?; + let affix = text_operand(affix, &format!("{at}[1]"))?; + match (&text, &affix) { + (TextOperand::Constant(_), TextOperand::Constant(_)) => { + at.truncate(parent); + return Err(format!( + "{}reads no property, so it would hold for every document or for none", + located(at) + )); + } + (TextOperand::Property(text), TextOperand::Property(affix)) + if text.path == affix.path => + { + return Err(format!( + "at {at} tests \"{}\" against itself, so it would hold for every \ + document or for none", + text.path + )); + } + _ => {} + } + let position = if key == STARTS_WITH { + AffixPosition::Start + } else { + AffixPosition::End + }; + PropertyConstraint::TextAffix { + position, + text, + affix, + } + } CONTAINS => { let Some([array, needle]) = body.as_array().map(Vec::as_slice) else { return Err(format!( @@ -1914,6 +2072,15 @@ fn text_side(value: &Value, at: &str) -> Result { )) } +/// A side at `at` (`startsWith[1]`) of a `startsWith` or `endsWith`: a `const` +/// string or a string property, as [`text_side`] reads them. +fn text_operand(value: &Value, at: &str) -> Result { + Ok(match text_side(value, at)? { + TextSide::Constant(value) => TextOperand::Constant(value), + TextSide::Property(property) => TextOperand::Property(property), + }) +} + /// The comparison at `at` (`equal`) of `left` and `right`, a comparison of /// strings: only `equal` and `notEqual` compare them, and each side is a /// `const` string or a string property ([`text_side`]). `None` when both are diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index bcf602b5574..6c420213353 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -1590,8 +1590,8 @@ fn should_parse_present_and_absent() { ( platform_value!({ "exists": "discount" }), "rule \"rule\" names \"exists\", which is not a comparison (equal, notEqual, \ - lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual), in, contains, present, \ - absent, anyOf, allOf or not", + lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual), in, startsWith, \ + endsWith, contains, present, absent, anyOf, allOf or not", ), ] { expect_refusal(platform_value!({ "rule": condition }), needle); @@ -2829,3 +2829,153 @@ fn should_look_for_a_value_among_the_elements() { Some(PropertyConstraintViolation::DivisionByZero) ); } + +// ── startsWith and endsWith ───────────────────────────────────────────── + +/// Each side is a const or a string property, with or without a default; +/// a string constant looked for in a property is listed for the enum check. +#[test] +fn should_parse_starts_with_and_ends_with() { + for (key, position) in [ + ("startsWith", AffixPosition::Start), + ("endsWith", AffixPosition::End), + ] { + assert_eq!(position.wire_name(), key); + let rule = parse_rule_value(platform_value!({ key: ["status", { "const": "op" }] })); + assert_eq!( + rule, + PropertyConstraint::TextAffix { + position, + text: TextOperand::Property(TextProperty { + path: "status".to_string(), + if_absent: None, + }), + affix: TextOperand::Constant("op".to_string()), + }, + "{key}" + ); + assert_eq!(rule.node_count(), 3); + assert_eq!(rule.property_reads(), [("status", PropertyRead::Text)]); + assert_eq!(rule.text_affixes(), [("status", "op", position)]); + // A prefix or a suffix is not a whole value: no equality enum check + assert!(rule.text_constants().is_empty()); + + let both = parse_rule_value(platform_value!({ + key: [{ "ifAbsent": ["to", "x"] }, "from"] + })); + assert_eq!( + both.property_reads(), + [("to", PropertyRead::Text), ("from", PropertyRead::Text)] + ); + assert_eq!(both.text_defaults(), [("to", "x")]); + assert!(both.text_affixes().is_empty()); + + // A constant tested for a property's affix is no enum typo to check + let constant_text = parse_rule_value(platform_value!({ + key: [{ "const": "https://example.org" }, "status"] + })); + assert!(constant_text.text_affixes().is_empty()); + } +} + +#[test] +fn should_refuse_a_malformed_starts_with() { + for (rule, needle) in [ + ( + platform_value!({ "startsWith": ["status"] }), + "at startsWith must list two strings: the one tested, then the one it must start with", + ), + ( + platform_value!({ "endsWith": ["status", "from", "to"] }), + "at endsWith must list two strings: the one tested, then the one it must end with", + ), + ( + platform_value!({ "startsWith": [{ "const": "a" }, { "const": "b" }] }), + "rule \"rule\" reads no property", + ), + ( + platform_value!({ "endsWith": ["status", "status"] }), + "at endsWith tests \"status\" against itself", + ), + ( + platform_value!({ "startsWith": ["status", 5] }), + "at startsWith[1] must be the path of a string property", + ), + ( + platform_value!({ "startsWith": ["status", { "const": 5 }] }), + "at startsWith[1].const must be a string", + ), + ] { + expect_refusal(platform_value!({ "rule": rule }), needle); + } +} + +/// Byte for byte, with no case folding: a string starts and ends with the +/// empty one and with itself; a property left out without a default takes no +/// string, and the condition does not hold for it. +#[test] +fn should_test_whether_a_string_starts_or_ends_with_another() { + let none = DocumentSystemValues::default(); + let https = + parse_rule_value(platform_value!({ "startsWith": ["status", { "const": "https://" }] })); + let domain = + parse_rule_value(platform_value!({ "endsWith": ["status", { "const": ".dash" }] })); + for (status, starts, ends) in [ + (Some("https://pay.dash"), true, true), + (Some("HTTPS://pay.dash"), false, true), + (Some("http://pay.dash/"), false, false), + (Some("https://"), true, false), + (Some(""), false, false), + (None, false, false), + ] { + let values = match status { + Some(status) => data(&[("status", Value::from(status))]), + None => data(&[]), + }; + assert_eq!(https.holds(&values, &none), Ok(starts), "{status:?}"); + assert_eq!(domain.holds(&values, &none), Ok(ends), "{status:?}"); + } + + // Multibyte text compares byte for byte, which for valid strings is + // character for character + let accented = + parse_rule_value(platform_value!({ "startsWith": ["status", { "const": "é" }] })); + assert_eq!( + accented.holds(&data(&[("status", Value::from("été"))]), &none), + Ok(true) + ); + assert_eq!( + accented.holds(&data(&[("status", Value::from("e"))]), &none), + Ok(false) + ); + + // Two properties: a reply's path starts with its thread's + let nested = parse_rule_value(platform_value!({ "startsWith": ["to", "from"] })); + let paths = |to: &str, from: Option<&str>| { + let mut entries = vec![("to", Value::from(to))]; + if let Some(from) = from { + entries.push(("from", Value::from(from))); + } + data(&entries) + }; + assert_eq!(nested.holds(&paths("a/b/c", Some("a/b")), &none), Ok(true)); + assert_eq!(nested.holds(&paths("a/c", Some("a/b")), &none), Ok(false)); + assert_eq!(nested.holds(&paths("a/b", None), &none), Ok(false)); + // A default fills a property left out + let defaulted = parse_rule_value(platform_value!({ + "startsWith": ["to", { "ifAbsent": ["from", ""] }] + })); + assert_eq!(defaulted.holds(&paths("a/b", None), &none), Ok(true)); + // `not` refuses a prefix + let not_draft = parse_rule_value(platform_value!({ + "not": { "startsWith": ["status", { "const": "draft:" }] } + })); + assert_eq!( + not_draft.violation(&data(&[("status", Value::from("draft:1"))]), &none), + Some(PropertyConstraintViolation::NotMet) + ); + assert_eq!( + not_draft.violation(&data(&[("status", Value::from("final"))]), &none), + None + ); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index 5f353062978..802a3731b7b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -385,6 +385,38 @@ mod property_constraints_tests { }) } + /// An `offer` type with the integers [`set_valid_offer`] fills, a `url`, a + /// `path` and a `parentPath`, with three rules on prefixes and suffixes: + /// `dashDomain` (a url ends with `.dash`), `secureUrl` (a url starts with + /// `https://`) and `underParent` (a path starts with its parent's). + fn linked_offer_schema() -> Value { + platform_value!({ + "type": "object", + "properties": { + "price": { "type": "integer", "minimum": 0, "position": 0 }, + "fee": { "type": "integer", "minimum": 0, "position": 1 }, + "quantity": { "type": "integer", "minimum": 0, "position": 2 }, + "deposit": { "type": "integer", "minimum": 0, "position": 3 }, + "url": { "type": "string", "maxLength": 100, "position": 4 }, + "path": { "type": "string", "maxLength": 100, "position": 5 }, + "parentPath": { "type": "string", "maxLength": 100, "position": 6 } + }, + "required": ["price", "fee", "quantity", "deposit"], + "propertyConstraints": { + "dashDomain": { + "anyOf": [{ "absent": "url" }, { "endsWith": ["url", { "const": ".dash" }] }] + }, + "secureUrl": { + "anyOf": [{ "absent": "url" }, { "startsWith": ["url", { "const": "https://" }] }] + }, + "underParent": { + "anyOf": [{ "absent": "parentPath" }, { "startsWith": ["path", "parentPath"] }] + } + }, + "additionalProperties": false + }) + } + /// An offer that meets every rule: (100 + 10) * 2 = 220. fn set_valid_offer(document: &mut Document) { document.set("price", Value::U64(100)); @@ -1803,4 +1835,43 @@ mod property_constraints_tests { StateTransitionExecutionResult::SuccessfulExecution { .. } ); } + + /// `startsWith` and `endsWith` read by real creates: a url on another domain, + /// one without https and a path outside its parent's are each refused with + /// the rule they break, and an offer meeting all three is stored. + #[tokio::test] + async fn should_judge_prefixes_and_suffixes_on_create() { + let mut fixture = OfferFixture::with_schema(linked_offer_schema()); + + let result = fixture + .create(|document| document.set("url", Value::from("https://shop.com"))) + .await; + expect_violated(result, "dashDomain", PropertyConstraintViolation::NotMet); + + let result = fixture + .create(|document| document.set("url", Value::from("http://shop.dash"))) + .await; + expect_violated(result, "secureUrl", PropertyConstraintViolation::NotMet); + + let result = fixture + .create(|document| { + document.set("path", Value::from("a/c")); + document.set("parentPath", Value::from("a/b")); + }) + .await; + expect_violated(result, "underParent", PropertyConstraintViolation::NotMet); + assert!(fixture.stored_offers().is_empty()); + + assert_matches!( + fixture + .create(|document| { + document.set("url", Value::from("https://shop.dash")); + document.set("path", Value::from("a/b/c")); + document.set("parentPath", Value::from("a/b")); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 1); + } } diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index d710aa21f9e..884529d3e78 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1068,9 +1068,11 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// declaring `refersTo` included) or of `$ownerId`, the document's owner, /// likewise, with base58 identifier constants or another identifier operand /// and no default, an identifier the document leaves out equalling none; -/// `contains`, whether a typed array property holds an element equal to an -/// integer expression, a string or an identifier operand (a constant, a -/// property, or `$ownerId`), as its elements are, an array the document +/// `startsWith` or `endsWith`, whether a string (a constant or a string +/// property, at least one a property) starts or ends with another, byte for +/// byte; `contains`, whether a typed array property holds an element equal +/// to an integer expression, a string or an identifier operand (a constant, +/// a property, or `$ownerId`), as its elements are, an array the document /// leaves out holding nothing; `present` or `absent` naming a property of /// any type, whether the document holds it (the one way to tell a property /// left out from one set to 0); `anyOf` or `allOf` over two or more @@ -1086,21 +1088,23 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// a pass, so an earlier condition guards a later one. The parser checks /// that every path an operand reads names an integer or boolean property, /// every path a `length` or `byteLength` measures a string property, every -/// path a `count` counts an array or byte array property, every array a -/// `contains` looks in a typed array of the kind it looks for (a string -/// constant in the elements' `enum` when they declare one), every system -/// time or height a rule reads one the type lists in `required` (none on an -/// indexOnly type), every path compared with identifiers an identifier -/// property, every path compared with strings a string property (whose -/// `enum`, if it declares one, lists every constant it is compared with), -/// and every path `present` or `absent` tests a property of any type, none -/// transient nor inside a transient object; that every comparison and `in` -/// reads a property or the owner; that nothing is compared with itself; -/// that strings and identifiers are only compared for equality, and never -/// with each other; that no `in` lists a value twice; that an `anyOf` or -/// `allOf` holds none directly of its own kind and a `not` no `not`; that -/// an indexOnly type, whose deletes carry no owner, reads no `$ownerId`; -/// and that no condition or operand nests deeper than +/// path a `count` counts an array or byte array property, every string +/// `startsWith` or `endsWith` tests a string property (a constant tested +/// against one with an `enum` starting or ending one of its values), every +/// array a `contains` looks in a typed array of the kind it looks for (a +/// string constant in the elements' `enum` when they declare one), every +/// system time or height a rule reads one the type lists in `required` +/// (none on an indexOnly type), every path compared with identifiers an +/// identifier property, every path compared with strings a string property +/// (whose `enum`, if it declares one, lists every constant it is compared +/// with), and every path `present` or `absent` tests a property of any +/// type, none transient nor inside a transient object; that every +/// comparison and `in` reads a property or the owner; that nothing is +/// compared with itself; that strings and identifiers are only compared for +/// equality, and never with each other; that no `in` lists a value twice; +/// that an `anyOf` or `allOf` holds none directly of its own kind and a +/// `not` no `not`; that an indexOnly type, whose deletes carry no owner, +/// reads no `$ownerId`; and that no condition or operand nests deeper than /// `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every parse. Under full /// validation it holds the limits `SystemLimits::max_property_constraints` /// (16 rules) and `max_property_constraint_nodes` (32 per rule, every diff --git a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs index db1c19679f7..fbe4deb6a97 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs @@ -85,6 +85,8 @@ export type PropertyConstraintEqualityOperand = * - `in`: an integer expression and two or more distinct integers, or a * string or identifier property (or `$ownerId`) and two or more distinct * strings or base58 identifiers; + * - `startsWith` / `endsWith`: two strings, a `const` or a string property + * each, the first starting or ending with the second, byte for byte; * - `contains`: a typed array property and the value one of its elements must * equal, an integer expression, a string or an identifier operand as its * elements are; an array the document leaves out holds nothing; @@ -100,6 +102,8 @@ export type PropertyConstraintCondition = | { greaterThan: [PropertyConstraintExpression, PropertyConstraintExpression] } | { greaterThanOrEqual: [PropertyConstraintExpression, PropertyConstraintExpression] } | { in: [PropertyConstraintExpression, Array] | [string | { ifAbsent: [path: string, value: string] }, string[]] } + | { startsWith: [PropertyConstraintEqualityOperand, PropertyConstraintEqualityOperand] } + | { endsWith: [PropertyConstraintEqualityOperand, PropertyConstraintEqualityOperand] } | { contains: [path: string, PropertyConstraintExpression | PropertyConstraintEqualityOperand] } | { present: string } | { absent: string } diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts index 43c3f469959..8ef042ac405 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts @@ -323,6 +323,41 @@ describe('DataContract: propertyConstraints (v14)', () => { .to.deep.include({ rule: 'notUsed', violation: 'NotMet' }); }); + it('should check startsWith and endsWith byte for byte', () => { + const rules = { + secureUrl: { startsWith: ['url', { const: 'https://' }] }, + dashDomain: { endsWith: ['url', { const: '.dash' }] }, + }; + const contract = buildContract({ + link: { + type: 'object', + properties: { + url: { type: 'string', maxLength: 100, position: 0 }, + }, + required: ['url'], + additionalProperties: false, + propertyConstraints: rules, + }, + }); + + expect(contract.documentTypePropertyConstraints('link').map((rule) => rule.reads)) + .to.deep.equal([[{ path: 'url', kind: 'text' }], [{ path: 'url', kind: 'text' }]]); + + const link = (url: string) => new wasm.Document({ + properties: { url }, + documentTypeName: 'link', + dataContractId: contract.id, + ownerId, + revision: BigInt(1), + }); + expect(contract.checkDocumentPropertyConstraints(link('https://pay.dash'))).to.equal(undefined); + expect(contract.checkDocumentPropertyConstraints(link('https://pay.com'))) + .to.deep.include({ rule: 'dashDomain', violation: 'NotMet' }); + // No case folding + expect(contract.checkDocumentPropertyConstraints(link('HTTPS://pay.dash'))) + .to.deep.include({ rule: 'secureUrl', violation: 'NotMet' }); + }); + it('should report integer literals past Number.MAX_SAFE_INTEGER exactly, as bigint', () => { const big = 9007199254740993n; // 2 ** 53 + 1, which a number rounds const rules = { From 5870cdde4be4b5b8493190ccab92b01a7bf1234f Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 05:56:52 +0700 Subject: [PATCH 061/113] fix(wasm-sdk): keep StateTransitionResult.ownerBalance exact in JSON (#5059) Co-authored-by: Claude Opus 5.5 --- packages/wasm-sdk/src/queries/system.rs | 36 ++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/wasm-sdk/src/queries/system.rs b/packages/wasm-sdk/src/queries/system.rs index 71716411e9e..8f2460da8e1 100644 --- a/packages/wasm-sdk/src/queries/system.rs +++ b/packages/wasm-sdk/src/queries/system.rs @@ -1158,6 +1158,7 @@ impl PathElementWasm { } } +#[dpp_json_convertible_derive::json_safe_fields(crate = "dash_sdk::dpp")] #[wasm_bindgen(js_name = "StateTransitionResult")] #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1172,7 +1173,7 @@ pub struct StateTransitionResultWasm { /// DAPI read it without a proof. Present when the SDK did not ask for a /// proof (it then asks for the balance); a proved wait of an owned, /// fee-paying transition carries the balance inside the proof instead. - pub owner_balance: Option, + owner_balance: Option, } impl StateTransitionResultWasm { @@ -1191,6 +1192,15 @@ impl StateTransitionResultWasm { } } +#[wasm_bindgen(js_class = StateTransitionResult)] +impl StateTransitionResultWasm { + /// The credit balance of the transition's owner after it executed, when DAPI reported it. + #[wasm_bindgen(getter = "ownerBalance")] + pub fn owner_balance(&self) -> Option { + self.owner_balance + } +} + #[wasm_bindgen] impl WasmSdk { #[wasm_bindgen(js_name = "getStatus")] @@ -1777,6 +1787,30 @@ impl WasmSdk { #[cfg(test)] mod tests { + /// A balance above JavaScript's safe integer range must survive the JSON form, which + /// `json_safe_fields` makes a string there, and stay a number below it. + #[test] + fn should_keep_a_large_owner_balance_exact_in_json() { + let large = (1u64 << 53) + 1; + let result = StateTransitionResultWasm::new( + "hash".to_string(), + "SUCCESS".to_string(), + None, + Some(large), + ); + let json = serde_json::to_value(&result).expect("expected to serialize"); + assert_eq!(json["ownerBalance"], serde_json::json!(large.to_string())); + + let small = StateTransitionResultWasm::new( + "hash".to_string(), + "SUCCESS".to_string(), + None, + Some(1_000), + ); + let json = serde_json::to_value(&small).expect("expected to serialize"); + assert_eq!(json["ownerBalance"], serde_json::json!(1_000)); + } + use super::*; use dash_sdk::drive::grovedb::element::reference_path::ReferencePathType; From b23a06b7dc6f3fa0a105cb9633446a5cc523a359 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 05:58:29 +0700 Subject: [PATCH 062/113] fix(dpp)!: size estimates of strings of 16384 or more characters no longer overflow (PV14) (#5086) Co-authored-by: Claude Opus 5.5 --- .../document_type/methods/mod.rs | 134 +++++- .../methods/versioned_methods.rs | 22 +- .../document_type/property/array.rs | 44 +- .../document_type/property/mod.rs | 190 ++++++++ .../tests/document/long_string_sizing.rs | 405 ++++++++++++++++++ .../batch/tests/document/mod.rs | 1 + 6 files changed, 786 insertions(+), 10 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/long_string_sizing.rs diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index c864b2822f0..5a6dc811a9f 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -786,12 +786,19 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe mod tests { use super::*; use crate::data_contract::config::DataContractConfig; - use crate::data_contract::document_type::DocumentType; + use crate::data_contract::document_type::{DocumentType, CONTRACT_VERSION_STAMP_MAX_SIZE}; use platform_value::{platform_value, Identifier}; /// Build a document type from a schema using latest platform version. fn build_doc_type(name: &str, schema: Value) -> DocumentType { - let platform_version = PlatformVersion::latest(); + build_doc_type_at(name, schema, PlatformVersion::latest()) + } + + fn build_doc_type_at( + name: &str, + schema: Value, + platform_version: &PlatformVersion, + ) -> DocumentType { let config = DataContractConfig::default_for_version(platform_version) .expect("should create default config"); DocumentType::try_from_schema( @@ -810,6 +817,129 @@ mod tests { .expect("should build doc type") } + // -------------------------------------------------------------- + // DocumentTypeV0Methods::estimated_size + // -------------------------------------------------------------- + + /// A note type with one `text` property. + fn note_schema(text: Value) -> Value { + platform_value!({ + "type": "object", + "properties": {"text": text}, + "additionalProperties": false, + }) + } + + /// A string of up to 20000 characters without `maxBytes`: up to 80000 + /// bytes, past `u16::MAX`. + fn long_text() -> Value { + platform_value!({"type": "string", "maxLength": 20000, "position": 0}) + } + + #[test] + fn should_estimate_a_string_past_16383_characters_as_a_string_without_max_length() { + let platform_version = PlatformVersion::latest(); + let long = build_doc_type("note", note_schema(long_text())); + let unbounded = build_doc_type( + "note", + note_schema(platform_value!({"type": "string", "position": 0})), + ); + + let estimated_size = long + .as_ref() + .estimated_size(platform_version) + .expect("the long string is estimated"); + assert_eq!( + estimated_size, + unbounded + .as_ref() + .estimated_size(platform_version) + .expect("the unbounded string is estimated") + ); + // Half of u16::MAX, rounded up, and the contract-version stamp + assert_eq!(estimated_size, 32768 + CONTRACT_VERSION_STAMP_MAX_SIZE); + } + + #[test] + fn should_estimate_a_typed_array_of_strings_past_16383_characters() { + let platform_version = PlatformVersion::latest(); + let list = build_doc_type( + "list", + platform_value!({ + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {"type": "string", "maxLength": 20000}, + "maxItems": 2, + "position": 0 + } + }, + "additionalProperties": false, + }), + ); + + // Between the one-byte count of an empty list and u16::MAX + assert_eq!( + list.as_ref() + .estimated_size(platform_version) + .expect("the typed array is estimated"), + 32768 + CONTRACT_VERSION_STAMP_MAX_SIZE + ); + } + + /// Generation 0, which protocol version 13 selects, still fails on the + /// overflow. + #[test] + fn should_fail_the_estimate_of_a_string_past_16383_characters_at_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("expected version 13"); + let long = build_doc_type_at("note", note_schema(long_text()), platform_version); + + assert!(matches!( + long.as_ref().estimated_size(platform_version), + Err(ProtocolError::Overflow(_)) + )); + } + + /// Below 16384 characters both generations size every property alike; + /// generation 1 adds only the contract-version stamp. + #[test] + fn should_estimate_bounded_properties_as_protocol_version_13_does_plus_the_stamp() { + let platform_version = PlatformVersion::latest(); + let platform_version_13 = PlatformVersion::get(13).expect("expected version 13"); + let schema = platform_value!({ + "type": "object", + "properties": { + "title": {"type": "string", "minLength": 3, "maxLength": 16383, "position": 0}, + "count": {"type": "integer", "minimum": 0, "maximum": 1000, "position": 1}, + "data": {"type": "array", "byteArray": true, "maxItems": 64, "position": 2}, + "owner": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 3 + } + }, + "additionalProperties": false, + }); + let at_latest = build_doc_type("doc", schema.clone()); + let at_13 = build_doc_type_at("doc", schema, platform_version_13); + + assert_eq!( + at_latest + .as_ref() + .estimated_size(platform_version) + .expect("estimated"), + at_13 + .as_ref() + .estimated_size(platform_version_13) + .expect("estimated") + + CONTRACT_VERSION_STAMP_MAX_SIZE + ); + } + // -------------------------------------------------------------- // DocumentTypeBasicMethods::requires_revision / initial_revision // -------------------------------------------------------------- diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs index 08f08178e5f..37d9b66b67d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs @@ -642,10 +642,26 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa /// Generation 0 plus the document serialization format 3 /// contract-version stamp varint. Selected together with format 3 by /// the version table. + /// + /// Each property is sized by + /// [`DocumentPropertyType::saturating_middle_byte_size_ceil`]: a string + /// of 16384 or more characters without `maxBytes` has a byte bound past + /// `u16::MAX`, which generation 0 fails on with an overflow and this + /// generation holds at `u16::MAX`. Every other type is sized as + /// generation 0 sizes it, and the total saturates as generation 0's does. fn estimated_size_v1(&self, platform_version: &PlatformVersion) -> Result { - Ok(self - .estimated_size_v0(platform_version)? - .saturating_add(CONTRACT_VERSION_STAMP_MAX_SIZE)) + let mut total_size = 0u16; + + for document_property in self.flattened_properties().values() { + if let Some(size) = document_property + .property_type + .saturating_middle_byte_size_ceil(platform_version)? + { + total_size = total_size.saturating_add(size); + } + } + + Ok(total_size.saturating_add(CONTRACT_VERSION_STAMP_MAX_SIZE)) } fn max_size_v0(&self, platform_version: &PlatformVersion) -> Result { diff --git a/packages/rs-dpp/src/data_contract/document_type/property/array.rs b/packages/rs-dpp/src/data_contract/document_type/property/array.rs index e6cdec40c75..6c0da655666 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/array.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/array.rs @@ -173,12 +173,29 @@ impl TypedArrayProperty { &self, platform_version: &PlatformVersion, ) -> Result { + let element_bytes = self.item_type.min_byte_size(platform_version)?; + Ok(self.min_encoded_size_of_elements(element_bytes)) + } + + /// [`Self::min_encoded_size`] with the element sized by + /// [`DocumentPropertyType::saturating_min_byte_size`], so a string element + /// of 16384 or more characters counts as `u16::MAX` bytes instead of + /// failing with an overflow. + pub fn saturating_min_encoded_size( + &self, + platform_version: &PlatformVersion, + ) -> Result { + let element_bytes = self.item_type.saturating_min_byte_size(platform_version)?; + Ok(self.min_encoded_size_of_elements(element_bytes)) + } + + fn min_encoded_size_of_elements(&self, element_bytes: Option) -> u16 { let min_items = self.min_items.unwrap_or(0); - let element_bytes = self.item_type.min_byte_size(platform_version)?.unwrap_or(0); + let element_bytes = element_bytes.unwrap_or(0); let size = (min_items.required_space() as u64).saturating_add( u64::from(min_items).saturating_mul(self.element_encoded_size(element_bytes)), ); - Ok(u16::try_from(size).unwrap_or(u16::MAX)) + u16::try_from(size).unwrap_or(u16::MAX) } /// The most bytes the array encodes to: the varint count of `maxItems` @@ -189,14 +206,31 @@ impl TypedArrayProperty { &self, platform_version: &PlatformVersion, ) -> Result { - let element_bytes = match self.item_type.max_byte_size(platform_version)? { + let element_bytes = self.item_type.max_byte_size(platform_version)?; + Ok(self.max_encoded_size_of_elements(element_bytes)) + } + + /// [`Self::max_encoded_size`] with the element sized by + /// [`DocumentPropertyType::saturating_max_byte_size`], so a string element + /// of 16384 or more characters makes the array `u16::MAX` bytes instead of + /// failing with an overflow. + pub fn saturating_max_encoded_size( + &self, + platform_version: &PlatformVersion, + ) -> Result { + let element_bytes = self.item_type.saturating_max_byte_size(platform_version)?; + Ok(self.max_encoded_size_of_elements(element_bytes)) + } + + fn max_encoded_size_of_elements(&self, element_bytes: Option) -> u16 { + let element_bytes = match element_bytes { Some(element_bytes) if element_bytes < u16::MAX => element_bytes, - _ => return Ok(u16::MAX), + _ => return u16::MAX, }; let size = (self.max_items.required_space() as u64).saturating_add( u64::from(self.max_items).saturating_mul(self.element_encoded_size(element_bytes)), ); - Ok(u16::try_from(size).unwrap_or(u16::MAX)) + u16::try_from(size).unwrap_or(u16::MAX) } /// Encodes a list: the varint element count, then each element exactly as diff --git a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index ddc28d227b9..005341e4760 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -1571,6 +1571,21 @@ pub enum DocumentPropertyType { KeyIdWithReference(KeyIdReference), } +/// Adds byte sizes the way summing `Option` does, `None` once any size is +/// `None`, but saturating at `u16::MAX` instead of overflowing. +fn saturating_sum( + sizes: impl Iterator, ProtocolError>>, +) -> Result, ProtocolError> { + let mut total = 0u16; + for size in sizes { + let Some(size) = size? else { + return Ok(None); + }; + total = total.saturating_add(size); + } + Ok(Some(total)) +} + impl DocumentPropertyType { #[deprecated = "this method is missing required information to create a type. Use TryFrom<&Value> instead."] pub fn try_from_name(name: &str) -> Result { @@ -1978,6 +1993,80 @@ impl DocumentPropertyType { } } + /// [`Self::min_byte_size`], with a bound past `u16::MAX` held at + /// `u16::MAX` instead of refused with an overflow. A string counts four + /// bytes a character, so `minLength` 16384 or more does not fit. For size + /// estimates, which only need a bound. + pub fn saturating_min_byte_size( + &self, + platform_version: &PlatformVersion, + ) -> Result, ProtocolError> { + match self { + DocumentPropertyType::String(StringPropertySizes { + min_length: Some(length), + .. + }) => Ok(Some(length.saturating_mul(4))), + DocumentPropertyType::Object(sub_fields) => { + saturating_sum(sub_fields.values().map(|sub_field| { + sub_field + .property_type + .saturating_min_byte_size(platform_version) + })) + } + DocumentPropertyType::TypedArray(typed_array) => typed_array + .saturating_min_encoded_size(platform_version) + .map(Some), + property_type => property_type.min_byte_size(platform_version), + } + } + + /// [`Self::max_byte_size`], with a bound past `u16::MAX` held at + /// `u16::MAX` instead of refused with an overflow. A string without + /// `maxBytes` counts four bytes a character, so `maxLength` 16384 or more + /// does not fit; it then sizes like a string without `maxLength`. For size + /// estimates, which only need a bound. + pub fn saturating_max_byte_size( + &self, + platform_version: &PlatformVersion, + ) -> Result, ProtocolError> { + match self { + DocumentPropertyType::String(StringPropertySizes { + max_length: Some(length), + max_bytes: None, + .. + }) => Ok(Some(length.saturating_mul(4))), + DocumentPropertyType::Object(sub_fields) => { + saturating_sum(sub_fields.values().map(|sub_field| { + sub_field + .property_type + .saturating_max_byte_size(platform_version) + })) + } + DocumentPropertyType::TypedArray(typed_array) => typed_array + .saturating_max_encoded_size(platform_version) + .map(Some), + property_type => property_type.max_byte_size(platform_version), + } + } + + /// [`Self::middle_byte_size_ceil`] from [`Self::saturating_min_byte_size`] + /// and [`Self::saturating_max_byte_size`]. + pub fn saturating_middle_byte_size_ceil( + &self, + platform_version: &PlatformVersion, + ) -> Result, ProtocolError> { + let Some(min_size) = self.saturating_min_byte_size(platform_version)? else { + return Ok(None); + }; + let Some(max_size) = self.saturating_max_byte_size(platform_version)? else { + return Ok(None); + }; + // The mean of two `u16` values fits in a `u16` + Ok(Some( + ((min_size as u32 + max_size as u32).div_ceil(2)) as u16, + )) + } + pub fn random_size(&self, rng: &mut StdRng) -> u16 { let min_size = self.min_size().unwrap_or_default(); let max_size = self.max_size().unwrap_or_default(); @@ -4775,6 +4864,107 @@ mod tests { assert_eq!(s.middle_byte_size_ceil(pv).unwrap(), Some(22)); } + // ----------------------------------------------------------------------- + // saturating_min_byte_size() / saturating_max_byte_size() tests + // ----------------------------------------------------------------------- + + fn string_sizes( + min_length: Option, + max_length: Option, + max_bytes: Option, + ) -> DocumentPropertyType { + DocumentPropertyType::String(StringPropertySizes { + min_length, + max_length, + max_bytes, + }) + } + + #[test] + fn should_hold_string_byte_bounds_past_u16_max_at_u16_max() { + let pv = PlatformVersion::latest(); + // 20000 characters of up to four bytes each is 80000 bytes + let long = string_sizes(Some(20000), Some(20000), None); + assert!(matches!( + long.min_byte_size(pv), + Err(ProtocolError::Overflow(_)) + )); + assert!(matches!( + long.max_byte_size(pv), + Err(ProtocolError::Overflow(_)) + )); + assert_eq!(long.saturating_min_byte_size(pv).unwrap(), Some(u16::MAX)); + assert_eq!(long.saturating_max_byte_size(pv).unwrap(), Some(u16::MAX)); + assert_eq!( + long.saturating_middle_byte_size_ceil(pv).unwrap(), + Some(u16::MAX) + ); + + // Past 16383 characters the bound is the one a string without + // `maxLength` has + assert_eq!( + string_sizes(None, Some(20000), None) + .saturating_middle_byte_size_ceil(pv) + .unwrap(), + string_sizes(None, None, None) + .middle_byte_size_ceil(pv) + .unwrap() + ); + } + + #[test] + fn should_size_bounds_that_fit_as_the_non_saturating_methods_do() { + let pv = PlatformVersion::latest(); + for property_type in [ + string_sizes(Some(1), Some(10), None), + string_sizes(None, Some(16383), None), + string_sizes(None, None, None), + // `maxBytes` bounds a long string below u16::MAX + string_sizes(None, Some(20000), Some(100)), + DocumentPropertyType::U64, + DocumentPropertyType::Identifier, + DocumentPropertyType::Array(ArrayItemType::Integer), + ] { + assert_eq!( + property_type.saturating_min_byte_size(pv).unwrap(), + property_type.min_byte_size(pv).unwrap(), + "{property_type:?}" + ); + assert_eq!( + property_type.saturating_max_byte_size(pv).unwrap(), + property_type.max_byte_size(pv).unwrap(), + "{property_type:?}" + ); + assert_eq!( + property_type.saturating_middle_byte_size_ceil(pv).unwrap(), + property_type.middle_byte_size_ceil(pv).unwrap(), + "{property_type:?}" + ); + } + } + + #[test] + fn should_saturate_the_byte_bounds_of_a_typed_array_of_long_strings() { + let pv = PlatformVersion::latest(); + let typed_array = DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: Box::new(string_sizes(None, Some(20000), None)), + item_constraints: Default::default(), + min_items: None, + max_items: 2, + unique_items: false, + }); + assert!(matches!( + typed_array.max_byte_size(pv), + Err(ProtocolError::Overflow(_)) + )); + // No element at least: the one byte count + assert_eq!(typed_array.saturating_min_byte_size(pv).unwrap(), Some(1)); + assert_eq!( + typed_array.saturating_max_byte_size(pv).unwrap(), + Some(u16::MAX) + ); + } + // ----------------------------------------------------------------------- // is_integer() tests // ----------------------------------------------------------------------- diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/long_string_sizing.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/long_string_sizing.rs new file mode 100644 index 00000000000..03116e86e1a --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/long_string_sizing.rs @@ -0,0 +1,405 @@ +//! Document writes on a type whose string property holds 16384 or more +//! characters and declares no `maxBytes`. Such a contract is valid at every +//! protocol version: the meta-schemas bound `maxLength` only when `pattern` or +//! `format` is set. At four bytes a character its byte bound does not fit in +//! the `u16` a document type's estimated size is computed in, which the fee +//! estimate of every create, replace and delete reads, in `check_tx` and in +//! the block. From protocol version 14 the estimate holds that bound at +//! `u16::MAX`; before it, the estimate fails and so does the write. + +use super::*; + +mod long_string_sizing_tests { + use super::*; + use crate::error::Error; + use crate::execution::check_tx::{CheckTxLevel, CheckTxResult}; + use crate::platform_types::platform::PlatformRef; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; + use dpp::data_contract::document_type::DocumentTypeRef; + use dpp::data_contract::DataContractFactory; + use dpp::document::Document; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::{Identity, IdentityPublicKey, IdentityV0}; + use dpp::platform_value::platform_value; + use dpp::prelude::{DataContract, IdentityNonce}; + use dpp::state_transition::data_contract_create_transition::methods::DataContractCreateTransitionMethodsV0; + use dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; + use dpp::state_transition::StateTransition; + use dpp::validation::ValidationResult; + use dpp::version::ProtocolVersion; + use drive::util::object_size_info::DocumentInfo::DocumentRefInfo; + use drive::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; + use simple_signer::signer::SimpleSigner; + use std::collections::BTreeMap; + + /// What one transition met in `check_tx` and in the block. + struct Outcome { + check_tx: Result, Error>, + processed: StateTransitionExecutionResult, + } + + impl Outcome { + fn assert_successful(&self) { + assert_matches!(&self.check_tx, Ok(result) if result.is_valid()); + assert_matches!( + self.processed, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// The estimated size failing on the string's byte bound, in both. + fn assert_size_overflow(&self) { + assert_matches!( + &self.check_tx, + Err(error) if error.to_string().contains("max_byte_size overflow") + ); + assert_matches!( + &self.processed, + StateTransitionExecutionResult::InternalError(error) + if error.contains("max_byte_size overflow") + ); + } + } + + /// One identity and one contract registered through a contract create + /// transition, whose `note` type holds a `text` of up to 20000 characters + /// without `maxBytes`. + struct NoteFixture { + platform: TempPlatform, + platform_version: &'static PlatformVersion, + signer: SimpleSigner, + key: IdentityPublicKey, + identity: Identity, + contract: DataContract, + /// The identity contract nonce the next document transition uses. + next_nonce: IdentityNonce, + } + + impl NoteFixture { + async fn new(protocol_version: ProtocolVersion) -> Self { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected a known version"); + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(20000); + let mut signer = SimpleSigner::default(); + let (master_key, master_private_key) = + IdentityPublicKey::random_ecdsa_master_authentication_key_with_rng( + 0, + &mut rng, + platform_version, + ) + .expect("expected a master key"); + signer.add_identity_public_key(master_key.clone(), master_private_key); + let (key, private_key) = + IdentityPublicKey::random_ecdsa_critical_level_authentication_key_with_rng( + 1, + &mut rng, + platform_version, + ) + .expect("expected a critical key"); + signer.add_identity_public_key(key.clone(), private_key); + let identity: Identity = IdentityV0 { + id: Identifier::random_with_rng(&mut rng), + public_keys: BTreeMap::from([(0, master_key), (1, key.clone())]), + balance: dash_to_credits!(1), + revision: 0, + } + .into(); + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add the identity"); + + let contract = DataContractFactory::new(protocol_version) + .expect("expected a factory") + .create_with_value_config( + identity.id(), + 1, + platform_value!({ + "note": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "text": { + "type": "string", + "maxLength": 20000, + "position": 0 + } + }, + "required": ["text"], + "additionalProperties": false + } + }), + None, + None, + ) + .expect("expected the contract to be created") + .data_contract_owned(); + let transition = DataContractCreateTransition::new_from_data_contract( + contract.clone(), + 1, + &identity.clone().into_partial_identity_info(), + key.id(), + &signer, + platform_version, + None, + ) + .await + .expect("expected the contract create transition"); + + let fixture = Self { + platform, + platform_version, + signer, + key, + identity, + contract, + // The contract create took nonce 1 of the new contract + next_nonce: 2, + }; + // The contract itself registers at every protocol version + fixture.check_and_process(&transition).assert_successful(); + fixture + } + + fn note_type(&self) -> DocumentTypeRef<'_> { + self.contract + .document_type_for_name("note") + .expect("expected the note type") + } + + /// A new note, with the id its create transition at `next_nonce` gives it. + fn new_note(&self, text: &str) -> (Document, [u8; 32]) { + let entropy = [7u8; 32]; + let mut note = self + .note_type() + .create_document_from_data( + Value::from(BTreeMap::from([( + "text".to_string(), + Value::Text(text.to_string()), + )])), + self.identity.id(), + 0, + 0, + entropy, + self.platform_version, + ) + .expect("expected a note"); + note.set_id_for_creation( + self.note_type(), + &entropy, + self.next_nonce, + self.platform_version, + ) + .expect("expected the note id"); + (note, entropy) + } + + async fn create(&mut self, note: Document, entropy: [u8; 32]) -> Outcome { + let transition = BatchTransition::new_document_creation_transition_from_document( + note, + self.note_type(), + entropy, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + self.platform_version, + None, + ) + .await + .expect("expected the create transition"); + self.next_nonce += 1; + self.check_and_process(&transition) + } + + async fn replace(&mut self, stored: &Document, text: &str) -> Outcome { + let mut replacement = stored.clone(); + replacement.set("text", Value::Text(text.to_string())); + replacement + .increment_revision() + .expect("expected the revision to increment"); + let transition = BatchTransition::new_document_replacement_transition_from_document( + replacement, + self.note_type(), + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + self.platform_version, + None, + ) + .await + .expect("expected the replace transition"); + self.next_nonce += 1; + self.check_and_process(&transition) + } + + async fn delete(&mut self, stored: &Document) -> Outcome { + let transition = BatchTransition::new_document_deletion_transition_from_document( + stored.clone(), + self.note_type(), + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + self.platform_version, + None, + ) + .await + .expect("expected the delete transition"); + self.next_nonce += 1; + self.check_and_process(&transition) + } + + /// Writes `note` straight through Drive, which sizes nothing when it + /// applies, so replace and delete can be exercised where the create + /// transition fails. + fn seed(&self, note: &Document) { + self.platform + .drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((note, None)), + owner_id: None, + }, + contract: &self.contract, + document_type: self.note_type(), + }, + false, + BlockInfo::default(), + true, + None, + self.platform_version, + None, + ) + .expect("expected to seed the note"); + } + + fn check_and_process(&self, transition: &StateTransition) -> Outcome { + let serialized = transition + .serialize_to_bytes() + .expect("expected the transition to serialize"); + let platform_state = self.platform.state.load(); + let platform_ref = PlatformRef { + drive: &self.platform.drive, + state: &platform_state, + config: &self.platform.config, + core_rpc: &self.platform.core_rpc, + }; + let check_tx = self.platform.check_tx( + &serialized, + CheckTxLevel::FirstTimeCheck, + &platform_ref, + self.platform_version, + ); + + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + self.platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + Outcome { + check_tx, + processed: processing_result.into_execution_results().remove(0), + } + } + + fn stored_notes(&self) -> Vec { + let query = DriveDocumentQuery::from_sql_expr( + "select * from note", + &self.contract, + Some(&self.platform.config.drive), + self.platform_version, + ) + .expect("expected a document query"); + self.platform + .drive + .query_documents(query, None, false, None, None) + .expect("expected a query result") + .documents() + .to_vec() + } + } + + #[tokio::test] + async fn should_create_replace_and_delete_a_document_with_a_string_of_20000_characters() { + let mut fixture = NoteFixture::new(PlatformVersion::latest().protocol_version).await; + + let (note, entropy) = fixture.new_note("hello"); + fixture.create(note, entropy).await.assert_successful(); + let stored = fixture.stored_notes(); + assert_eq!(stored.len(), 1); + + fixture + .replace(&stored[0], "hello again") + .await + .assert_successful(); + let stored = fixture.stored_notes(); + assert_eq!( + stored[0].get("text"), + Some(&Value::Text("hello again".to_string())) + ); + + fixture.delete(&stored[0]).await.assert_successful(); + assert!(fixture.stored_notes().is_empty()); + } + + /// Protocol version 13 selects the estimated size generation that fails + /// on the bound, so each write still fails as an internal error there. + #[tokio::test] + async fn should_fail_writes_on_a_string_of_20000_characters_at_protocol_version_13() { + let mut fixture = NoteFixture::new(13).await; + + let (note, entropy) = fixture.new_note("hello"); + fixture + .create(note.clone(), entropy) + .await + .assert_size_overflow(); + assert!(fixture.stored_notes().is_empty()); + + fixture.seed(¬e); + let stored = fixture.stored_notes(); + assert_eq!(stored.len(), 1); + + fixture + .replace(&stored[0], "hello again") + .await + .assert_size_overflow(); + fixture.delete(&stored[0]).await.assert_size_overflow(); + assert_eq!(fixture.stored_notes(), stored); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index 28fb6c8511e..cda64f2f870 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -13,6 +13,7 @@ mod immutable; mod index_only; mod keep_history; mod list_element_reference; +mod long_string_sizing; mod lookup_reference; mod max_bytes; mod nft; From 55a05ae0d76a23a8f053a86dffdb42e224190aff Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 06:21:57 +0700 Subject: [PATCH 063/113] fix(drive-abci): finalize a block accepted in an earlier round after a later proposal was refused (#5081) Co-authored-by: Claude Opus 5.5 --- .../src/abci/handler/finalize_block.rs | 139 +++++++++- .../src/abci/handler/process_proposal.rs | 9 +- .../test_cases/vote_extension_round_tests.rs | 245 +++++++++++++++++- 3 files changed, 383 insertions(+), 10 deletions(-) diff --git a/packages/rs-drive-abci/src/abci/handler/finalize_block.rs b/packages/rs-drive-abci/src/abci/handler/finalize_block.rs index efaa5ffe996..48f7dbdbab9 100644 --- a/packages/rs-drive-abci/src/abci/handler/finalize_block.rs +++ b/packages/rs-drive-abci/src/abci/handler/finalize_block.rs @@ -1,7 +1,10 @@ +use super::process_proposal::execute_proposal; use crate::abci::app::{BlockExecutionApplication, PlatformApplication, TransactionalApplication}; +use crate::abci::AbciError; use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::block_execution_context::v0::BlockExecutionContextV0Getters; +use crate::execution::types::block_state_info::v0::BlockStateInfoV0Methods; use crate::metrics; #[cfg(debug_assertions)] use crate::perf::{self, PhaseTimer}; @@ -13,6 +16,111 @@ use std::sync::atomic::Ordering; use std::sync::Arc; use tenderdash_abci::proto::abci as proto; +/// Executes the block being finalized the way `ProcessProposal` executes a proposal, when the +/// block execution context this node holds is not that block's. Returns whether it had to. +/// +/// Tenderdash asks for a block to be processed again before committing it only when the round +/// state it kept is not that block's, and a proposal this node refused does not replace that +/// round state. The refused proposal of a later round has nevertheless replaced the transaction +/// of the block accepted in an earlier round, dropped or replaced its block execution context and +/// cleared the drive block caches its execution filled, so none of what finalizing that block +/// reads is left. Executing the block again rebuilds all of it on the path `ProcessProposal` +/// takes, from the request Tenderdash would have sent, so this node then holds what a node that +/// processed the block right before its commit holds. The app hash the execution gives is still +/// checked against the one in the block header before anything is committed. +fn execute_block_unless_current<'a, A, C>( + app: &A, + request: &proto::RequestFinalizeBlock, +) -> Result +where + A: PlatformApplication + TransactionalApplication<'a> + BlockExecutionApplication, + C: CoreRPCLike, +{ + let height = u64::try_from(request.height).map_err(|_| { + AbciError::BadRequest("height is negative in finalize block request".to_string()) + })?; + let round = u32::try_from(request.round).map_err(|_| { + AbciError::BadRequest("round is negative in finalize block request".to_string()) + })?; + + { + let block_execution_context_guard = app + .block_execution_context() + .read() + .expect("poisoned only after a panic, which stops the node"); + if let Some(block_execution_context) = block_execution_context_guard.as_ref() { + if block_execution_context + .block_state_info() + .matches_current_block(height, round, request.hash.clone())? + { + return Ok(false); + } + } + } + + tracing::warn!( + method = "finalize_block", + height, + round, + block_hash = hex::encode(&request.hash), + "the block execution context is not the one of the block being finalized; executing the block again before finalizing it", + ); + + let response = execute_proposal(app, process_proposal_request(request)?)?; + + if response.status != proto::response_process_proposal::ProposalStatus::Accept as i32 { + return Err(AbciError::WrongFinalizeBlockReceived(format!( + "the block being finalized at height {} round {}, block hash {}, is not valid on this node", + height, + round, + hex::encode(&request.hash), + )) + .into()); + } + + Ok(true) +} + +/// The `ProcessProposal` request for the block `request` finalizes, with the fields Tenderdash +/// fills when it processes a block before committing it: the block's own, the commit round, and +/// the quorum hash of the validator set that signed the commit. `proposed_last_commit` is left +/// out, since a block proposal does not read it. +fn process_proposal_request( + request: &proto::RequestFinalizeBlock, +) -> Result { + let block = request.block.as_ref().ok_or_else(|| { + AbciError::BadRequest("finalize block is missing actual block".to_string()) + })?; + let header = block.header.as_ref().ok_or_else(|| { + AbciError::BadRequest("finalize block is missing the block header".to_string()) + })?; + let commit = request + .commit + .as_ref() + .ok_or_else(|| AbciError::BadRequest("finalize block is missing commit".to_string()))?; + + Ok(proto::RequestProcessProposal { + txs: block + .data + .as_ref() + .map(|data| data.txs.clone()) + .unwrap_or_default(), + proposed_last_commit: None, + misbehavior: request.misbehavior.clone(), + hash: request.hash.clone(), + height: header.height, + round: request.round, + time: header.time, + next_validators_hash: header.next_validators_hash.clone(), + core_chain_locked_height: header.core_chain_locked_height, + core_chain_lock_update: block.core_chain_lock.clone(), + proposer_pro_tx_hash: header.proposer_pro_tx_hash.clone(), + proposed_app_version: header.proposed_app_version, + version: header.version, + quorum_hash: commit.quorum_hash.clone(), + }) +} + pub fn finalize_block<'a, A, C>( app: &A, request: proto::RequestFinalizeBlock, @@ -25,6 +133,13 @@ where #[cfg(debug_assertions)] let mut phases = PhaseTimer::new("finalize_block"); + // Before the transaction is read: executing the block replaces it + #[cfg_attr(not(debug_assertions), allow(unused_variables))] + let executed_again = execute_block_unless_current(app, &request)?; + + #[cfg(debug_assertions)] + phases.end_phase_if(executed_again, "execute_block_again"); + let transaction_guard = app.transaction().read().unwrap(); let transaction = transaction_guard @@ -740,9 +855,18 @@ mod tests { let app = FullAbciApplication::::new(&platform.platform); - // No transaction started, no block execution context + // The block execution context is the finalized block's, but no transaction was started + app.block_execution_context + .write() + .unwrap() + .replace(block_execution_context( + (**platform.state.load()).clone(), + 1, + 1_700_000_000_000, + None, + )); let request = proto::RequestFinalizeBlock { - hash: vec![0u8; 32], + hash: BLOCK_HASH.to_vec(), height: 1, round: 0, ..Default::default() @@ -758,8 +882,10 @@ mod tests { ); } + /// Without a block execution context the finalized block is executed again, which takes the + /// block the request carries. #[test] - fn finalize_block_fails_when_no_block_execution_context() { + fn finalize_block_without_a_block_execution_context_fails_when_the_request_has_no_block() { let platform = TestPlatformBuilder::new() .with_latest_protocol_version() .build_with_mock_rpc(); @@ -778,11 +904,8 @@ mod tests { let result = finalize_block::<_, MockCoreRPCLike>(&app, request); assert!( - matches!( - result, - Err(Error::Execution(ExecutionError::CorruptedCodeExecution(_))) - ), - "Expected CorruptedCodeExecution error, got: {result:?}" + matches!(result, Err(Error::Abci(AbciError::BadRequest(_)))), + "Expected BadRequest error, got: {result:?}" ); } diff --git a/packages/rs-drive-abci/src/abci/handler/process_proposal.rs b/packages/rs-drive-abci/src/abci/handler/process_proposal.rs index 6ae6809e619..0583f3c9677 100644 --- a/packages/rs-drive-abci/src/abci/handler/process_proposal.rs +++ b/packages/rs-drive-abci/src/abci/handler/process_proposal.rs @@ -144,7 +144,10 @@ where Ok(()) } -fn execute_proposal<'a, A, C>( +/// Executes the proposal `request` describes, unless the block execution context already holds +/// its result, and leaves the block execution context and the transaction of an accepted proposal +/// for `finalize_block`. `finalize_block` also calls it to execute a committed block again. +pub(super) fn execute_proposal<'a, A, C>( app: &A, request: proto::RequestProcessProposal, ) -> Result @@ -278,6 +281,10 @@ where } } + // Even when the proposal is refused below, the block accepted in an earlier round of this + // height loses its block execution context, its transaction and the drive block caches its + // execution filled. Tenderdash can still commit that block without asking for it to be + // processed again, and `finalize_block` then executes it again. if drop_block_execution_context { block_execution_context_guard.take(); } diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs index 432f9199176..a36e22923c2 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/vote_extension_round_tests.rs @@ -4,6 +4,10 @@ //! different withdrawal transactions. Precommits of the earlier round are still valid and can //! still arrive after this node processed the later proposal, so each vote must be verified //! against the block it is for. A vote for a block this node has not accepted is rejected. +//! +//! A block accepted in one round can also still be committed after this node refused a later +//! round's proposal, without Tenderdash processing it again, and it must be finalized as that +//! block. #[cfg(test)] mod tests { use crate::execution::run_chain_for_strategy; @@ -13,6 +17,8 @@ mod tests { use dpp::dashcore::hashes::Hash; use drive_abci::config::{PlatformConfig, PlatformTestConfig}; use drive_abci::execution::types::block_execution_context::v0::BlockExecutionContextV0Getters; + use drive_abci::execution::types::block_state_info::v0::BlockStateInfoV0Getters; + use drive_abci::mimic::CHAIN_ID; use drive_abci::platform_types::platform::Platform; use drive_abci::platform_types::platform_state::PlatformStateV0Methods; use drive_abci::rpc::core::MockCoreRPCLike; @@ -23,9 +29,13 @@ mod tests { use tenderdash_abci::proto::abci::response_process_proposal::ProposalStatus; use tenderdash_abci::proto::abci::response_verify_vote_extension::VerifyStatus; use tenderdash_abci::proto::abci::{ - ExtendVoteExtension, RequestExtendVote, RequestProcessProposal, RequestVerifyVoteExtension, + CommitInfo, ExtendVoteExtension, RequestExtendVote, RequestFinalizeBlock, + RequestProcessProposal, RequestVerifyVoteExtension, }; use tenderdash_abci::proto::google::protobuf::Timestamp; + use tenderdash_abci::proto::types::{ + Block, BlockId, Data, EvidenceList, Header, PartSetHeader, + }; use tenderdash_abci::proto::version::Consensus; use tenderdash_abci::proto::FromMillis; use tenderdash_abci::Application; @@ -178,6 +188,62 @@ mod tests { .vote_extensions } + /// The request Tenderdash finalizes `proposal` with once it is committed in its own round, + /// with `app_hash` in the block header. The commit carries no withdrawal signatures, so the + /// block must not have any withdrawal transactions to sign. + fn finalize_request( + proposal: &RequestProcessProposal, + app_hash: Vec, + ) -> RequestFinalizeBlock { + RequestFinalizeBlock { + commit: Some(CommitInfo { + round: proposal.round, + quorum_hash: proposal.quorum_hash.clone(), + block_signature: vec![0u8; 96], + threshold_vote_extensions: vec![], + }), + misbehavior: vec![], + hash: proposal.hash.clone(), + height: proposal.height, + round: proposal.round, + block: Some(Block { + header: Some(Header { + version: proposal.version, + chain_id: CHAIN_ID.to_string(), + height: proposal.height, + time: proposal.time, + last_block_id: None, + last_commit_hash: vec![], + data_hash: vec![0u8; 32], + validators_hash: proposal.quorum_hash.clone(), + next_validators_hash: proposal.quorum_hash.clone(), + consensus_hash: vec![0u8; 32], + next_consensus_hash: vec![0u8; 32], + app_hash, + results_hash: vec![0u8; 32], + evidence_hash: vec![], + proposed_app_version: proposal.proposed_app_version, + proposer_pro_tx_hash: proposal.proposer_pro_tx_hash.clone(), + core_chain_locked_height: proposal.core_chain_locked_height, + }), + data: Some(Data { + txs: proposal.txs.clone(), + }), + evidence: Some(EvidenceList { evidence: vec![] }), + last_commit: None, + core_chain_lock: proposal.core_chain_lock_update.clone(), + }), + block_id: Some(BlockId { + hash: proposal.hash.clone(), + part_set_header: Some(PartSetHeader { + total: 0, + hash: vec![0u8; 32], + }), + state_id: vec![0u8; 32], + }), + } + } + /// Whether this node signs a precommit for `proposal` fn signs(outcome: &ChainExecutionOutcome, proposal: &RequestProcessProposal) -> bool { outcome @@ -401,4 +467,181 @@ mod tests { "the rejected block must not be signed" ); } + + /// How a round 1 proposal is made unacceptable + #[derive(Clone, Copy, Debug)] + enum Refusal { + /// A protocol version this node does not run, refused before the block is executed + BeforeExecution, + /// Bytes that decode to no state transition, refused once the block was executed + AfterExecution, + } + + impl Refusal { + fn apply(self, proposal: &mut RequestProcessProposal) { + match self { + Refusal::BeforeExecution => { + proposal.version = Some(Consensus { + block: 0, + app: PlatformVersion::latest().protocol_version as u64 + 1, + }) + } + Refusal::AfterExecution => proposal.txs = vec![vec![0u8; 10]], + } + } + } + + /// The round of the block execution context this node holds, if it holds one + fn block_execution_context_round(outcome: &ChainExecutionOutcome) -> Option { + outcome + .abci_app + .block_execution_context + .read() + .unwrap() + .as_ref() + .map(|block_execution_context| block_execution_context.block_state_info().round()) + } + + /// The committed root hash of Drive + fn committed_root_hash(outcome: &ChainExecutionOutcome) -> [u8; 32] { + outcome + .abci_app + .platform + .drive + .grove + .root_hash(None, &PlatformVersion::latest().drive.grove_version) + .unwrap() + .expect("expected the committed root hash") + } + + /// Accepts the round 0 proposal of the next height and refuses the round 1 proposal made + /// unacceptable by `refusal`, returning the round 0 proposal and the app hash it was accepted + /// with. + fn accept_round_0_and_refuse_round_1( + outcome: &ChainExecutionOutcome, + refusal: Refusal, + ) -> (RequestProcessProposal, Vec) { + let core_height = outcome + .abci_app + .platform + .state + .load() + .last_committed_core_height(); + let round_0 = proposal(outcome, 0, core_height, ROUND_0_BLOCK); + let mut refused_round_1 = proposal(outcome, 1, core_height, ROUND_1_BLOCK); + refusal.apply(&mut refused_round_1); + + let response = outcome + .abci_app + .process_proposal(round_0.clone()) + .expect("expected to process the round 0 proposal"); + assert_eq!(response.status, ProposalStatus::Accept as i32); + let round_0_app_hash = response.app_hash; + + reject(outcome, &refused_round_1); + + let expected_context_round = match refusal { + Refusal::BeforeExecution => None, + Refusal::AfterExecution => Some(1), + }; + assert_eq!( + block_execution_context_round(outcome), + expected_context_round, + "test premise: the {refusal:?} refusal left the round 0 block without its block \ + execution context" + ); + + (round_0, round_0_app_hash) + } + + /// Tenderdash keeps the round state of the block it accepted when this node refuses a later + /// round's proposal, so it commits that block without asking this node to process it again. + /// The refused proposal has meanwhile replaced the accepted block's transaction and dropped + /// or replaced its block execution context, and the block must still be committed with the + /// app hash it was accepted with. + async fn should_finalize_the_round_0_block_after_round_1_is_refused(refusal: Refusal) { + let mut platform = TestPlatformBuilder::new() + .with_config(config()) + .build_with_mock_rpc(); + let outcome = run_chain(&mut platform).await; + + let (round_0, round_0_app_hash) = accept_round_0_and_refuse_round_1(&outcome, refusal); + + outcome + .abci_app + .finalize_block(finalize_request(&round_0, round_0_app_hash.clone())) + .expect("expected to finalize the round 0 block"); + + let platform_state = outcome.abci_app.platform.state.load(); + assert_eq!( + platform_state.last_committed_block_height(), + round_0.height as u64 + ); + assert_eq!( + platform_state + .last_committed_block_app_hash() + .map(Vec::from), + Some(round_0_app_hash.clone()) + ); + assert_eq!( + Vec::from(committed_root_hash(&outcome)), + round_0_app_hash, + "the committed state must be the one the round 0 block was accepted with" + ); + } + + #[tokio::test] + async fn should_finalize_a_block_accepted_in_an_earlier_round_after_a_later_proposal_was_refused_before_execution( + ) { + should_finalize_the_round_0_block_after_round_1_is_refused(Refusal::BeforeExecution).await; + } + + #[tokio::test] + async fn should_finalize_a_block_accepted_in_an_earlier_round_after_a_later_proposal_was_refused_after_execution( + ) { + should_finalize_the_round_0_block_after_round_1_is_refused(Refusal::AfterExecution).await; + } + + /// Finalizing a block this node no longer holds the execution of does not trust the app + /// hash in its header: when the block's execution gives a different one, nothing is + /// committed. + #[tokio::test] + async fn should_not_finalize_a_block_whose_header_app_hash_differs_from_its_execution() { + let mut platform = TestPlatformBuilder::new() + .with_config(config()) + .build_with_mock_rpc(); + let outcome = run_chain(&mut platform).await; + + let committed_height = outcome + .abci_app + .platform + .state + .load() + .last_committed_block_height(); + let root_hash_before = committed_root_hash(&outcome); + + let (round_0, round_0_app_hash) = + accept_round_0_and_refuse_round_1(&outcome, Refusal::AfterExecution); + let wrong_app_hash = vec![0xFF; 32]; + assert_ne!(round_0_app_hash, wrong_app_hash); + + let result = outcome + .abci_app + .finalize_block(finalize_request(&round_0, wrong_app_hash)); + assert!( + result.is_err(), + "a block whose execution gives another app hash must not be finalized" + ); + + assert_eq!( + outcome + .abci_app + .platform + .state + .load() + .last_committed_block_height(), + committed_height + ); + assert_eq!(committed_root_hash(&outcome), root_hash_before); + } } From d38592f82774434550d247dff1f5cd22751fb382 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 06:23:18 +0700 Subject: [PATCH 064/113] docs: remove committed working specs and plans (#5060) Co-authored-by: Claude Opus 5.5 --- .github/workflows/kotlin-sdk-build.yml | 1 - book/src/drive/document-count-trees.md | 4 + docs/dashpay/BLOCK_SPEC.md | 107 - docs/dashpay/CONTACTINFO_FORMAT_SPEC.md | 306 - .../DASHPAY_STATE_ENCAPSULATION_SPEC.md | 463 -- docs/dashpay/DIP15_INVITATIONS_SPEC.md | 835 --- docs/dashpay/DIP_CONFORMANCE_GAPS.md | 359 -- .../IDENTITY_KEY_SCALAR_ELIMINATION_SPEC.md | 493 -- docs/dashpay/INTEROP_DESK_CHECK.md | 49 +- docs/dashpay/KOTLIN_INVITATIONS_SPEC.md | 442 -- .../KOTLIN_MIGRATION_FOLLOWUPS_SPEC.md | 219 - docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md | 98 - docs/dashpay/KOTLIN_MIGRATION_SPEC.md | 441 -- docs/dashpay/MULTI_ACCOUNT_SPEC.md | 330 -- .../PENDING_CONTACT_CRYPTO_RELOCATION_SPEC.md | 188 - docs/dashpay/QA_TESTCASES_SPEC.md | 225 - docs/dashpay/QR_AUTO_ACCEPT_SPEC.md | 270 - docs/dashpay/SIGNER_SEED_ELIMINATION_SPEC.md | 666 --- docs/dashpay/SPEC.md | 1362 ----- docs/dashpay/SYNC_CORRECTNESS_SPEC.md | 441 -- docs/sdk/CODE_REVIEW_NOTES.md | 214 - docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md | 705 --- .../PR3999_WALLET_LIFECYCLE_HARDENING_SPEC.md | 652 -- packages/dashpay-contract/README.md | 5 + packages/kotlin-sdk/CLAUDE.md | 26 + .../kotlin-sdk/KotlinExampleApp/TEST_PLAN.md | 5 +- .../wallet/DashPayUnlockAndSyncTest.kt | 2 +- .../wallet/WalletManagerRoundTripTest.kt | 2 +- .../dashsdk/persistence/DashDatabase.kt | 7 + .../PERSISTENCE_REDESIGN.md | 1017 ---- packages/rs-platform-wallet/PLAN.md | 5247 ----------------- .../docs/DASHPAY_MIGRATION_PLAN.md | 257 - .../src/changeset/core_bridge.rs | 8 + .../src/changeset/traits.rs | 6 +- .../src/manager/identity_sync.rs | 13 +- .../rs-platform-wallet/src/wallet/apply.rs | 26 +- .../src/wallet/identity/crypto/auto_accept.rs | 7 +- .../src/wallet/identity/crypto/dip14.rs | 6 + .../src/wallet/identity/crypto/invitation.rs | 27 +- .../identity/network/contact_requests.rs | 54 +- .../src/wallet/identity/network/invitation.rs | 1 - .../src/wallet/identity/network/payments.rs | 8 +- .../src/wallet/identity/network/profile.rs | 11 + .../wallet/identity/types/dashpay/profile.rs | 6 + .../src/wallet/identity/types/key_storage.rs | 13 + packages/swift-sdk/CLAUDE.md | 16 + .../Core/Wallet/WalletStorage.swift | 11 + .../SwiftDashSDK/FFI/KeychainSigner.swift | 12 + .../Core/Services/ShieldedService.swift | 9 +- .../Core/Views/CoreContentView.swift | 3 +- .../docs/shielded-sync-timing-spec.md | 353 -- 51 files changed, 286 insertions(+), 15742 deletions(-) delete mode 100644 docs/dashpay/BLOCK_SPEC.md delete mode 100644 docs/dashpay/CONTACTINFO_FORMAT_SPEC.md delete mode 100644 docs/dashpay/DASHPAY_STATE_ENCAPSULATION_SPEC.md delete mode 100644 docs/dashpay/DIP15_INVITATIONS_SPEC.md delete mode 100644 docs/dashpay/DIP_CONFORMANCE_GAPS.md delete mode 100644 docs/dashpay/IDENTITY_KEY_SCALAR_ELIMINATION_SPEC.md delete mode 100644 docs/dashpay/KOTLIN_INVITATIONS_SPEC.md delete mode 100644 docs/dashpay/KOTLIN_MIGRATION_FOLLOWUPS_SPEC.md delete mode 100644 docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md delete mode 100644 docs/dashpay/KOTLIN_MIGRATION_SPEC.md delete mode 100644 docs/dashpay/MULTI_ACCOUNT_SPEC.md delete mode 100644 docs/dashpay/PENDING_CONTACT_CRYPTO_RELOCATION_SPEC.md delete mode 100644 docs/dashpay/QA_TESTCASES_SPEC.md delete mode 100644 docs/dashpay/QR_AUTO_ACCEPT_SPEC.md delete mode 100644 docs/dashpay/SIGNER_SEED_ELIMINATION_SPEC.md delete mode 100644 docs/dashpay/SPEC.md delete mode 100644 docs/dashpay/SYNC_CORRECTNESS_SPEC.md delete mode 100644 docs/sdk/CODE_REVIEW_NOTES.md delete mode 100644 docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md delete mode 100644 docs/sdk/PR3999_WALLET_LIFECYCLE_HARDENING_SPEC.md delete mode 100644 packages/rs-platform-wallet/PERSISTENCE_REDESIGN.md delete mode 100644 packages/rs-platform-wallet/PLAN.md delete mode 100644 packages/rs-platform-wallet/docs/DASHPAY_MIGRATION_PLAN.md delete mode 100644 packages/swift-sdk/SwiftExampleApp/docs/shielded-sync-timing-spec.md diff --git a/.github/workflows/kotlin-sdk-build.yml b/.github/workflows/kotlin-sdk-build.yml index a6f6fe1c6ff..f1fc725e270 100644 --- a/.github/workflows/kotlin-sdk-build.yml +++ b/.github/workflows/kotlin-sdk-build.yml @@ -18,7 +18,6 @@ on: - 'packages/*-contract/**' - 'packages/simple-signer/**' - 'docs/sdk/sdk-parity-manifest.json' - - 'docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md' - 'packages/kotlin-sdk/PARITY_SUMMARY.md' - 'scripts/check_sdk_parity_manifest.py' - 'scripts/tests/**' diff --git a/book/src/drive/document-count-trees.md b/book/src/drive/document-count-trees.md index 2a6ac5b1415..51b9717e985 100644 --- a/book/src/drive/document-count-trees.md +++ b/book/src/drive/document-count-trees.md @@ -393,6 +393,10 @@ A few notes about the index-level flag: A migration check from `dapi-grpc` server logic: every count query requires either `documentsCountable: true` (for unfiltered totals) or a `countable: true` / `rangeCountable: true` index whose properties **exactly match** the query's where-clause fields. No covering index → the call returns a clear `InvalidArgument` describing what the picker was looking for ("requires a `range_countable: true` index whose last property matches the range field" for range queries, "requires a countable index whose properties exactly match the where clause fields" for Equal/In queries). Pick your indexes deliberately at contract creation time — per-index `countable: true` / `rangeCountable: true` flags can't be added later (contract indexes are immutable post-creation). +### Counts Are Public + +Anyone can run a count query and verify its proof, so a countable index publishes everything its counts reveal. Encrypting a document's fields does not hide its existence or its indexed values. A countable index keyed first by a recipient and then by the document owner answers "who sent documents to this recipient, and how many" for every recipient. On the DashPay `contactRequest` type, a countable `[toUserId, $ownerId]` index would publish every user's inbound contacts. If a UI only needs a badge, a countable index on the recipient alone (`["toUserId"]`) reveals a total and no per-sender edges. + ## SDK Access at Three Layers ### `rs-sdk` (native Rust) diff --git a/docs/dashpay/BLOCK_SPEC.md b/docs/dashpay/BLOCK_SPEC.md deleted file mode 100644 index 9770ea01b13..00000000000 --- a/docs/dashpay/BLOCK_SPEC.md +++ /dev/null @@ -1,107 +0,0 @@ -# DashPay ignore — cross-device design + DoS / social-graph-leak analysis - -Status: the single-device **"Block sender"** design this file originally carried was -**superseded** by the shipped local-only **Ignore** (per-sender, reversible; -`ignored_senders`, `ignore_sender`/`unignore_sender`, applied in `changeset/apply.rs`). -That feature is documented in `SPEC.md` (G5) and `SYNC_CORRECTNESS_SPEC.md`; the -single-device Block design — its state/persistence/UI/test plan and the review -resolutions specific to it — is no longer reproduced here. - -This file is retained **only** for the two forward-looking pieces Ignore does not yet -cover: - -- **(a) Cross-device ignore** — how to make ignore sync across a user's devices via a - single owner-scoped, self-encrypted blocklist, and the privacy reason it is **not** - carried via `contactInfo` (§1). -- **(b) DoS / social-graph-leak analysis** — the fetch-cost / flood analysis and the - countable-index social-graph leak that any query-level DoS filter must avoid (§2). - -Owner: platform-wallet / swift-sdk. -Relates to: `SPEC.md` (G5, the shipped Ignore), `SYNC_CORRECTNESS_SPEC.md`, -`CONTACTINFO_FORMAT_SPEC.md`. - ---- - -## 1. Cross-device ignore — a self-encrypted blocklist, NOT `contactInfo` - -Ignore is per-device local state today; you re-ignore a sender on each device. Making -it sync is a **future** item on the contract / governance track — not built. - -**Why not `contactInfo`.** The tempting reuse — carry an ignore flag in the -`contactInfo.privateData` blob we already sync — **breaks the DIP-15 ≥2-contacts -unlinkability gate** and is rejected. An ignore targets a *non-established* sender, so -carrying it would mean creating a `contactInfo` **about a non-contact**. That document's -public existence + `$createdAt` correlates with the inbound `contactRequest` (via the -public `userIdCreatedAt` index) to re-identify *who* you ignored: `encToUserId` is -encrypted so *who* is hidden, but the doc's *existence/count* is not. The -"`displayHidden` is precedent" argument is a false equivalence — `displayHidden` rides a -document that exists anyway (an established contact), whereas an ignore-of-a-non-contact -*creates* the leaking document. It is also mechanically blocked today: -`set_contact_info_with_external_signer` → `set_contact_metadata` hard-requires an -established contact, and the apply side drops non-established `contactInfo`. - -**The design if it ships.** A **single owner-scoped, self-encrypted blocklist -document** — one document the owner encrypts to themselves (same key family as -`contactInfo`'s `privateData`, but a single owner-private list, not per-contact and not -gated by the 2-contact rule). Every device reads and applies it, so ignore (and -optionally decline) apply everywhere. Costs: each edit is a document write (credits); -it reveals only *that* a blocklist exists plus an edit count — **not** one document per -ignored victim. Its update timing should be conflated with normal profile edits so it -does not leak the per-sender existence/count. This is a contract change on the later -governance track, and the metadata-leak analysis above must be settled before building -it. - -## 2. DoS / spam, and the social-graph leak a countable index would create - -### 2.1 Fetch model, and why an ignore can't cut fetch cost - -The received-request query is keyed by recipient: - -``` -where toUserId == me, order_by $createdAt, limit: 100 -``` - -An ignore is a **local read-filter applied after fetch**: the index has no -`sender NOT IN (…)` axis and Sybil senders are unpredictable, so an ignore cannot avoid -the fetch + GroveDB proof-verify cost of an incoming request — it only hides it once -fetched. - -**Threat: a sender (or a funded Sybil swarm) creates many requests.** Invalid ones are -the worst — they fail parse/validation but still cost fetch + proof-verify + parse. The -only built-in deterrent is **economic**: each `contactRequest` costs the sender -platform credits. Spam isn't free, but it isn't prevented. A naive `limit: 100, -start: None` re-fetch also lets a flood of ≥100 junk requests **bury** legitimate ones -past the first page. - -**Mitigation — incremental fetch (high-water).** Track the newest `$createdAt` seen per -identity and query `WHERE toUserId == me AND $createdAt > high_water`, paginating -forward: each request is fetched exactly once, pagination can't bury legit requests past -100, and ignore/decline become one-time-on-first-sight. This bounds steady-state work to -O(new requests per sweep). It does **not** stop the *first* fetch of a request from a new -sender (impossible without server-side sender exclusion), but nothing in the protocol -can. The existing `userIdCreatedAt` index `[toUserId, $createdAt]` already serves this -range-after-equality query, so incremental fetch needs **no** contract change. *(This -high-water incremental fetch has since shipped — see `SYNC_CORRECTNESS_SPEC.md` and the -DIP-15 §8.8/§8.12 row in `DIP_CONFORMANCE_GAPS.md`.)* - -### 2.2 The trap: a countable `[toUserId, $ownerId]` index leaks the inbound social graph - -A natural-looking next step is a **countable** index on the recipient→sender axis so the -wallet can answer "how many pending requests do I have" / "is one sender flooding me" -from a count proof **without fetching documents**: - -``` -byRecipientSender = [{ toUserId: asc }, { $ownerId: asc }] // countable — DO NOT ship as drafted -``` - -(The `$ownerId` of a `contactRequest` *is* the sender.) This is a **social-graph leak** -and must not ship in that form. Platform count / group-by proofs are **public, not -recipient-private**, and return cleartext `{sender_id → count}`. A countable -`[toUserId, $ownerId]` therefore lets *anyone* scrape "who contacted recipient R, with -counts" in O(log n) — the inbound social graph, in the clear. - -**Resolution:** drop the per-sender `GROUP BY $ownerId` axis. At most keep an aggregate -`COUNT(*) WHERE toUserId == me` (a single number, for a pending-request badge), which -reveals only a total and not per-sender edges. Any real query-level DoS filter that -excludes ignored/rejected senders *before* fetching is a contract change (DIP / -maintainer coordination), and this graph-exposure analysis must be carried into that DIP. diff --git a/docs/dashpay/CONTACTINFO_FORMAT_SPEC.md b/docs/dashpay/CONTACTINFO_FORMAT_SPEC.md deleted file mode 100644 index 0ea08e454cc..00000000000 --- a/docs/dashpay/CONTACTINFO_FORMAT_SPEC.md +++ /dev/null @@ -1,306 +0,0 @@ -# contactInfo `privateData` — DIP-15 varint format (migrate off CBOR) - -Status: **IMPLEMENTED** (2026-06-18) — DIP-15 varint codec in `crypto/contact_info.rs`, -byte-vector + compat tests. (The tolerant minor-version decode stays available for a -future additive field, but **ignore state does NOT ride contactInfo** — R1 found -that leaks who you ignored; ignore is local-only, cross-device via a future encrypted -profile field. See the R1 item in the backlog, dashpay/platform#4020.) -Owner: platform-wallet / platform-encryption -Relates to: Spec 2 (Ignore, adds `relationshipState`), `BLOCK_SPEC.md`, -`CONTACTINFO_FORMAT_SPEC.md` Appendix A. - -## Format decision: DIP-15 varint, NOT CBOR (2026-06-18) - -`contactInfo.privateData` is an **opaque encrypted byteArray** — the registered -contract validates only its **length** (`byteArray:true, minItems:48, -maxItems:2048` in `dashpay.schema.json`); the field description's "…encoded as an -array in cbor" is **advisory documentation, not a structural constraint**. The -plaintext inside the AES-256-CBC ciphertext is therefore a writer/reader -convention we are free to choose — and we choose **DIP-15**, the authoritative -protocol spec, so we interop with DIP-15-compliant clients (the reference -`dash-wallet` / `kotlin-platform` will follow the DIP when it implements -contactInfo). **No contract change is needed** (length-only validation accepts any -48..2048-byte ciphertext). No client decodes contactInfo today, so this is a free -window: we set the de-facto format and it matches the DIP. - -> An earlier pass briefly "reconciled" this the other way (keep CBOR, per the -> schema *description* + `CONTACTINFO_FORMAT_SPEC.md` Appendix A). That over-weighted an advisory -> description as binding. Corrected: the contract enforces length only, DIP-15 is -> authoritative — use varint. - -This is **Spec 1** of the DashPay-privacy track. (The minor-version forward-compat -seam — §3 — remains for any future additive field. Note: ignore state is **not** -carried here — R1 found a per-sender `contactInfo` leaks who you ignored, so ignore -is local-only with cross-device deferred to a future encrypted `profile` field.) - ---- - -## 1. Problem - -Our `contactInfo.privateData` codec (`crypto/contact_info.rs::encode/decode_private_data`) -emits a **CBOR array** `[aliasName, note, displayHidden, padding?]`. **DIP-15 -defines a different format** (verified against `github.com/dashpay/dips/dip-0015.md`, -§"Contact Info" / §"Encrypting Private Data"): - -- **Serialization:** "the private data should be serialized in the same way as - done for **Dash message data**" (dip-0015.md:811) — i.e. the Bitcoin/Dash - protocol binary format (var-int-length-prefixed strings/arrays), **not CBOR**. -- **Fields (v0), in order:** - | # | Field | Type | Encoding | - |---|-------|------|----------| - | 0 | `version` | uInt32 | `major << 16 \| minor` (dip-0015.md:771) | - | 1 | `aliasName` | String | var-int length + UTF-8 | - | 2 | `note` | String | var-int length + UTF-8 | - | 3 | `displayHidden` | uInt8 | 1 byte | - | 4 | `acceptedAccounts` | array | var-int count + u32s (dip-0015.md:805) | -- **Crypto:** AES-256-CBC with the `rootEncryptionKey/(2^16+1)'/idx'` derived key - (we already do this — only the *plaintext serialization* changes). - -**Our gaps vs DIP-15:** (a) CBOR instead of Dash-message varint; (b) **no -`version` field**; (c) **no `acceptedAccounts`**. - -**Why now (the one cheap window):** verified 2026-06 that **no client decodes -`contactInfo.privateData` today** — `android-dashpay` has no `ContactInfo` class -(the schema is bundled as JSON only); `dash-wallet` has only a `// TODO: choose -the contactRequest based on the ContactInfo.accountRef value`. So there is **no -reader to break.** When `dash-wallet` implements its TODO it will follow DIP-15 -(varint), not our CBOR — so if we don't align now, the two clients won't interop. -We're the only writer; fix the wire format while it's free. - -## 2. Goal - -- Replace the CBOR codec with the **DIP-15 Dash-message varint** serialization, - including the `version` field and `acceptedAccounts`. -- Adopt DIP-15's **major/minor version forward-compat** model. -- **Define** (not yet populate) `reject` / `block` fields as a **minor-version - extension**, so a later spec can sync reject/block via contactInfo without - another format change. -- No behavior change to alias/note/hidden; pure wire-format + versioning. - -## 3. DIP-15 versioning model (verbatim, because it drives everything) - -dip-0015.md:771-776: `version = major << 16 | minor`. -- **Major** change = **incompatible**: a client that doesn't understand the major - version **discards the whole contactInfo**. -- **Minor** change = "most likely additional fields": an un-updated client - "should still be able to parse the first fields … and **ignore data past the - final field known in the version**." - -Consequence (this answers the "won't old clients break?" question): **adding -`reject`/`block` is a MINOR bump** → a DIP-15-v0 reader parses `version … -acceptedAccounts` and ignores our trailing fields. **No breakage.** Only a major -bump locks old readers out. So: -- our baseline = **major 0, minor 0** (DIP-15 v0 fields exactly); -- our reject/block extension = **major 0, minor 1** (appended fields); -- decoders MUST be **tolerant**: read the fields the known minor defines, ignore - trailing bytes; on an unknown **major**, discard. - -## 4. The reject/block fields — DEFINED but NOT ADOPTED (R1, resolved 2026-06-18) - -> **Resolution.** This field was the proposed cross-device carrier for -> reject/block. It is **not implemented** and is **not part of the shipped DIP-15 -> codec** (which carries only `aliasName` / `note` / `displayHidden` / -> `acceptedAccounts`). Per R1, a `contactInfo` about a *non-established* sender -> leaks *who* you ignored (the timing-correlation argument below), so **Ignore is -> local-only** (Spec 2) and cross-device sync is deferred to a future **encrypted -> field on the `profile` document** (contract / governance track) — NOT to -> `contactInfo`. The design below is retained for reference only. - -Appended after `acceptedAccounts`, present from **minor 1** (design only — unused): - -| # | Field | Type | Meaning | -|---|-------|------|---------| -| 5 | `relationshipState` | uInt8 | 0 = active, 1 = declined, 2 = blocked (extensible) | - -Rationale for a single `relationshipState` byte over two bools: declined/blocked -are mutually-exclusive states of one relationship; one enum is smaller, avoids -the "both set" ambiguity, and extends cleanly (e.g. 3 = muted). `displayHidden` -(field 3) stays as-is for backward DIP-15 compat; `relationshipState` is the -richer superset we read first when present. - -**Scope boundary (critical):** this spec only **defined** the field + its -encoding. *Whether and how* a `contactInfo` is created to carry it — especially -for a **non-established** declined/blocked sender — was the **privacy question -(R1 from the block review)**, **RESOLVED (2026-06-18): not via `contactInfo` at -all.** Ignore is local-only (Spec 2); cross-device goes through an encrypted -`profile` field later. Kept for context: - -> A `contactInfo` *about a non-contact* is a brand-new on-chain document whose -> existence + `$createdAt` can be timing-correlated with the inbound -> `contactRequest` (public `userIdCreatedAt` index) to re-identify *who* you -> blocked — even though `encToUserId` is encrypted, and the ≥2-contacts gate -> (dip-0015.md:697-699) can't cover a non-contact. **Spec 2 resolved this: a -> per-sender `contactInfo` is leaky (above) and even a single owner-scoped list -> on `contactInfo` still signals "an ignore happened", so ignore is kept -> local-only and cross-device is deferred to an encrypted `profile` field whose -> update timing is conflated with ordinary profile edits.** This format spec is agnostic to that -> choice — it just provides the field. - -## 5. Padding / 48-byte floor - -The contract validates `privateData` at **48–2048 bytes** (dip-0015.md:727). -Our CBOR codec appends a padding element to reach 48; the Dash-message format has -no field for that, and trailing padding would collide with the "ignore data past -the final known field" rule (a future reader could mis-read padding as a higher- -minor field). **Decision needed (Q-pad):** -- (a) Pad the **ciphertext region** only — encode the exact fields, then rely on a - reserved **`padding` length-prefixed byte field** placed *last in every minor* - and documented as "ignore"; or -- (b) define the floor purely as an encryption-layer concern (pad plaintext to ≥ - the size that yields 48-byte ciphertext, inside AES-CBC, with the pad length - recoverable) so the field stream itself carries no padding. -Recommend **(a) an explicit trailing `padding` var-bytes field** that is *always -the final field* and always skipped — it's self-describing (var-int length) so -"ignore trailing" still works, and it's the closest analog to today's behavior. - -## 6. Migration / compatibility of *existing* data - -contactInfo docs are immutable on-chain. Any docs **we** already wrote (CBOR, -no version field) become unreadable by the new varint decoder. -- DashPay is **pre-release** (not on mainnet); existing CBOR docs are - testnet/devnet UAT artifacts → **acceptable to abandon** (they'll simply fail - to decode and be skipped, same as a foreign-root doc today). -- Do **not** build a CBOR↔varint dual-reader unless we find we must preserve - specific test data. (Open question Q-dual.) -- The **local** SwiftData/SQLite mirror is rebuilt from chain on sync, so no - local migration is needed beyond the decoder swap. - -## 7. Implementation surface - -- `packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs`: - rewrite `encode_private_data` / `decode_private_data` to the Dash-message varint - format (var-int string/array helpers; `version` first; tolerant decode that - stops at the known-minor field count and skips trailing). Keep the AES-CBC layer. -- `ContactInfoPrivateData` struct: add `version: u32` (or major/minor accessors), - `accepted_accounts: Vec`, and `relationship_state: u8` (minor ≥ 1). - `displayHidden` stays. (Note: the in-memory struct already flows through - `set_contact_metadata(ContactInfoPrivateData)` after the recent refactor.) -- No FFI/Swift signature change (privateData is opaque bytes across the boundary); - only the bytes' internal layout changes. - -## 8. Test plan - -- **Round-trip:** encode→decode every field incl. empty/None strings, empty and - non-empty `acceptedAccounts`, `relationshipState` 0/1/2. -- **Forward-compat:** a **minor-0** decoder reading **minor-1** bytes parses - v0 fields and ignores `relationshipState` (the DIP-15 guarantee) — pin it. -- **Major-incompat:** a decoder reading an unknown **major** discards (returns - None / skips), not a partial parse. -- **Vector:** if any DIP-15 / reference test vector for privateData exists, match - it byte-for-byte (none found in dashj/android-dashpay; we may be authoring the - first — note that). -- **Floor:** encoded output is ≥ 48 bytes after padding (Q-pad), ≤ 2048. - -## 9. Open decisions - -- **Q-pad** — explicit trailing `padding` field (recommended) vs encryption-layer - padding. -- **Q-dual** — abandon existing CBOR docs (recommended, pre-release) vs build a - CBOR/varint dual-reader. -- **Q-state** — single `relationshipState: uInt8` (recommended) vs separate - `declined`/`blocked` flags. -- **Q-minor-now** — define `relationshipState` (minor 1) in *this* spec/PR, or - ship the pure DIP-15-v0 alignment first (minor 0) and add the field in Spec 2? - (Leaning: ship v0 alignment here; add the field in Spec 2 where it's used — - keeps this PR a clean wire-format fix.) - ---- - - - -## Appendix A — contactInfo wire conventions (research, 2026-06-12) - -Source-verified findings for implementing the DashPay `contactInfo` -document (M3 task 13). Full citations at the bottom. - -### Decisive finding: no reference client implements contactInfo - -DashSync-iOS (`DSBlockchainIdentity.m` + Identity models), dashj / -android-dpp / kotlin-platform, and dash-shared-core contain **zero** -contactInfo creation or encryption code. DIP-15 + the deployed -dashpay-contract schema are the only authoritative sources, and **this -repo's implementation sets the de-facto wire convention.** There is no -cross-client byte-compatibility constraint — only self-consistency and -schema validity. - -### Conventions adopted (CONFIRMED unless marked INFERRED) - -#### Key derivation (DIP-15) - -The "root encryption key" is the identity's **registered ENCRYPTION -key** (DIP-11 purpose 1); `rootEncryptionKeyIndex` is that key's id on -the identity. Two child keys are derived from its extended form in the -owner's HD tree (hardened CKDpriv): - -```text -encToUserId key: rootEncryptionKey / 65536' / derivationEncryptionKeyIndex' (2^16) -privateData key: rootEncryptionKey / 65537' / derivationEncryptionKeyIndex' (2^16 + 1) -``` - -The 2^16 offset is DIP-15's explicit "discount other potential -derivations" choice. The AES-256 key is the raw 32-byte child private -key scalar (INFERRED — no hash step is specified anywhere; matches how -contactRequest ECDH consumes key material). - -`derivationEncryptionKeyIndex` is sequential per `$ownerId` starting at -0 (one per contactInfo document; the unique index is -`($ownerId, rootEncryptionKeyIndex, derivationEncryptionKeyIndex)`). - -#### encToUserId (DIP-15, verbatim justification in the DIP) - -`AES-256-ECB(toUserId)` — exactly 32 bytes = two blocks, **no IV, no -padding**. ECB is sound here because the plaintext is itself a SHA-256 -output and the key is never reused for other purposes. - -#### privateData - -> **CORRECTION (2026-06-18): use DIP-15 varint, not CBOR.** The conclusion -> below ("the deployed schema description wins → CBOR") over-weighted an -> advisory note. The contract validates `privateData` by **length only** -> (`byteArray`, 48–2048); its "array in cbor" text is documentation, NOT an -> enforced structural constraint. The encrypted plaintext format is a free -> writer/reader convention, so we follow **DIP-15** (the authoritative protocol -> spec) with `version`/varstr/`acceptedAccounts`. See the spec above. - -`IV(16) ‖ AES-256-CBC(plaintext)` — IV prepended (INFERRED from the -`encryptedPublicKey` convention; DIP-15 doesn't state placement for -this field). - -Plaintext (~~CBOR~~ → **DIP-15 varint**, per the correction above): the original -analysis adopted a **CBOR array `[aliasName, note, displayHidden]`** per the -deployed schema's field description — positional, with CBOR `null` for absent -strings (INFERRED). DIP-15 prose instead describes Bitcoin-varint "Dash message -data" with `version` + `acceptedAccounts` — and that is what we now use (the -schema enforces length only, so there's no conflict and no contract change). - -#### Privacy rule (DIP-15, spec-only — no client enforces it today) - -> "A client should not transmit a contact info document for a user to -> the network until that user has at least two established contacts." - -Enforced at the publish gate: with <2 established contacts the local -state still updates; the document write is deferred until the rule is -satisfied. - -### Discrepancy table (DIP-15 prose vs deployed schema) - -| Question | DIP-15 prose | Deployed schema | -|---|---|---| -| Plaintext format | Bitcoin varint stream | CBOR array | -| Fields | version, aliasName, note, displayHidden, acceptedAccounts | aliasName, note, displayHidden | -| version | uInt32 present | absent | -| acceptedAccounts | array of uInt32 | absent | - -### Sources - -- DIP-0015 (dashpay/dips) — derivation offsets, ECB/CBC modes, privacy rule -- dashpay-contract `schema/v1/dashpay.schema.json` — CBOR-array description, - unique index, 48–2048B bounds -- DIP-0011 (key purposes), DIP-0013 (identity key paths), DIP-0009 - assignments (15'/16' are incoming-funds / auto-accept — no contactInfo path) -- dashsync-iOS Identity models, android-dpp, kotlin-platform, - dash-shared-core — checked: no contactInfo implementation anywhere -- rs-dpp `lib.rs` `RootEncryptionKeyIndex` / `DerivationEncryptionKeyIndex` - type aliases diff --git a/docs/dashpay/DASHPAY_STATE_ENCAPSULATION_SPEC.md b/docs/dashpay/DASHPAY_STATE_ENCAPSULATION_SPEC.md deleted file mode 100644 index 072b95d1024..00000000000 --- a/docs/dashpay/DASHPAY_STATE_ENCAPSULATION_SPEC.md +++ /dev/null @@ -1,463 +0,0 @@ -# DashPay State Encapsulation — extract `ManagedIdentity`'s DashPay fields into `DashPayState` - -Status: draft, rev 2 (review must-fixes folded) -Scope: `packages/rs-platform-wallet` (+ mechanical re-paths in `rs-platform-wallet-ffi`; -test-helper construction in `rs-platform-wallet-storage`). No on-disk format change, no FFI -ABI change, no Swift change. Follow-up to PR #3841 — lands as its own PR after #3841 merges. - -Origin: review question on PR #3841 — "Having dashpay stuff right in identity wallet and -identity manager and managed identity is mixing too much different stuff… shall we have a -separate dashpaywallet and somehow encapsulate dashpay stuff from common identity things?" - -> **Review outcome (rev 2).** Four independent reviewers (feasibility, scope, adversarial -> failure-modes, Rust/domain-fit) audited rev 1 against the code. Scope verdict: -> right-sized; two-tier design and two-commit staging earn their keep. The load-bearing -> corrections folded here: **(MF-1)** rev 1 undercounted the FFI cold-load restore path — -> it raw-writes **six** DashPay fields including all three relationship maps -> (`ffi/persistence.rs:4064/4067/4070`), so the relationship-map `apply_*` methods must be -> `pub`, not `pub(crate)`, and the boundary claim is recalibrated from "sealed" to -> "raw writes impossible; invariant-bypassing writes are named `apply_*` and auditable". -> **(MF-2)** a `pub dashpay` field is bypassable by whole-value replacement -> (`managed.dashpay = Default::default()` / `mem::take` compiles anywhere and silently wipes -> the high-water cursors) — the field itself is now private with a `dashpay()` borrow -> getter and per-field Tier B `_mut` accessors, and **no** whole-struct `dashpay_mut()`. -> **(MF-3)** rev 1 answered only one of the three sites the origin comment names; §0 now -> maps the design to all three honestly, and an optional facade-level `DashPayView` -> (zero-cost borrowing namespace — materially different from the twice-reverted owned -> facade) is added as decision point Q1. Plus: getters live on `DashPayState` itself; -> `dashpay_` field-name stutter dropped; `pub(super)` replaces the long scoped-visibility -> path; the in-crate test-fixture raw writes are inventoried and budgeted (§5); D4's method -> doc keeps the caller-side half of the cursor contract explicit. - ---- - -## 0. What the origin comment names, and what this spec covers - -The PR comment names three sites. Honest mapping: - -1. **`ManagedIdentity` (state)** — the worst offender and **this spec's target**: 12 of 19 - fields are DashPay social state, all `pub`, invariants enforceable only by convention. -2. **`IdentityWallet` (network facade)** — the *largest* mixing by volume (~10.2k lines of - DashPay ops vs ~6.6k identity-core across the `network/` impl files), but already - file-split by concern with documented layering (`network/mod.rs`). Two owned-facade - splits were tried and deliberately reverted on this branch (§4.1). What this spec offers - there is the optional zero-cost `DashPayView` namespace (§3 D8, decision Q1) — call-site - visibility without a second handle. -3. **`IdentityManager` / manager layer** — already clean: the manager holds buckets + a - location index with no DashPay logic; DashPay sync orchestration is already its own - coordinator (`manager/dashpay_sync.rs`). No change proposed. - -## 1. Problem - -`ManagedIdentity` (`src/wallet/identity/state/managed_identity/mod.rs`) mixes two concerns -in one flat, fully-`pub` struct: - -- **Identity-core**: `identity`, `identity_index`, `wallet_id`, `status`, `dpns_names`, - `contested_dpns_names`, two sync block-times. -- **DashPay social state — 12 fields**: `established_contacts`, `sent_contact_requests`, - `incoming_contact_requests`, `ignored_senders`, `auto_accept_verify_failed`, - `dashpay_rescan_triggered`, `dashpay_profile`, `dashpay_payments`, `contact_profiles`, - `high_water_received_ms`, `high_water_sent_ms`, `pending_contact_crypto`. - -Three concrete costs today (all verified against the code): - -1. **No boundary.** Nothing in the type answers "what is DashPay vs identity-core"; the - distinction lives in field-comment prose. Any future extraction (or reasoning about one) - starts from zero. -2. **Invariants are bypassable — and one already lives outside the state layer.** All fields - are `pub`, so the auto-establish invariant (reciprocal request ⇒ established contact), the - `AUTO_ACCEPT_VERIFY_FAILED_CAP` eviction, and the ignore-emits-both changeset rule are - upheld only by the convention of calling the right method. Worse, **high-water cursor - monotonicity is enforced nowhere in the state layer**: the compare-and-advance rule - (`advance_if_unchanged`, `network/contact_requests.rs:822`) is a free function in network - code writing the fields raw (`:1363`, `:1370`). A miss reintroduces the lost-unignore bug - that function's doc-comment describes. -3. **The FFI crate reads 9 public fields directly** inside handle closures - (`ffi/src/dashpay_profile.rs:150` et al.) and **raw-writes six fields on the cold-load - restore path**: `ignored_senders` (`ffi/persistence.rs:3758`), `dashpay_payments` - (`:3806`), `contact_profiles` (`:3900`), and — via `apply_contact_rows` - (`:3961-4077`, production load path) — all three relationship maps (`:4064/:4067/:4070`). - The compiler offers no help distinguishing "legit restore write" from "invariant bypass". - -## 2. Current architecture (facts the design relies on) - -From a three-agent inventory of the state layer, all access sites, and the persistence -coupling, plus four review passes re-verifying the cites: - -- **The core↔DashPay coupling is narrow.** No method in - `state/managed_identity/{contacts,contact_requests,identity_ops,sync}.rs` mutates both an - identity-core field and a DashPay field in the same call. Coupling is exactly: (a) - `snapshot_changeset()` → `IdentityEntry::from_managed` (`changeset/changeset.rs:332-349`) - reads 4 DashPay scalar fields on every core-field persist; (b) the two constructors - initialize both groups; (c) `disable_keys` reads `wallet_id`/`identity_index` alongside a - snapshot. DashPay mutators read only the immutable `self.id()`. -- **Mutation methods are already the norm.** Network code calls state-mutation methods 53×; - direct production field writes outside the owner module: ~11 in `network/` (high-water ×2, - `dashpay_rescan_triggered` ×1, `established_contacts.get_mut` ×3, - `set_dashpay_profile`/`mark_auto_accept_verify_failed` pass-throughs, `contact_profiles` - ×1), 12 in `state/manager/apply.rs` (changeset replay), 9 in `wallet/apply.rs` (changeset - replay), and **9 in the FFI crate** (6 restore-path raw writes + 3 via methods). Test - fixtures add raw writes in `wallet/apply.rs:562-567/:1348-1351`, `network/` test modules, - `ffi/tests/test_data/mod.rs:283-286/:304`, and `contact_workflow_tests.rs:289` - (`established_contacts.get_mut`). -- **Persistence never serializes `ManagedIdentity` itself** — it derives `Debug, Clone` only. - Three persistence tiers cover the 12 fields: - - `IdentityEntry` scalar snapshot (serde-derived flat struct, `changeset.rs:270-323`): - `dashpay_profile` (merge: LWW), `dashpay_payments` (extend, LWW per txid), - `contact_profiles` (extend, LWW per contact), `ignored_senders` (union). - - `ContactChangeSet` (`changeset.rs:634-663`): the three relationship maps + the - `ignored`/`unignored` tombstone pair. - - `PlatformWalletChangeSet.pending_contact_crypto_{added,cleared}` (`changeset.rs:1189/1193`) - for the deferred-crypto queue. - - **In-memory only, never persisted**: `dashpay_rescan_triggered`, - `auto_accept_verify_failed`, `high_water_received_ms`, `high_water_sent_ms`. -- **The FFI ABI is keyed off the flat entry types** (`IdentityEntryFFI::from_entry`, - `ContactRequestFFI::from_*` — `#[repr(C)]` with pinned sizes), NOT off `ManagedIdentity`'s - layout. Regrouping `ManagedIdentity` cannot move a single FFI byte as long as the entry - types stay flat. -- **Two restore paths construct/populate `ManagedIdentity` outside its methods**: the - boot/load path (in-crate: pre-built identities arrive via `IdentityManagerStartState`, - consumed at `manager/load.rs:100` — no field-level construction; FFI loader: - `ManagedIdentity::new` + restore writes, `ffi/persistence.rs:3692-3700`) and the - changeset-replay path (`state/manager/apply.rs`, `wallet/apply.rs` contacts block, - `apply_established_contact`). -- **One external struct-literal construction** exists in `rs-platform-wallet-storage` - (`schema/identities.rs:206-237`) — test-gated (`#[cfg(any(test, feature="__test-helpers"))]`). - It defaults the three relationship maps (loaded separately from the contacts table) and - populates `ignored_senders` by wholesale clone. -- **The WalletPersister is a method parameter**, not a `ManagedIdentity` field. Non-persisting - mutators return a `ContactChangeSet` for the caller to store. A sub-struct inherits the same - two patterns unchanged. -- History: a separate DashPay surface was tried and deliberately reverted twice on this - branch — `914e244401` folded `wallet/dashpay/` under `identity/` (duplicate files, - bidirectional refs, "where does this live?"), `cdd0da880e` merged the `DashPayWallet` - facade into `IdentityWallet` (two FFI handles, two clones per op, straddling ops like - `accept_contact_request`). - -## 3. Design - -### D1 — `DashPayState` struct, privately owned by `ManagedIdentity` - -New file `state/managed_identity/dashpay.rs` (a child module of `managed_identity`, sibling -of the four impl files — verified module chain makes `pub(super)` fields visible to all of -them and to nothing outside `managed_identity/`): - -```rust -/// Per-identity DashPay social state: the DashPay-contract layer -/// (contacts, requests, profile, payments, deferred crypto) carried by -/// a `ManagedIdentity` on top of its identity-core fields. -#[derive(Debug, Clone, Default)] -pub struct DashPayState { - // -- Tier A: guarded (sibling-module fields, mutate via methods) -- - pub(super) established_contacts: BTreeMap, - pub(super) sent_contact_requests: BTreeMap, - pub(super) incoming_contact_requests: BTreeMap, - pub(super) ignored_senders: BTreeSet, - pub(super) auto_accept_verify_failed: BTreeSet<[u8; 32]>, - pub(super) high_water_received_ms: Option, - pub(super) high_water_sent_ms: Option, - - // -- Tier B: open (plain data / caches, no cross-field invariant) -- - pub profile: Option, - pub payments: BTreeMap, - pub contact_profiles: BTreeMap, - pub rescan_triggered: BTreeSet, - pub pending_contact_crypto: Vec, -} -``` - -On `ManagedIdentity`, the 12 flat fields are replaced by a **private** field -`dashpay: DashPayState` (private-to-`managed_identity`: visible in `mod.rs` and all child -impl files, invisible outside — this also closes the whole-value-replacement bypass, see -D3). The existing field doc-comments (several are load-bearing, e.g. the in-memory-only -rationale on `rescan_triggered` and `auto_accept_verify_failed`) move verbatim. The -`dashpay_` name prefix is dropped inside the struct (no stutter behind `dashpay()`). - -**Tier assignment rationale.** Tier A = every field with a cross-field or temporal invariant: -the three relationship maps (auto-establish; rotation-supersede; both-exist precheck), -`ignored_senders` (ignore must emit `removed_incoming` + `ignored` together), -`auto_accept_verify_failed` (CAP eviction — already method-only today), the two high-water -cursors (compare-and-advance; only writer besides the sweep is `unignore_sender`'s rewind). -Tier B = independent per-key caches where a raw insert cannot corrupt sibling state, and where -the replay/restore paths and profile/payment recorders already write directly today. -`pending_contact_crypto` stays Tier B: its dedup invariant lives in the free function -`upsert_pending_contact_crypto` shared with the changeset apply path, and its drain uses -owned snapshots — capturing that in methods is real scope with no bypass bug on record -(possible follow-up, out of scope here). - -### D2 — mutation methods stay on `ManagedIdentity`; signatures unchanged - -Every existing mutation/query method (`add_sent_contact_request`, -`add_incoming_contact_request`, `accept_incoming_request`, `ignore_sender` / -`unignore_sender`, `set_contact_metadata`, `apply_rotated_incoming_request`, -`mark_auto_accept_verify_failed`, `should_enqueue_auto_accept`, `set_dashpay_profile`, -`record_dashpay_payment`, …) keeps its receiver, name, signature, and persister-threading -pattern; bodies reach through `self.dashpay.*`. The 53 existing method call sites don't -change. This is deliberately NOT a `DashPayState`-methods design for mutations: they need -`self.id()` and snapshot access, and moving them would churn every call site for zero -invariant gain. - -### D3 — read access: one `dashpay()` borrow + getters on `DashPayState` - -`ManagedIdentity` gains exactly one read accessor: - -```rust -pub fn dashpay(&self) -> &DashPayState -``` - -Tier B fields are `pub`, so all reads flow `managed.dashpay().payments`, -`managed.dashpay().contact_profiles`, … Tier A fields get borrow getters **on -`DashPayState` itself** (next to the fields): `established_contacts()`, -`sent_contact_requests()`, `incoming_contact_requests()`, `ignored_senders()`, and by-value -`high_water_received_ms()` / `high_water_sent_ms()` (`Option` is `Copy`). -`auto_accept_verify_failed` gets NO getter — the two existing query methods on -`ManagedIdentity` (`is_auto_accept_verify_failed`, `should_enqueue_auto_accept`) cover every -reader. Existing query helpers (`is_sender_ignored`, `established_contact(&id)`, -`prior_sent_account_reference`, …) stay on `ManagedIdentity` unchanged. - -Read sites (~30 network, ~18 FFI, ~55 test assertions) re-path mechanically -(`managed.established_contacts` → `managed.dashpay().established_contacts()`). Verified: no -name collisions with existing `ManagedIdentity` methods; the same-named methods on -`IdentityWallet` are a different type. - -Tier B **writes** get per-field mut accessors on `ManagedIdentity`: `payments_mut()`, -`contact_profiles_mut()`, `rescan_triggered_mut()`, `pending_contact_crypto_mut()`, and -`set_profile_raw` is unnecessary (`set_dashpay_profile` already exists; the replay paths use -the mut accessors). There is deliberately **no whole-struct `dashpay_mut()`** and the -`dashpay` field is private: `managed.dashpay = DashPayState::default()`, `mem::take`, and -`mem::swap` — whole-value replacements that would silently wipe Tier A state including the -cursors — do not compile outside `managed_identity/`. - -`established_contact_mut(&id) -> Option<&mut EstablishedContact>` **stays, promoted to -`pub`** (documented escape hatch; `contact_workflow_tests.rs:289` — an external compilation -unit — needs it, as do three in-crate network sites that mutate contact sub-fields then -persist a hand-built `ContactChangeSet`). Sealing per-contact sub-field mutation is -follow-up scope; the boundary claim here is deliberately modest — see D5. - -### D4 — capture the high-water invariant (the one real behavior-adjacent move) - -`advance_if_unchanged` + `advance_high_water` (free fns, -`network/contact_requests.rs:814-832`) move onto `ManagedIdentity` as the ONLY write path -for the cursors: - -```rust -/// Compare-and-advance: advance the received-direction cursor to -/// `max_fetched` (never below its current value) ONLY if the cursor -/// still holds `snapshot` — the value read at sweep start. A mid-sweep -/// `unignore_sender` rewind (reset to None) must not be clobbered by a -/// stale sweep max, or the un-ignored sender stays invisible until a -/// cold restart. -/// -/// Caller contract (unchanged from the free fn): invoke only when the -/// paginate exhausted without error AND every ingest reached disk — -/// fetch/persist-success gating stays at the call site. -pub fn advance_high_water_received(&mut self, snapshot: Option, max_fetched: Option); -pub fn advance_high_water_sent(&mut self, snapshot: Option, max_fetched: Option); -``` - -The two raw network writes (`:1363`, `:1370`) become calls; `unignore_sender`'s rewind stays -internal to the state layer. The moved invariant is CAS + monotonicity **only**; the -fetch-succeeded/persist-succeeded gating remains caller-side convention, stated in the doc. -Semantics bit-identical: the snapshot stays a caller-supplied param, so the two-guard -interleaving with a concurrent un-ignore is unchanged. - -### D5 — replay/restore writes become named `apply_*` methods - -The replay and cold-load paths currently write Tier A fields raw. They get intent-named -methods that skip business invariants **by design** (establishment/ignore decisions were made -before persist; replay must reproduce state, not re-decide it). Visibility follows the -callers — the FFI crate restores relationship maps in production, so these are `pub`: - -- `pub fn apply_sent_contact_request(&mut self, ContactRequest)` / - `apply_incoming_contact_request` — for `wallet/apply.rs:197-222` and the FFI loader - (`ffi/persistence.rs:4067/:4070`) + FFI fixtures (`ffi/tests/test_data/mod.rs:304`). -- `apply_established_contact` — exists (`contact_requests.rs:621`), promoted - `pub(crate)` → `pub` (FFI loader `:4064`, fixtures `:283-286`). Parity note: its - remove-both-pending-sides is a provable no-op on the cold-load path — `apply_contact_rows`' - match arms emit exactly one of {established, sent, incoming} per contact into fresh maps. -- `pub fn apply_ignored_sender(&mut self, Identifier)` / `apply_unignored_sender` — for - `wallet/apply.rs:255/265`, the FFI restore write (`ffi/persistence.rs:3758`), and the - storage-crate test helper. **Implementer note:** `state/manager/apply.rs:71/:115/:143` and - the storage helper write the *whole set* (`.extend()` union / fresh-object assign) — loop - `apply_ignored_sender` per element. Equivalent because `:115/:143` sit on fresh-insert - branches where the set is constructor-empty (assign ≡ insert-loop) and `:71` is already a - union; pin this equivalence with a test. -- `pub(crate) fn apply_removed_sent(&mut self, &Identifier)` / `apply_removed_incoming` — - only `wallet/apply.rs:225/:230` removes. -- `state/manager/apply.rs`'s remaining writes touch Tier B only (`dashpay_profile` → - `profile`, `dashpay_payments` → `payments`, `contact_profiles`) — they use the Tier B mut - accessors, as do the FFI restore writes to `payments`/`contact_profiles`. - -Each `apply_*` doc-comment states why it bypasses the invariant and who may call it. - -**Boundary claim, calibrated** (this is what the refactor actually buys): raw *field* writes -to Tier A are compile errors outside `managed_identity/`; invariant-*bypassing* writes still -exist but are named `apply_*`, greppable, and auditable — the compiler cannot distinguish a -new illegitimate `apply_*` caller from a restore path. The capability-level seal is real for -exactly two things: the high-water cursors (D4 — the only public write path enforces CAS) -and whole-value replacement of the DashPay group (D3). Everything else is -naming-and-audit, which is the honest, proportionate win. - -### D6 — construction - -`DashPayState` derives `Default` (every field defaults empty/None — true today for both -constructors and the cold-load path; all 12 field types are `Default`-able). -`ManagedIdentity::new` / `new_out_of_wallet` set `dashpay: DashPayState::default()`. The FFI -loader keeps `ManagedIdentity::new` + `apply_*`/Tier-B-mut writes. The storage test helper -(`storage/schema/identities.rs:206-237`) switches its literal to -`dashpay: DashPayState::default()` semantics via constructor + an `apply_ignored_sender` -loop (its relationship maps are already defaulted there; contacts load separately). - -### D7 — persistence mapping updates (no wire change) - -`IdentityEntry::from_managed` (lives in `crate::changeset` — reads Tier A through the `pub` -getters, Tier B through `dashpay()`). `IdentityEntry`, `ContactChangeSet`, all merge -functions, the SQLite schema, and every `#[repr(C)]` FFI mirror are **untouched**. The -on-disk format and FFI ABI provably cannot change: nothing serializes `ManagedIdentity` -(derives `Debug, Clone` only), and no FFI struct or `const` size assert is edited. Only -observable representation delta: `Debug` output nests the group under `dashpay:` — no test -parses `Debug` output (verified). - -### D8 — OPTIONAL: facade-level `DashPayView` namespace (decision Q1) - -The origin comment's eye was on `IdentityWallet` — where DashPay ops outnumber identity-core -ops by lines ~10.2k to ~6.6k. The twice-reverted design was a second **owned** facade (two -handles through FFI, two clones per op). A **borrowing view** has none of those costs: - -```rust -pub struct DashPayView<'a, B: TransactionBroadcaster + ?Sized>(&'a IdentityWallet); - -impl IdentityWallet { - pub fn dashpay(&self) -> DashPayView<'_, B> { DashPayView(self) } -} -``` - -The DashPay op definitions (already file-split: `contact_requests.rs`, `contacts.rs`, -`contact_info.rs`, `payments.rs`, `profile.rs`) move their `impl IdentityWallet` blocks to -`impl DashPayView<'_, B>` — one route per op, no forwarding shims. Call sites become -`wallet.identity().dashpay().send_contact_request(…)`. FFI **function signatures are -unchanged** (they re-path internally); Swift is untouched. Cost: a mechanical re-path of the -FFI + internal DashPay call sites and the sync coordinator; zero new state, zero clones. -This is the piece that makes the DashPay/identity boundary visible at every call site — but -it is severable: commits 1–2 stand alone if this is declined. - -## 4. Alternatives rejected - -1. **Separate owned `DashPayWallet` facade (the PR comment's literal suggestion).** Tried and - reverted twice on this very branch (§2 history). The ops straddle the boundary - (`accept_contact_request` = identity signing + DashPay docs; payments = core broadcaster; - contact crypto = identity DIP-9/14 keys), so a second owned facade re-creates the same - handle with a different name, re-splits ops that straddle, and re-introduces FFI - handle-juggling. DashPay is a *layer on the identity aggregate*, not a sibling domain. - Rejected on evidence, not taste. (The borrowing view in D8 is the surviving kernel of - this idea — namespace without ownership.) -2. **Separate DashPay store keyed by identity id** (e.g. `IdentityManager.dashpay: - BTreeMap`). Splits one aggregate into two maps that must stay - key-synchronized through add/remove/apply/load; every combined read becomes a two-map - join; the changeset apply and both restore paths get a second lookup + orphan mode. All - cost, and the "is it one thing?" answer is unchanged — a DashPay state without its - identity is meaningless. -3. **Extension-trait split of `IdentityWallet`** (`DashPayOps` trait). Pure cosmetics: state - stays mixed, callers add trait imports, and the network layer is already file-split by - concern. The borrowing view (D8) achieves the namespacing without the trait ceremony. -4. **Full lockdown (all 12 fields private, no `_mut` escape hatch).** Forces dedicated - methods for per-contact sub-field mutation (3 network sites), the profile fetch-cache - writer, payments recorder internals, and the pending-crypto upsert/drain — roughly - doubles the new-method surface to protect fields with no cross-field invariants and no - observed bypass bugs. Poor cost/benefit now; Tier A→B promotion later is cheap. -5. **Stop after commit 1 (all-`pub` regroup, no encapsulation).** Fixes cost 1 of §1 - (boundary/naming) but leaves costs 2–3 untouched: the high-water cursors stay raw - network writes guarding a documented lost-unignore bug, and the FFI keeps 6 - indistinguishable raw restore writes. The ~14 new methods in commit 2 are earned by - exactly those two costs. -6. **Docs only (comment banner grouping the fields).** Zero enforcement; the next reviewer - asks the same question. - -## 5. Migration & staging - -Own PR, based on `v4.1-dev` **after #3841 merges** (this touches the same files as #3841's -tail; doing it inside would bloat an already-huge diff and re-trigger full re-review). - -Commits, each independently green: - -1. **Mechanical regroup.** Introduce `DashPayState` with ALL fields temporarily `pub` (and - the `dashpay` field `pub`); move the 12 fields (renaming the three `dashpay_`-prefixed - ones); re-path every access (`managed.X` → `managed.dashpay.X`). No visibility change, no - method change. Compile-error-driven; behavior-identical by construction. Also deletes - the orphaned 0-byte `state/managed_identity/tests.rs` left by `914e244401`. -2. **Encapsulate.** Apply tier visibilities + private `dashpay` field; add `dashpay()`, - Tier A getters, Tier B mut accessors, `advance_high_water_*`, the `apply_*` family; - convert the network + FFI + replay sites; move the two high-water free fns into the - state layer with their tests. **Test-fixture conversions budgeted here** (not - "mechanical re-paths"): `wallet/apply.rs:562-567/:1348-1351` (insert → `apply_*`), - `network/contact_requests.rs` fixture sites (~3411-3418 flag write → - `established_contact_mut`; ~3560-3564 `ignored_senders.clear()` — no direct equivalent, - becomes clone-keys + `apply_unignored_sender` loop), `network/payments.rs:1535/1610/2537` - + `network/contact_info.rs` helper (insert → `apply_*`), FFI fixtures - (`ffi/tests/test_data/mod.rs`), `contact_workflow_tests.rs:289` - (→ `established_contact_mut`). -3. **(Optional, decision Q1) `DashPayView` facade namespace** per D8. - -Rollback story: each commit reverts independently of the ones before it. - -## 6. Failure modes & risks - -- **Missed access site** → compile error (loud, the mechanism working as intended). Zero - runtime discovery. -- **High-water semantics drift** (the only logic that *moves*): mitigated by porting the - existing free-fn unit tests unchanged, plus new method-level tests written to pass against - the free fn's behavior BEFORE the move (kill-the-mutant check: `snapshot != current` must - leave the field untouched). -- **Replay-path behavior change**: `apply_*` methods must reproduce today's raw writes - exactly. Verified caller-side semantics that must NOT move into the methods: - `wallet/apply.rs` keys inserts off `entry.request.recipient_id`/`sender_id`, warns on - orphan inserts but is silent on orphan removes, orders inserts-before-removes and - unignore-after-ignore (un-ignore wins) — all stay in `wallet/apply.rs`. The - `ignored_senders` assign-vs-loop equivalence (D5) gets a pinning test. -- **Borrow-checker fallout**: adversarial pass verified all existing sites compile under the - new surface (the three `get_mut` sites touch only the contact + persister while the `&mut` - is live; loops over Tier A maps are read-only in-body; mutating sites collect inputs before - taking `&mut`). New sites holding a getter-returned borrow across a `&mut` call will fail - to compile — clone first in tests. -- **FFI restore path**: `apply_*` parity is exact (raw inserts have no side effects; - `apply_established_contact`'s extra removes are a no-op on fresh maps — D5). -- **Merge risk with in-flight DashPay work**: pure mechanics; land in a quiet window. - Orthogonal to the pending-contact-crypto follow-ups (that spec moved the field *onto* the - identity; this one only re-paths it). -- **Residual escape hatches — the honest list**: `established_contact_mut` (`pub`), Tier B - `pub` fields + mut accessors, and the **`pub apply_*` family itself** (any crate can call - `apply_ignored_sender` instead of `ignore_sender`, skipping the tombstone contract — same - exposure as today's `pub` fields, but now named and greppable). The boundary claim is - D5's calibrated version, not "all DashPay state is sealed". - -## 7. Test & verification plan - -- **Existing suites are the harness** (behavior-preserving refactor): full - `rs-platform-wallet` lib tests (the auto-establish, rotation, ignore/unignore, CAP-eviction, - persist-before-commit pins all keep passing untouched), `contact_workflow_tests`, - `rs-platform-wallet-ffi` lib + integration tests. -- **New unit tests** (written against the CURRENT free-fn behavior first, then the method): - `advance_high_water_{received,sent}` — advance, never-below, `None`-snapshot, - mid-sweep-rewind-preserved; `apply_ignored_sender` loop ≡ wholesale-assign parity; - `apply_sent/incoming_contact_request` parity with today's `wallet/apply.rs` raw inserts - (including the no-auto-establish property of the replay path). -- **Visibility is its own test — for what it actually seals**: after commit 2, a Tier A raw - *field* write or a whole-`dashpay` replacement outside `managed_identity/` is a compile - error. (`apply_*` misuse is not compiler-catchable — that's the calibrated D5 claim.) -- **Local CI mirror**: `cargo clippy --workspace --all-features` + `cargo fmt --check --all` - (targeted `-p` builds miss feature-gated callers). -- **iOS**: rebuild the xcframework + run the existing FFI persistence round-trip tests; no - Swift source change expected (assert: `git diff --stat` on `packages/swift-sdk` is empty). - -## 8. Open questions (decision points for the PR author) - -1. **Include commit 3 (`DashPayView` facade namespace, D8)?** Recommendation: yes — it is - the only part of this spec that changes what the origin comment's author *sees* at the - `IdentityWallet` layer, and it is zero-cost at runtime. But commits 1–2 deliver the - state-layer value standalone; declining Q1 drops D8 with no other edits. -2. Should `payments` be Tier A? `record_dashpay_payment` has rollback-on-persist semantics, - but the FFI restore + overlay paths write it raw; sealing it means two more `apply_*` - methods. Proposed: keep Tier B now. -3. DPNS fields (`dpns_names`, `contested_dpns_names`): once `DashPayState` lands, their - loose placement becomes the next obvious question. Position: deliberately-separate - follow-up using the identical pattern ("dashpay first, dpns next"), not scope here. diff --git a/docs/dashpay/DIP15_INVITATIONS_SPEC.md b/docs/dashpay/DIP15_INVITATIONS_SPEC.md deleted file mode 100644 index 1f2152e994b..00000000000 --- a/docs/dashpay/DIP15_INVITATIONS_SPEC.md +++ /dev/null @@ -1,835 +0,0 @@ -# DashPay Invitations (DIP-13 sub-feature 3') — Implementation Spec - -> **Status:** SHIPPED on PR #4041 (2026-07-14) — create + claim + reclaim + persistence + UI, -> three review-fix rounds folded, funded testnet e2e green (TEST_PLAN DP-12..19, `AI_QA/QA004`). -> The original design pass (2026-07-08, §1–§14 below) is kept as rationale; **§0 records where -> the as-built implementation deliberately diverged.** Where §0 and a later section disagree, -> §0 wins. - -Tracked as the "NEXT" item in the DashPay backlog (dashpay/platform#4020); called out in -`SPEC.md` Milestone 5 and `DIP_CONFORMANCE_GAPS.md`. - ---- - -## 0. As-built delta (supersedes the marked sections below) - -1. **Link envelope = the LEGACY query format, not the §6 binary blob (supersedes §6, §7).** - The 2026-07-13 legacy-compat rework (owner decision; contract in §0A) replaced the hand-rolled versioned payload with the - query form shared with dash-wallet Android / dashwallet-iOS, so links are field-level - cross-claimable: `dashpay://invite?du=&assetlocktx=&pk=&islock=` - `[&display-name=…][&avatar-url=…]` (also parses `https://invitations.dashpay.io/applink?…`). - Emit strict / parse lenient. Consequences: - - The link carries the funding **txid**, not the embedded proof → **claim-by-fetch**: the - invitee refetches the funding tx by txid (bounded retry for DAPI propagation lag, both - byte orders), reconstructs the proof, and selects the credit output by matching - `voucher_credit_script(pk)`. - - **No expiry field on the wire** — the §5.1/§8/§10 "claim refuses a past-expiry link" - mechanism does not exist in the as-built claim; `expiry_unix` survives only as inviter-side - local display metadata. The economic bounds are the amount caps. - - **No inviter identity id on the wire** (`inviter_id` always zeroed) — the contact bootstrap - resolves the id from the `du` username via DPNS at claim time. `InviterInfo.username` is - `Option`: a display-name/avatar-only link is metadata-only (`has_inviter == true`, - `inviter_username == nil`, no bootstrap). - - Amount is not on the wire (claim preview shows "—"). -2. **Claim accepts ChainLock invites too (amends §5.1).** `islock` absent or literal `"null"` - ⇒ a `ChainAssetLockProof` is reconstructed (requires the funding tx to be chain-locked). - Create still emits only InstantSend links — a slow-IS ChainLock fallback at create is - rejected as a *link* but the funded lock is recorded first and stays reclaimable. -3. **Amounts (amends §5/§8/§9):** `MIN_INVITATION_DUFFS = 300_000` (0.003 DASH — a smaller - voucher can fund neither a claim nor a register-reclaim, discovered by funded e2e), - `MAX_INVITATION_DUFFS = 26_000_000` (0.26 DASH), Swift default **0.03 DASH**. -4. **Persistence as-built (amends §4.2):** the `InvitationChangeSet` flows through - `PlatformWalletPersistence::store()` to each backend — the SQLite backend's - `V003__invitations` table, and on iOS the FFI `on_persist_invitations_fn` bridge into the - SwiftData `PersistentInvitation` model (SwiftData is the UI source; no Rust rehydrate; §0B). Persist failures are signaled end-to-end - (nonzero callback → rolled-back round → `create_invitation` errors), not best-effort. -5. **Durability + ordering hardening (review rounds 1–3; the per-finding log lives in the - PR #4041 review threads + commit messages):** the pre-broadcast gate persists **and flushes** the - invitation funding-index pool (aborting before broadcast on failure); creation refuses - non-durable backends (`PlatformWalletPersistence::persists_durably()`); the funded-asset-lock - flow is split so the invitation record is persisted immediately **after broadcast, before the - proof wait** — an interrupted create can no longer orphan a funded voucher. -6. **Reclaim shipped (extends §1 scope):** an unclaimed voucher is recovered as identity - **credits** (top-up an existing identity or register a new one; the L1 amount was - OP_RETURN-burned). Already-consumed handling is classified via the persisted - `reclaimInFlight` marker: marker unset ⇒ provably a foreign claim (neutral "already - claimed"); marker set ⇒ **explicitly ambiguous** (the marker proves only that a local - consume attempt started, not that it landed — a racing claim is indistinguishable, so the - row resolves to the conservative terminal `Claimed` with an ambiguity message, never an - inferred `Reclaimed`) — see `AI_QA/QA004` step 6 for the exact classifier arms. -7. **QA contract as-built:** TEST_PLAN §4.10 rows **DP-12..DP-19** (not just DP-12..15) + - `AI_QA/QA004_invitation_reclaim.md`; funded e2e evidence recorded there. - ---- - -## 0A. As-built link envelope & legacy interop (absorbs the legacy-compat spec) - -The interop contract is **field-level parity with the live legacy wallets, emit -strict/canonical, parse leniently** — exactly as tolerantly as the live Android wallet. -Byte-for-byte parity is NOT the contract (the two legacy wallets differ in param order and in -scheme/host). The on-chain primitive and derivation path (`m/9'/coin'/5'/3'/idx'`) are -identical across all three wallets — no consensus change. - -### 0A.1 Wire format - -**Emit (canonical, what we produce):** -```text -dashpay://invite - ?du= # required to emit; optional on parse - &assetlocktx= - &pk= - &islock= # or omit (see below) - [&display-name=] - [&avatar-url=] -``` -- Parse **by field name, order-independent**; accept **both** the `dashpay://invite` scheme - and the `https://invitations.dashpay.io/applink` host (iOS legacy links use the latter). -- **`pk`**: WIF, **compressed** flag set (the credit-output hash uses the *compressed* - pubkey — wrong compression ⇒ wrong `hash160` ⇒ claim fails), network byte `0xCC` mainnet / - `0xEF` testnet-family. -- **`assetlocktx`**: emit lowercase big-endian display hex; on claim parse leniently — try - as-given, then **retry byte-reversed** on a fetch miss (old iOS links are little-endian). -- **`islock`**: OPTIONAL, with two absence forms — param missing **and the literal string - `"null"`** (Android emits `"null"` for a chainlock-confirmed invite). Absent/`"null"` ⇒ - reconstruct a **`ChainAssetLockProof`** at claim, never reject. The hex is not - self-describing: decode as the modern deterministic **ISDLOCK**; the ancient - non-deterministic ISLOCK is unrepresentable in rust-dashcore and fails closed (documented - limitation — no live producer exists). -- **Validity (lenient superset of both wallets):** require `assetlocktx` + `pk` - present/non-blank; never reject solely on a missing `du` or missing/`"null"` `islock`. - -### 0A.2 Claim-by-fetch - -The link carries the funding **txid**, not a proof, so claim reconstructs it (mirrors Android -`TopUpRepository.obtainAssetLockTransaction`): -1. Fetch the tx by `assetlocktx` via `Sdk::get_transaction` (bounded retry/backoff for DAPI - propagation lag; reversed-retry per §0A.1). -2. Fail-fast guards: fetched txid matches `assetlocktx` (either byte order); when an islock is - present, `islock.txid == fetched tx.txid`. -3. **Derive `output_index` by script match** — scan the fetched tx's `credit_outputs` for the - output whose `script_pubkey` == `voucher_credit_script(pk)`; never hard-code index 0. -4. Build `InstantAssetLockProof` (islock present) or `ChainAssetLockProof` (absent/`"null"`; - requires the tx to be chain-locked), then submit through the **unchanged** - `put_to_platform_and_wait_for_response_with_private_key`. - -Consensus enforces pk↔output, islock↔tx, and identity_id↔outpoint — all fail closed; the -local guards are fast-fail UX + correct index selection, not theft prevention. - -### 0A.3 Consequences of the legacy format - -- **No inviter identity id on the wire** — only `du`. `InviterInfo = {username?, display_name?, - avatar_url?}` and the invitee resolves the inviter's id from `du` via DPNS at - contact-bootstrap. A `du`-less link is metadata-only (`has_inviter == true`, - `inviter_username == nil`, no bootstrap possible). -- **No expiry on the wire** — the pre-network staleness gate is gone; the real bounds are the - amount caps + reclaim. The inviter-side local record keeps expiry for display only. -- **Amount is not on the wire** — the claim preview shows "—" pre-fetch. - -### 0A.4 Amounts (onboarding tiers) - -`MIN_INVITATION_DUFFS = 300_000` (0.003 DASH, == Android `DASH_PAY_INVITE_MIN`; a smaller -voucher can fund neither a claim nor a register-reclaim — found by funded e2e). -`MAX_INVITATION_DUFFS = 26_000_000` (0.26 DASH). Swift create default **0.03 DASH** = identity -+ a normal DPNS name (Android `DASH_PAY_FEE`). The cap covers the contested/premium tier as -well (0.25, Android `DASH_PAY_FEE_CONTESTED`), with the remaining margin for the create/claim -fees; the claim path is amount-agnostic, so nothing further gates a contested-tier invite. - -### 0A.5 Transport - -The custom `dashpay://` scheme is the shipped, first-class transport (QR / share sheet / -in-person). The legacy wallets' AppsFlyer OneLink wrapper is **externally blocked** (Android -team creds; brand domain + template) and tracked separately (#4096-adjacent); note that -OneLink discloses the plaintext `pk` to AppsFlyer server-side — an accepted, documented -regression vs a self-contained link, bounded by the amount cap + reclaim. The custom scheme's -same-device interception limitation is documented in `Info.plist` + §6.1. - ---- - -## 0B. As-built persistence & reclaim (absorbs the Swift-persistence spec) - -### 0B.1 Persistence bridge - -`InvitationChangeSet` (structurally an `asset_locks`-style `BTreeMap` upserts + -`BTreeSet` removals) flows through `PlatformWalletPersistence::store()` to each backend: the -SQLite backend's `V003__invitations` table, and on iOS the **push-callback FFI bridge** -(`on_persist_invitations_fn` → `persistInvitationsCallback` → SwiftData -`PersistentInvitation`), mirroring the asset-lock wiring. Key properties: -- **SwiftData is the UI source; push-only, no Rust→Swift rehydrate.** A SwiftData wipe loses - only list *visibility* — never funds or key re-derivability (`funding_index` re-derives the - voucher key). -- **Persist failures are signaled, never swallowed:** a skipped write returns nonzero from the - callback, failing the (invitation-only) `store()` round and surfacing an error from - `create_invitation` instead of reporting a voucher that never reached SwiftData. -- **Outpoint key seam:** both the upsert and the removal path derive the unique - `outPointHex` via `PersistentAssetLock.encodeOutPoint` verbatim (key-form drift is pinned by - `InvitationPersistenceTests`). - -### 0B.2 Reclaim - -The invitation's DASH is **burned into an `OP_RETURN`** at create time — the credit output -exists only in the tx payload as a Platform-side authorization, never as an L1 UTXO — so -"reclaim" means: **the inviter consumes the still-unclaimed voucher into a Platform identity -of their own, recovering the value as credits** (mechanically, claiming your own invitation). -UI copy always says "recovered as identity credits", never "DASH returned". - -- **Primitive:** consume the tracked lock via - `FromExistingAssetLock { out_point, consume_invitation_voucher: true }` — the inviter's own - signer re-derives the voucher key at `9'/coin'/5'/3'/funding_index'` internally (no key - export). Two user-picked targets: **top-up an existing identity** or **register a new - one**. The `consume_invitation_voucher` flag is the reclaim flow's **explicit - authorization**: every generic resume/top-up path passes `false` and the funding resolver - refuses `IdentityInvitation`-typed locks, so a shared voucher can never be silently - consumed into an unrelated local identity (the Swift resumable-registrations surface also - excludes `fundingTypeRaw == 3` rows). -- **Race / already-consumed:** no L1 double-spend exists (no shared UTXO); Platform - deterministically rejects the second consume - (`IdentityAssetLockTransactionOutPointAlreadyConsumed` — the loser wastes only an ST fee). - The Swift side classifies via the persisted `reclaimInFlight` marker, which is saved - (required — the consume may not run on a failed save) only immediately before the on-chain - consume: marker unset ⇒ provably the invitee claimed first (row → `Claimed`, neutral - "This invitation was already claimed." — claimant not named); marker set ⇒ **explicitly - ambiguous** — the marker proves only that a local consume attempt started, not that it - landed (a racing claim between crash and retry is indistinguishable), so the row resolves - to the conservative terminal `Claimed` with an ambiguity message, never an inferred - `Reclaimed` (`Reclaimed` is written only by a success observed in-flow). The local - "is not tracked" resume-guard failure with the marker set is surfaced as an explicit - ambiguity error (status unchanged — there is no on-chain proof of consumption at all). - The decision is the pure, unit-tested `classifyReclaimFailure(error:hadPriorReclaimInFlight:)` - seam; see `AI_QA/QA004` step 6 for the verified classifier arms. -- **Status lifecycle:** `Reclaimed`/`Claimed` are written by the Swift UI on the local row - (SwiftData is the UI source; create is the only Rust emitter). - ---- - -## 1. Problem & goal - -DashPay onboarding today assumes the new user already **has** a Dash identity (which -requires L1 Dash to fund the ~0.0002 DASH asset lock that registers it). That is a -chicken-and-egg wall for inviting a friend who has never touched Dash: they can't receive a -payment (no identity → no contact) and can't register an identity (no funds). - -**DIP-13 "Identity Invitation Funding keys" solves this.** An existing user (the *inviter*) -pre-funds an asset lock at a dedicated derivation sub-feature, hands the one-time private key -+ the asset-lock proof to a friend (the *invitee*) as a link, and the invitee registers -**their own new identity** funded by that voucher — no L1 Dash required on the invitee's -side. The invitation optionally bootstraps the DashPay contact in the same act (the invitee's -contact request to the inviter carries a DIP-15 `autoAcceptProof`, so it auto-establishes). - -**Goal:** implement invitation **create** (inviter) and **claim** (invitee) end-to-end across -`rs-platform-wallet` + `rs-platform-wallet-ffi` + `swift-sdk` + `SwiftExampleApp`, with unit -+ integration tests, a testnet funded e2e, and QA-contract scenarios. - -### Non-goals -- **No byte-for-byte interop with the production iOS/Android DashWallet invitation link.** We - can't drive those builds in this environment (same constraint the auto-accept spec accepted: - iOS-first, DIP-faithful where the DIP defines a format, normative-for-us where it is silent). - The **on-chain** artifacts (asset lock, IdentityCreate, contactRequest) are consensus formats - and *are* interoperable; only the off-chain **link envelope** is ours. See §7 for the interop - decision once the reference format is confirmed. -- **No new on-chain artifact.** Invitations reuse the existing AssetLock special-tx, the - IdentityCreate transition, and a plain contactRequest. -- **No auto-accept bearer key in the invitation (v1).** The contact-bootstrap is a *normal* - contact request (see §2 design change); no `dapk` is embedded. -- **No invitation for identity-less inviters in v1** beyond the pure funding voucher: the - contact-bootstrap requires the inviter to hold a registered identity. A voucher from an - identity-less funder still works as pure onboarding funding; it just carries no inviter to - contact. -- **Advisory expiry, not consensus revocation.** The voucher key controls an on-chain asset - lock that never expires; the payload's `expiry` is an **advisory** bound (the claim UI refuses - a stale link; the inviter is prompted to reclaim). True "revocation" is the inviter racing to - *reclaim* the unclaimed lock (a race it can lose if the link already leaked — §8 Finding 6). A - dedicated revoke UI is a follow-up. - ---- - -## 2. The model — two roles, three on-chain acts - -1. **Inviter (Bob, has funds + identity).** - - Derives a one-time ECDSA **voucher key** at the DIP-13 invitation path - `m/9'/coin'/5'/3'/funding_index'` (sub-feature `3'`). - - Builds + broadcasts an **asset lock** paying `amount` duffs to that key, and waits for an - **InstantSend** proof (§5.1 — fast, self-contained; a short IS-scoped expiry covers - staleness). - - **Optionally ticks "send a contact request back to me"** — if checked, the link carries the - inviter's identity id + username; if not, it's a pure funding voucher. - - Emits a `dashpay://invite?...` link carrying: **voucher private key**, **asset-lock - proof (IS)**, **advisory expiry**, and *(if opted in)* **inviter identity id + username + - display name**. The voucher key is re-derivable from `funding_index`, so it is **never - persisted**; only the funding index + outpoint are tracked (for recovery + status). -2. **Invitee (Carol, no funds).** - - Opens the link → decodes (voucher key, proof, optional inviter info). - - Registers **her own new identity** with keys derived from **her** seed at - `m/9'/coin'/5'/0'/0'/identity_index'/…`, funded by the imported `(proof, voucher_key)` via - the SDK's in-process raw-key path (§5.2). No L1 Dash on Carol's side. - - **If the link carries inviter info, Carol is *asked* "establish contact with \?"** — - on confirm, a *normal* contactRequest Carol→Bob is sent via the shipped - `send_contact_request` path; Bob sees it in his Requests and accepts. Opt-in on both ends - (inviter checkbox + invitee prompt); no bearer auto-accept key is embedded. - -> **Design change from the first draft (security review Finding 1 + reference behavior).** The -> first draft embedded a DIP-15 auto-accept `dapk` in the link so the contact would auto-establish -> with zero taps on the inviter. That is **removed**: auto-accept's safety rests entirely on a -> **1-hour TTL**, which is fundamentally incompatible with an invitation that is claimed hours-to- -> days later — a link long-lived enough to be useful would be a long-lived auto-accept bearer -> credential against the inviter (anyone finding a stale/posted link could make the inviter -> publish an encrypted friendship xpub to them). The production wallets don't do this either: -> their claim flow (`sendContactRequestToInviterUsingInvitationURL`) sends a **plain** contact -> request. So v1 auto-sends a normal contactRequest; zero-tap acceptance is the inviter's own -> orthogonal auto-accept setting, not baked into the shared link. (Embedding a short-TTL dapk with -> an explicit "expired → manual request" fallback is a possible v2 nicety — deferred.) - -The consensus acts (asset lock, IdentityCreate, contactRequest) are all already implemented and -tested; invitations are the **orchestration + off-chain envelope + key-handoff** around them. - ---- - -## 3. What already exists (reuse inventory — first-hand code read) - -| Capability | Where | Reused for | -|---|---|---| -| **Invitation funding derivation** `AssetLockFundingType::IdentityInvitation` (sub-feature `3'`), `accounts.identity_invitation` xpub, storage/recovery/persistence all wired | `asset_lock/build.rs:200-216` (`peek_next_funding_address`), storage `schema/accounts.rs`, `asset_lock/sync/recovery.rs:427`, `persistence.rs:3633` | **Create**: derive the voucher key + build the voucher asset lock | -| **Full funded-asset-lock flow** `create_funded_asset_lock_proof(amount, account_index, funding_type, identity_index, signer) -> (AssetLockProof, DerivationPath, OutPoint)` (build → track → broadcast → IS wait → CL-upgrade → attach proof) | `asset_lock/build.rs:305-417` | **Create**: build the voucher lock | -| **IS→CL upgrade** `upgrade_to_chain_lock_proof(out_point, None)` | `identity/network/registration.rs:186-197,247-250` | **Create**: force a CL proof before export | -| **Register identity from a raw asset-lock private key** `Identity::put_to_platform_and_wait_for_response_with_private_key(sdk, proof, asset_lock_proof_private_key: &PrivateKey, identity_signer, settings)` | `rs-sdk/.../put_identity.rs:50-59,146+` | **Claim**: register invitee identity funded by the imported voucher — **core claim needs no new SDK code** | -| **Bare claim FFI (external proof + one-time key)** `dash_sdk_identity_put_to_platform_with_instant_lock` / `_with_chain_lock(sdk, …proof bytes…, private_key:[u8;32], signer, settings)` | `rs-sdk-ffi/src/identity/put.rs:29,211` | Lower layer under the platform-wallet `claim_invitation` wrapper (no Swift binding yet) | -| **`AssetLockProof::Instant` embeds the full tx + islock** (self-contained); `Chain` = outpoint+height (Platform resolves tx) | `asset_lock_proof/instant/…:38`, `…/chain/…:24` | **Link**: serialize the proof directly — no separate txid + L1 fetch | -| **Consensus verifies the create sig against the asset-lock output's P2PKH hash** | `identity_create/state/v0/mod.rs:222-245` | Security trust anchor (§8): holder of the voucher key == who may create the identity | -| **Seedless register (self-funded)** `register_identity_with_funding(AssetLockFunding, identity_index, keys_map, identity_signer, asset_lock_signer, …)` | `identity/network/registration.rs:121` | Template; claim uses the raw-key variant instead | -| **Sanctioned raw-scalar export (path-gated)** `ContactCryptoProvider::export_auto_accept_private_key(&path)` / resolver hook | `contact_requests.rs:63`, `mnemonic_resolver_core_signer.rs:353` | **Create**: template for the new path-gated `export_invitation_private_key` (§5.3) | -| **Send a normal contactRequest** `platform_wallet_send_contact_request_with_signer(...)` | FFI `dashpay.rs:225` | **Claim**: auto-send the plain contact-bootstrap invitee→inviter (no dapk) | -| **Register/resume identity FFI (external signer)** `platform_wallet_register_identity_with_funding_signer`, `platform_wallet_resume_identity_with_existing_asset_lock_signer` | FFI `identity_registration_funded_with_signer.rs` | Template for the new claim FFI marshaling | -| **Asset-lock build FFI + tracked-lock listing** `asset_lock_manager_build_transaction`, `create_funded_proof`, `list_tracked_locks` | FFI `asset_lock/build.rs`, `asset_lock/manager.rs` | Create FFI + inviter-side status | - -**Net: the funding-derivation family and both consensus signing paths already exist.** The new -code is (a) the create orchestration + voucher-key export, (b) the claim orchestration, (c) the -`dashpay://invite` envelope codec, (d) inviter-side invitation persistence, (e) FFI + Swift + UI. - ---- - -## 4. Interface / data flow per layer - -### 4.1 Rust — new module `wallet/identity/network/invitation.rs` (+ codec in `crypto/invitation.rs`) - -**Create (inviter):** -``` -async fn create_invitation( - &self, - amount_duffs: u64, // rejected if 0 or > MAX_INVITATION_DUFFS - funding_account_index: u32, // BIP44 account supplying the L1 UTXOs - inviter: Option, // id + username + display_name (contact-bootstrap) - expiry_unix: u32, // advisory; the FFI sets now + MAX_INVITATION_TTL_SECS - asset_lock_signer: &AS, // funds the asset-lock (funding-input + credit-output) - crypto_provider: &CP, // exports the voucher scalar (path-gated resolver) -) -> Result -``` -where `inviter: Option` is `Some` only when the inviter ticked "send a -contact request back to me" (§ owner decision). Steps: (1) **bound the amount** -(`0 < amount_duffs ≤ MAX_INVITATION_DUFFS`) and the expiry (non-zero), else err; -(2) `create_funded_asset_lock_proof(amount, funding_account_index, IdentityInvitation, signer)` -→ `(IS proof, path, out_point)` — **the builder auto-selects the next unused funding index** and -returns its derivation `path`; **keep the IS proof, no CL upgrade** (§5.1); (3) **export the -voucher private key** via the seedless resolver hook, **path-gated to the fully-hardened -`9'/coin'/5'/3'/idx'`** (§5.3); (4) build the `Invitation` struct + `dashpay://invite` URI (§6); -(5) **persist an invitation record** through the wallet persister (§4.2) — created status, -outpoint, funding_index (from `path`), amount, expiry, optional inviter info; **the voucher key is -never persisted** (re-derived from `funding_index`). - -**Claim (invitee):** -``` -async fn claim_invitation( - &self, - invitation: ParsedInvitation, // decoded from the URI - identity_index: u32, - keys_map: BTreeMap, // invitee's own new-identity keys - identity_signer: &IS, // invitee's identity-key signer - establish_contact: bool, // invitee's answer to "establish contact with ?" -) -> Result -``` -Claim **bypasses the wallet's `AssetLockFunding` machinery** — the deliberately-removed -`UseAssetLock` variant (external proof through the tracked-lock resolver) is *not* revived; the -invitee owns neither the lock's inputs nor its tracking and can't drive its IS→CL fallback, so -claim submits the imported proof directly. Steps: (1) **validate the parsed invitation before -any network act** (§8 Finding 5): proof is an **Instant** proof; the voucher pubkey is the -credit-output's P2PKH target (`proof.output() → credit_outputs[output_index]`); expiry not -past — fail loud with a specific error otherwise; (2) build the placeholder `Identity` with -`keys_map`; (3) -`placeholder.put_to_platform_and_wait_for_response_with_private_key(&sdk, invitation.proof, -&invitation.voucher_key, identity_signer, settings)` → new `Identity` — **wrap this submit in -`submit_with_cl_height_retry`** (feasibility Note A): the direct raw-key SDK call bypasses -`register_identity_with_funding`, so it doesn't inherit that helper's retry on a transient -CL-height-too-low (10506); without the wrapper a transient reject is a hard claim failure; (4) -local bookkeeping -(add to IdentityManager, breadcrumbs) — best-effort, non-propagating (mirrors -`register_identity_with_funding` Step 4); (5) if `invitation.inviter` present **and -`establish_contact`** (the invitee said yes to the prompt), **send a normal contactRequest** -invitee→inviter via the shipped `send_contact_request` path (the new invitee identity as -sender). Idempotent/re-sendable if step 5 fails after step 3 succeeds (§10). If the invitee -declines, the identity is still created — just no contact. - -### 4.2 Rust — inviter-side persistence (proper persister integration — owner decision) -**A first-class persisted invitation record, through the existing wallet persister system** -(not an ad-hoc KV blob). Follow the established DashPay changeset → persister → SwiftData-model -pattern already used for contact requests / payments (`rs-platform-wallet` changeset overlays + -`rs-platform-wallet-storage` migration + the Swift `Persistence/Models` `@Query` models — -research-swift map). Concretely: -- **Rust storage (`rs-platform-wallet-storage`):** a new `invitations` table via a migration - (mirroring `asset_locks` `V001__initial.rs:247`), columns `wallet_id, outpoint, funding_index, - amount_duffs, expiry_unix, status (created|claimed|reclaimed), inviter_opt_in, created_at, - claimed_identity_id?`. **No secret column** — the voucher key is re-derived from `funding_index` - (§5.3), never stored. -- **Rust changeset (`rs-platform-wallet`):** an `InvitationChangeSet` emitted by create/reclaim - and by the sync that flips *created → claimed* (detected by the tracked asset-lock's outpoint - being consumed on Platform / the invitee's inbound contactRequest), queued onto the persister - exactly like `AssetLockChangeSet` / the DashPay overlays. -- **Swift:** a `PersistentInvitation` SwiftData model registered in `DashModelContainer`, driving - a `@Query` "Sent invitations" list (`InvitationsView`). - -Recovery still leans on re-derivation: an unclaimed invitation's voucher key is re-derived from -its `funding_index` to re-package or reclaim (the asset-lock row already tracks the lock's -lifecycle for the actual reclaim submit). The invitations table adds the durable, queryable -*status* surface the UI needs. - -### 4.3 FFI (rs-platform-wallet-ffi) — new `invitation.rs` -- `platform_wallet_create_invitation(wallet, amount_duffs, funding_account_index, - inviter_identity_id: *const [u8;32] /*nullable*/, inviter_username: *const c_char /*nullable*/, - expiry_unix: u32, core_signer_handle, out_uri: **c_char, out_outpoint: *mut OutPointFFI) - -> Result`. **Only `core_signer_handle`** (the asset-lock/Core signer) is needed — pure voucher - creation registers no identity, so there is no identity `signer_handle` (feasibility Note B). - `now`/`expiry_unix` is passed in from Swift (FFI can't read the clock deterministically — same - convention as `build_auto_accept_qr`). -- `platform_wallet_claim_invitation(wallet, uri: *const c_char, identity_index, - identity_pubkeys, identity_pubkeys_count, signer_handle /*invitee identity signer*/, - establish_contact: bool, out_identity_id: *mut [u8;32], out_identity_handle: *mut Handle) - -> Result`. `establish_contact` is the invitee's answer to the "establish contact with - \?" prompt (only acted on if the link carries inviter info). Reuses - `decode_identity_pubkeys` + the managed-identity insert from - `identity_registration_funded_with_signer.rs`. Note: a **bare** identity-create-from-external- - proof FFI already exists one layer down — `dash_sdk_identity_put_to_platform_with_chain_lock` - / `..._with_instant_lock(sdk, …proof bytes…, private_key: *const [u8;32], signer, settings)` - (`rs-sdk-ffi/src/identity/put.rs:29,211`). We do **not** call that bare FFI from Swift for - claim: the platform-wallet `claim_invitation` wrapper is needed so the new invitee identity is - registered in the wallet's `ManagedIdentity` storage **and** the contact-bootstrap fires — it - calls `put_to_platform_and_wait_for_response_with_private_key` internally, then does bookkeeping - + the bootstrap send. (No `core_signer_handle` is needed on claim: the asset-lock signature - uses the imported raw voucher key, not a wallet-derived one.) -- `platform_wallet_list_invitations(...)` + free helpers for the inviter status list. -- String/URI input validation identical to the auto-accept FFIs (null checks, length caps). - -### 4.4 Swift (swift-sdk + SwiftExampleApp) -Current services (note: `PlatformService`/`WalletService`/`UnifiedAppState` were **removed**): -`AppState` (owns the `SDK`, network), `PlatformWalletManager` (per-network, DashPay sync -lifecycle), `ManagedPlatformWallet` (**all identity/DashPay FFI calls live here**). **All Swift -↔ Rust FFI work MUST go through the `swift-rust-ffi-engineer` agent** (repo `CLAUDE.md` rule). -The **DIP-15 auto-accept QR flow is the copy-template** for both directions. -- swift-sdk wrappers on `ManagedPlatformWallet`: - - `createInvitation(amountDuffs:fundingAccount:expiry:) async throws -> InvitationLink` - (idiom of `registerIdentityWithFunding` `ManagedPlatformWallet.swift:3370` — long-running L1 - build, so wrap with a Controller+Coordinator triad like `IdentityRegistrationController`). - - `claimInvitation(uri:identityIndex:) async throws -> ManagedIdentity` (idiom of - `sendContactRequestFromQR` `:1758`). -- SwiftExampleApp UI (under the DashPay tab, `App/Views/DashPay/`): - - **Create**: a "Create invitation" action (beside "Add me QR" in `DashPayProfileView.swift:74`) - → amount entry **+ a "send a contact request back to me" checkbox** (drives the optional - inviter info) → share sheet with the link + a QR (reuse `generateQRCode`). - - **Claim**: a toolbar button + sheet mirroring `AddViaQRSheet` (`DashPayTabView.swift:830`) - (paste/scan the `dashpay://invite` link) → register identity → **if the link carries inviter - info, prompt "establish contact with \?"** → pass the answer as `establish_contact` → - `kickDashPaySync` → the new identity (+ optional contact) land via `@Query`. - - **Invitations list** (created + status): a new `InvitationsView` (`@Query` over - `PersistentInvitation`, §4.2), reached via a toolbar `NavigationLink` (like the Ignored link - at `:151`). - - **Deep link (net-new plumbing):** no `onOpenURL`/`CFBundleURLTypes` exist today. Add the - `dashpay` URL scheme to `SwiftExampleApp/Info.plist` and `.onOpenURL { … }` on the - `WindowGroup` in `SwiftExampleAppApp.swift:105`, routing to `RootTab.dashpay` + the claim - sheet; reuse the `AddViaQRSheet` URI-parse as the model. -- `FundingType.identityInvitation = 3` already exists in Swift - (`ManagedAssetLockManager.swift:36`, `KeyWalletTypes.swift:14`). -- **Framework build:** `DashSDKFFI.xcframework` is a generated artifact (not committed); rebuild - via `packages/swift-sdk/build_ios.sh --target sim` after any FFI/header change, then the - `xcodebuild` app build (§ repo CLAUDE.md). Always clean+rebuild after header changes. - -### 4.5 QA contract -The authoritative QA contract is **`packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md`** (driven by -the `simulator-control` skill; dashboard at `dashpay.github.io/qa-dashboard-site`). Add rows to -**§4.10 DashPay** as **DP-12+** in the existing format: -`| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes |`. Planned rows: -- `DP-12 | Create invitation | Cross | Common | … | funding | DashPay → Create invitation → platform_wallet_create_invitation (builds L1 asset lock; needs testnet funds).` -- `DP-13 | Claim invitation | Platform | Common | … | | Paste/scan dashpay://invite → platform_wallet_claim_invitation → new identity + contact.` -- `DP-14 | Invite→claim e2e (two wallets) | Cross | Thorough | … | multiwallet | Create on A, claim on B, contact auto-establishes both ends (cf. DP-11).` -- `DP-15 | Reject malformed / already-claimed invitation | Platform | Uncommon | … | | Bad link + reused link both fail loudly, no side effects.` -(Secondary: the `AI_QA/` MCP playbooks — add a `QA004`-style invite→claim walkthrough if useful.) - ---- - -## 5. The three technical cruxes (de-risked first-hand; §11 spikes confirm) - -### 5.1 Proof type — DECIDED: InstantSend (owner decision 2026-07-08) -`AssetLockProof` has two variants with very different self-containment (confirmed -`asset_lock_proof/mod.rs:40`): -- **`InstantAssetLockProof { instant_lock, transaction, output_index }`** — embeds the **full - funding tx + the InstantLock**. Self-contained (Platform validates the islock against the - embedded tx). This is what the **reference iOS/Android wallets export** (`islock` + they carry - the txid and re-fetch the tx). Fast to produce (just wait for the IS lock). **Risk:** Platform - rejects an islock whose quorum has rotated or is too old relative to Platform's core height - (`is_instant_lock_proof_invalid` + the IS→CL retry in `registration.rs`). An invitation that - sits **unclaimed** for a long time can go stale. -- **`ChainAssetLockProof { core_chain_locked_height, out_point }`** — tiny (outpoint + height); - Platform resolves the tx from Core by outpoint. **No staleness window** (chain-locked is - permanent), so an unclaimed invitation stays valid indefinitely. Cost: the inviter waits for a - ChainLock at create (≈ up to a block or two, low-minutes). - -**DECISION (owner, 2026-07-08): export an InstantSend proof.** Faster create (no CL wait), matches -the reference wallets, and the `InstantAssetLockProof` embeds the full tx + islock so the link is -fully self-contained (the invitee never fetches anything from L1). `create_funded_asset_lock_proof` -returns exactly this for a fresh tx (its `validate_or_upgrade_proof` only upgrades to CL when the -tx is *old* — not the case at create), so the invitation path **keeps the IS proof, no forced CL -upgrade**. - -> **Slow-IS fallback must be enforced (Rust-core review H1).** `create_funded_asset_lock_proof` -> *also* falls back to a ChainLock proof if the IS lock doesn't propagate within its 300s -> preference window. Since the invitee's `validate_claimable` accepts only an InstantSend proof, -> `create_invitation` **must reject a returned ChainLock proof** — else it would emit a -> `dashpay://invite` link the invitee silently rejects (a dead voucher: funds locked, no signal). -> On this rare path create returns a clear error; the funding lock stays tracked/reclaimable, and -> the inviter retries. *(A future robustness option is to accept a Chain proof on claim too — -> it never goes stale — skipping the local credit-output pre-check since a Chain proof carries no -> embedded tx; deferred, as it deviates from the literal Instant-only decision.)* - -**Staleness mitigation = a short, IS-scoped advisory expiry (not an IS→CL upgrade in v1).** The -one real risk is that Platform rejects a *stale* islock (quorum rotated). Rather than build an -invitee-side IS→CL upgrade (which needs the embedded tx re-tracked — non-trivial, and the -external-proof `UseAssetLock` path was deliberately removed), v1 sets the invitation's advisory -`expiry` conservatively **inside the IS validity window** (default ~24h, ≤ `MAX_INVITATION_TTL`): -the claim path refuses a past-expiry link up front with a clear "invitation expired — ask the -sender for a new one," so an about-to-go-stale proof is never submitted. Cheap, no fund risk (the -inviter simply re-creates), and the inviter's asset lock is reclaimable after expiry. **Future -enhancement (not v1):** an invitee-side IS→CL upgrade from the embedded tx to extend the window to -days/weeks. *(Note: this makes the create FFI's identity-signer moot as before, and the claim's -`submit_with_cl_height_retry` wrapper — feasibility Note A — still applies to the IS submit.)* - -### 5.2 Claim is ordinary identity registration with imported funding -`put_to_platform_and_wait_for_response_with_private_key(proof, voucher_key, identity_signer)` -already does exactly what claim needs. The invitee's identity keys come from the invitee's own -seed (normal registration); only the **funding** `(proof, voucher_key)` is imported. **No new -SDK code for the core claim.** The `identity_invitation` account is an inviter-only concept — -the invitee never derives sub-feature `3'`. - -### 5.3 Exporting the voucher private key is a deliberate bearer-credential export -The architecture's invariant is "private keys never cross the FFI boundary as raw bytes," and -the signer-driven builder deliberately **withholds** the credit-output private key (it returns -`AssetLockCreditKeys::Public((pubkey, path))`, `build.rs:117`). The invitation **is** a raw-key -handoff (the whole point), so exporting it is a scoped, documented exception — exactly like the -auto-accept `dapk` blob, which already exports a bearer private key in a QR. - -**Key choice:** **HD-derived at `m/9'/coin'/5'/3'/index'`** (not a JS-style random key). HD makes -it DIP-13-recoverable — the wallet can re-derive/scan unclaimed invitation funding txs and let -the user reclaim/resend (DIP-13's explicit recommendation) — at the cost of needing an export -step. (A random ephemeral key, JS-SDK precedent `createAssetLockTransaction.ts:26`, exports -trivially but is unrecoverable; rejected.) - -**Export = a NEW seedless resolver hook, path-gated to the exact invitation sub-feature -(security review Finding 2 — normative).** The create FFI is **seedless** (it drives a -`MnemonicResolverCoreSigner`, not a resident `Wallet`), so there is no `&Wallet` to -`derive_extended_private_key` on for the real host — v1 must add a raw-scalar export on the -resolver, exactly mirroring the sanctioned precedent -`export_auto_accept_private_key(&path) -> SecretKey` (`mnemonic_resolver_core_signer.rs:353`, -`ContactCryptoProvider` `contact_requests.rs:63`). **The new `export_invitation_private_key(&path)` -MUST gate on the full path** `comps.len()==5 && comps[0]==9' && comps[2]==5' && comps[3]==3'` — -**not** merely `comps[2]==5'`, because feature `5'` is shared with identity-registration -(`5'/0'`,`5'/1'`), top-up (`5'/2'`), etc.; a loose gate would let a caller exfiltrate the user's -**own** identity-funding keys. Add a negative test mirroring -`export_auto_accept_private_key_gates_to_the_auto_accept_path`. - -**Never persist the key.** Because it is HD-derived, the inviter re-derives it from the seed -whenever it re-packages or reclaims. Storage tracks only funding index + outpoint (§4.2). The -returned URI (which *contains* the plaintext key) is treated as a secret end-to-end: no logging, -no analytics, sensitive-pasteboard flag on the Swift side (§8 Finding 3). - -> **This export hook is v1 critical path, not a follow-up (feasibility Finding 5, BLOCKING).** -> Production/example-app wallets are **seedless at steady state** (`Wallet::new_external_signable`, -> no root key — `persistence.rs:158-163`); only the *first-ever* session has a resident seed. So -> the "derive from a resident `Wallet`" idea is a **dead end**: create a wallet Monday (seed -> resident), relaunch Tuesday (external-signable) → tap "Create invitation" → -> `wallet.derive_extended_private_key(path)` errors and the existing -> `export_auto_accept_private_key` rejects the `5'/3'` path (it gates to `16'`), so **no link can -> be produced.** The fix is the new gated `export_invitation_private_key` on -> `MnemonicResolverCoreSigner` + a `ContactCryptoProvider`-style method (seedless + seed impls, -> cf. `contact_requests.rs:63/188`) + its FFI — a dedicated implementation slice (§13 slice 2). - ---- - -## 6. The `dashpay://invite` link envelope — a single versioned blob - -> **SUPERSEDED (2026-07-13, §0.1):** the shipped envelope is the legacy query format -> (`du`/`assetlocktx`/`pk`/`islock` — see §0A), not this blob. -> Kept for the design rationale it records (secret handling, caps, transport notes still apply). - -**Decision: one opaque, versioned payload** behind a `dashpay://invite?data=` -deep link (keeping the reference's `dashpay://invite` scheme name for familiarity), **not** the -reference's six loose query params. Rationale in §7. The payload is a small versioned blob in a -**hand-rolled little-endian binary encoding** (deliberately *not* serde/bincode — the crate's -`serde` feature is optional and off, and `AssetLockProof` is internally-tagged so bincode-serde -rejects it), so the envelope can evolve without breaking older links. The as-built wire order -(see `crypto/invitation.rs`) is: - -```text -wire = version:u8 // = 0 - ‖ voucher_key:[u8; 32] // one-time ECDSA private key (secret; zeroized) - ‖ expiry_unix:u32(LE) // ADVISORY, IS-scoped (§5.1); not consensus - ‖ inviter_present:u8 // 0 = none, 1 = InviterInfo follows - [ identity_id:[u8; 32] - ‖ username:len-prefixed // DPNS name (whom the invitee's contactRequest targets) - ‖ display_present:u8 [ display_name:len-prefixed ] ] - ‖ asset_lock:len-prefixed // InstantSend proof (§5.1) — embeds tx + islock; LAST, length-prefixed - // NO auto-accept dapk — v1 sends a normal contactRequest, invitee-confirmed (§2) -``` -- Serializing the `InstantAssetLockProof` directly means the link **embeds the full funding tx + - islock**, so the invitee needs **no L1 tx fetch** (an improvement over the reference, which - carried only the txid). Link size is a few hundred bytes → base58 ~a few hundred chars: fine - for a deep link and a QR. -- **Length-cap the `data=` param before decode (§8 Finding 5, LOW).** The base58-**char** cap on - the input *before* decoding is the DoS mitigation (mirrors the `dapk` cap in - `parse_dashpay_contact_uri`). Note: `AssetLockProof`'s consensus bincode decode is **already - bounded and panic-free** on arbitrary bytes (dashcore `MAX_VEC_SIZE`, finite cursor, all - `Result`-based — verified), so the residual is only "a huge blob is fully buffered," which the - pre-decode char cap closes. A fuzz test is cheap insurance, not a blocker. -- The codec pair in `crypto/invitation.rs` is - `encode_invitation_uri(voucher_key: &SecretKey, asset_lock: &AssetLockProof, expiry_unix: u32, - inviter: Option<&InviterInfo>) -> Result` and - `parse_invitation_uri(uri: &str) -> Result`, fully unit-tested (round-trip + - every malformed rejection). A plain `https://…` fallback host can wrap the same `?data=` for - users without the app installed — deferred (no hosting in v1; see §6.1). - -### 6.1 Transport security & the custom-scheme limitation - -The `data=` payload is a **bearer credential**: whoever reads the plaintext link controls the -voucher and can claim (front-run) it. Because the app registers the `dashpay://` **custom URL -scheme**, any other app that also registers `dashpay` can intercept an invite link on the same -device and steal the claim. The load-bearing mitigation is therefore **economic, not transport**: -`MAX_INVITATION_DUFFS` caps the loss at 0.26 DASH, and the inviter can reclaim an unclaimed voucher -(best-effort race). The advisory expiry does **not** bound a leak (a leaked-link holder ignores it). - -A hardened production transport would use **Universal Links** (HTTPS + a hosted -`apple-app-site-association`, `associated-domains` entitlement) or another verified handoff so the -OS can't hand the link to an impostor app. That is **out of scope for this example app** — it -needs hosting infrastructure the sample doesn't have, and the amount cap already bounds the blast -radius — but it is the recommended path for the production wallet and is tracked as a follow-up. -The `?data=` shape is transport-agnostic, so moving from the custom scheme to a Universal Link is a -routing change, not an envelope change. - ---- - -## 7. Interop decision — ~~RESOLVED: ship our own self-contained envelope~~ - -> **REVERSED (2026-07-13, §0.1):** the as-built codec adopts the reference wallets' legacy -> query format for field-level cross-claimability with dash-wallet iOS/Android. The analysis -> below (dead FDL delivery, JS SDK never had invitations) remains accurate — only the -> conclusion changed, by owner decision, once cross-wallet claimability was prioritized. -Research (research-reference, primary sources) settled this: -- The production iOS (DashSync) + Android (dash-wallet) wallets use an **identical plaintext - URL-query payload**: `du` (username), `display-name`, `avatar-url`, `assetlocktx` (**txid - only**, 64-hex), `pk` (**WIF** private key), `islock` (hex InstantLock). The invitee **fetches - the full funding tx from L1 by txid**, then registers using the embedded islock. -- That link was distributed via **Firebase Dynamic Links**, which **Google shut down - 2025-08-25** — the hosted `invitations.dashpay.io/link` short-links now **404**. So even the - production wallets' *share layer is already broken* and must be reworked. -- **The JS SDK never had an invitation API** — invitations existed only in the two native apps. - -**Conclusion:** there is little value matching a legacy wire format whose delivery mechanism is -dead. We ship our own **self-contained, versioned** envelope (§6). The **only** things we must -NOT diverge on are the **on-chain / consensus** semantics — the DIP-13 `3'` derivation and the -islock / asset-lock-proof shapes Platform consensus accepts — because those are what actually -interoperate. This mirrors the auto-accept spec's "iOS-first, DIP-faithful where defined, -normative-for-us where silent" stance. (If byte-interop with a future reworked DashWallet is ever -required, matching is a localized codec change; the on-chain acts already interoperate.) - ---- - -## 8. Security -*(Folds a 4-lens security review: no CRITICALs — the core crypto is sound; findings are must-fix -hardening + honest-framing fixes. Verified-clean floor: in-flight IdentityCreate is -non-malleable, double-claim is deterministic, the invitee never risks its own funds.)* - -- **Consensus trust anchor (why this is safe at all).** Platform validates the IdentityCreate's - outer signature against the **asset-lock output's P2PKH public-key hash** - (`identity_create/state/v0/mod.rs:222-245`) and the identity id is `hash(outpoint)` — so a - network observer who does *not* hold the voucher key cannot swap in their own keys and steal an - in-flight claim, and two racers target the *same* id (consensus commits exactly one). Every - claim-theft attack reduces to **"who holds the link."** The invitee's own identity keys sign - the per-key witnesses separately. -- **Bearer credential — the load-bearing leak mitigation is the amount cap + reclaim, NOT the - expiry (Rust-security-review LOW-2 honesty fix):** - - **Amount cap enforced in Rust (Finding 4).** `create_invitation` rejects - `amount_duffs > MAX_INVITATION_DUFFS` — the *actual* bound on a leaked link's blast radius - (a direct FFI caller / headless host / UI bug can't exceed it). Never UI-only. - - **Expiry is a UX / reclaim signal, not a leak bound.** A malicious *finder* of a leaked link - holds the voucher key + proof and can submit directly, **ignoring the honest UI's expiry - check** — so `expiry_unix` does not bound a leaked-link window. What it *does* do: (a) stop an - **honest** invitee from submitting an about-to-go-stale IS proof (§5.1), and (b) give the - inviter a clear reclaim-after signal. Advisory, not consensus. (The FFI sets a sensible - default expiry from `MAX_INVITATION_TTL_SECS`; clamping it in Rust is symmetry, not security.) - - **Single-use** (asset lock consumed on first claim → deterministic reject thereafter), funds - are the inviter's to give; the inviter can race to **reclaim** an unclaimed voucher (a race it - can lose if already leaked — §8 Finding 6). -- **The link is plaintext key material — treat the URI as secret end-to-end (Finding 3).** The - create FFI returns the URI (which *contains* the voucher key) as a C string that flows through - Swift + a `dashpay://invite` deep-link handler (handlers routinely log URLs) + clipboard - (iOS Universal Clipboard syncs across devices) + the share sheet. Requirements: **no logging / - no analytics** of the URI; secret/`Zeroizing` types Rust-side; a **sensitive-pasteboard** flag - Swift-side; the voucher key is **never persisted** (re-derived from `funding_index`, §5.3). -- **Inviter self-claim / front-run is a real griefing/DoS vector against the invitee (Finding 6 — - honesty fix).** *Not* "no third-party risk." The inviter can front-run or reclaim after handoff, - denying the invitee onboarding mid-flow with no signal it was the inviter's doing. No fund theft - (funds are the inviter's), but real denial. Likewise **"reclaim = revocation" is a race the - inviter can lose** if the link already leaked — reclaim is best-effort, and the advisory expiry - is the actual bound. Documented as an accepted, honestly-stated limitation. -- **Untrusted proof on claim — validate before submit (Finding 5, LOW after re-verify).** The - `AssetLockProof` bincode decode is already bounded/panic-free; the §6 pre-decode length cap is - the DoS mitigation (keep it). The genuinely useful part is **fail-fast UX, not a security gap**: - cheap **local pre-submit checks** — the proof is an **Instant** proof (§5.1), the advisory - expiry is not past, and the **voucher pubkey-hash ∈ the selected credit output** - (`proof.output() → credit_outputs[output_index]`) — so a malformed/hostile/stale link fails with - a clear error instead of an opaque consensus reject. The - credit-output-pubkey binding is itself consensus-enforced, so this cannot be *bypassed* to steal; - it only improves the error. -- **Unauthenticated envelope (Finding 7 — documented, no v1 fix).** Nothing signs the bundle, so a - MITM on the *link channel* can substitute the whole invite. Blast radius is limited (the - contact only forms toward whatever inviter identity is in the link; an attacker can at most make - the invitee contact the attacker's own identity — achievable with a normal contact request - anyway). Reduces to "bearer-link trust = channel trust"; envelope signing wouldn't help (the - channel is the trust root). -- **Privacy (Finding 8, LOW).** Because id = `hash(outpoint)`, the inviter knows the invitee's - future identity id before they claim, and that id is inviter-chosen. Noted. -- **Malformed / hostile link:** every field size-capped before decode; a bad link fails loudly - with no side effects. - ---- - -## 9. Decisions (RESOLVED — owner, 2026-07-08) -1. **Proof type: InstantSend** (§5.1). Fast create, self-contained link; staleness covered by a - short IS-scoped advisory expiry (claim refuses past-expiry), not an IS→CL upgrade in v1. -2. **Contact-bootstrap: opt-in on both ends.** Inviter ticks "send a contact request back to me" - (→ inviter info in the link); the invitee is *asked* "establish contact with \?" at - claim and only then is a normal contactRequest sent. In v1. No auto-accept dapk (§8 Finding 1). -3. **Inviter persistence: proper wallet-persister integration** (§4.2) — a first-class - `invitations` table + changeset + `PersistentInvitation` SwiftData model, not a KV blob. In v1. -4. **Link scheme:** our own self-contained versioned blob (§7). -5. **Amount / TTL:** Rust-enforced `MAX_INVITATION_DUFFS` (default a sensible identity-reg + - small-balance amount; confirm exact duffs during spikes) and `MAX_INVITATION_TTL` bounded to - the **IS validity window** (default ~24h) since the proof is InstantSend. - ---- - -## 10. Failure modes -- **Insufficient inviter balance to fund the lock** → create fails pre-broadcast, funds - untouched (reservation released — existing `create_funded_asset_lock_proof` rejection path). -- **InstantSend lock never arrives at create** → `create_funded_asset_lock_proof`'s 300s IS wait - elapses and (for a fresh tx) it surfaces an error; the tracked lock is resumable (inviter can - retry or reclaim). We do **not** force a CL upgrade (§5.1). -- **Stale IS proof (claimed too late)** → the advisory expiry makes the claim refuse *before* the - IS lock could be rejected by Platform; the inviter re-creates. (Extending the window via an - invitee-side IS→CL upgrade is a post-v1 enhancement.) -- **Invitee claims an already-claimed / inviter-front-run link** → Platform rejects (lock - consumed); claim returns a clear "invitation already used" error; no identity created. (This is - also the inviter-front-run griefing outcome, §8 Finding 6.) -- **Malicious inviter hands a mismatched/IS/expired proof** → caught by the claim pre-submit - checks (§4.1 step 1 / §8 Finding 5) → fail loud, no blind submit. -- **Claim interrupted after identity created but before contact-bootstrap sent** → the identity - exists (self-heals into the invitee's IdentityManager on next re-sync); the contact request is - re-sendable (idempotent — the send path adopts an existing friendship). Not a data-loss path. -- **Malformed / truncated / oversize link** → parse/size-cap error, no side effects. -- **Invitee has no seed / can't derive identity keys** → claim fails before any network act. -- **Voucher never claimed AND inviter loses seed (§8 Finding 9, LOW)** → L1 Dash stranded in the - lock (asset locks are one-way). Mitigated by HD re-derivation from `funding_index` — this stays - a generic "lost your seed" problem, not invitation-specific. - ---- - -## 11. Spikes (before implementation — task #11) -1. **S1 — raw-key claim end-to-end (offline):** in a `rs-platform-wallet` integration test, - build an asset lock at `IdentityInvitation`, derive the voucher key, and drive - `put_to_platform_and_wait_for_response_with_private_key` against a mock/echo SDK to confirm - the proof + raw-key + invitee-identity-signer triple registers an identity. Confirms §5.2. -2. **S2 — seedless voucher-key export + path gate:** add `export_invitation_private_key(&path)` - on the resolver/provider mirroring `export_auto_accept_private_key` - (`mnemonic_resolver_core_signer.rs:353`), and prove the gate: it exports for - `9'/coin'/5'/3'/idx'` and **rejects** `9'/coin'/5'/0'/…` (identity-auth), `…/5'/1'/…` (reg - funding), `…/5'/2'/…` (top-up) — the Finding-2 negative test. Confirms §5.3. -3. **S3 — create keeps the IS proof + persistence round-trip:** confirm - `create_funded_asset_lock_proof(IdentityInvitation)` returns an **Instant** proof for a fresh - tx (no auto-upgrade), and that an `InvitationChangeSet` round-trips through the persister - (`created` row readable back). Confirms §5.1 + §4.2. -4. **S4 — link envelope codec:** implement + unit-test `encode/parse_invitation_uri` - (round-trip + malformed) — cheap, do first. - ---- - -## 12. Test / verification plan -- **Rust unit:** invitation URI codec (round-trip + every malformed rejection incl. the - pre-decode length cap); voucher blob round-trip; the **export-path-gate negative test** (§5.3 / - S2, Finding 2 — the blocking one: exports `5'/3'`, rejects `5'/0'`,`5'/1'`,`5'/2'`,`16'`); - create-invitation **rejects `amount > MAX_INVITATION_DUFFS` and `expiry > now+MAX_TTL`** - (Finding 3/4); claim **pre-submit checks reject** a non-Instant proof and a voucher-pubkey ∉ - credit-output (Finding 5 — fail-fast); expired-link rejection. (Optional insurance: a fuzz test - that `parse_invitation_uri` on arbitrary bytes never panics — not a blocker, decode is already - bounded.) -- **Rust integration (`rs-platform-wallet`):** the S1 offline flow as a permanent test; the - create→export→re-derive-from-`funding_index` round-trip (recovery); reclaim-unused path. -- **FFI:** null/oversize/bad-URI input validation; create→parse round-trip; claim marshaling - (identity handle inserted, id out); assert the URI is not emitted to logs. -- **Swift:** `build_ios.sh` green; wrapper unit tests for encode/decode boundaries. -- **Testnet funded e2e (task #13):** fund an inviter wallet via the **built-in faucet** - (Wallet → Receive → "request from testnet", `TestnetFaucetService` → `faucet.thepasta.org`) → - register the inviter identity + DPNS name → `create_invitation` → parse the link in a **second** - wallet with no funds → `claim_invitation` → assert the invitee identity exists on Platform and - (if bootstrap) the contact auto-establishes after the inviter's drain. This is the acceptance - gate. Can run headless (Rust integration against testnet) and/or two-simulator on-device. -- **On-device (two sims):** create on sim A, claim on sim B, contact appears on both. -- **QA contract:** the scenarios from §4.5. - ---- - -## 13. Commit slicing (implementation order) -1. `crypto/invitation.rs` codec (payload struct + `encode/parse_invitation_uri` + length cap) + - tests (S4). -2. **Voucher-key export (v1 critical path — feasibility Finding 5):** gated - `export_invitation_private_key` on `MnemonicResolverCoreSigner` (gate `9'/coin'/5'/3'/idx'`) + - `ContactCryptoProvider`-style method (seedless + seed impls) + the path-gate negative test (S2). - Without this the seedless host cannot produce a link at all. -3. `network/invitation.rs` create (slice-2 export + keep IS proof + amount/expiry caps) + claim - (raw-key submit wrapped in CL-height retry + Instant-proof pre-submit checks + optional - invitee-confirmed contactRequest) helpers + unit tests (S1). -4. **Inviter persistence (§4.2):** `invitations` migration + `InvitationChangeSet` + status sync. -5. FFI `platform_wallet_create_invitation` (core signer only) / `_claim_invitation` - (`establish_contact` param) + tests (marshaling mirrors `identity_registration_funded_with_signer.rs`). -6. swift-sdk wrappers on `ManagedPlatformWallet` + `PersistentInvitation` SwiftData model (**via - `swift-rust-ffi-engineer`**). -7. SwiftExampleApp: create sheet (amount + "send request back" checkbox), claim sheet (with the - "establish contact with \?" prompt), `InvitationsView` list, + `dashpay://invite` - deep-link handler (`Info.plist` scheme + `.onOpenURL`). -8. QA-contract rows (TEST_PLAN.md §4.10 DP-12+). -9. Testnet e2e evidence + docs (`SPEC.md` Milestone 5 as-built, `DIP_CONFORMANCE_GAPS.md` row). - ---- - -## 14. Multi-agent spec-review resolutions (2026-07-08) -Four research streams (wallet/SDK/Swift/reference) + three adversarial spec reviews -(feasibility / security / scope). Folded: -- **Feasibility — core mechanic CONFIRMED** (claim independence proven at `v0_methods.rs:65-78`; - create/CL/FFI confirmed). **One blocker: seedless voucher-key export** — the resident-`Wallet` - idea is a dead end (production wallets are `new_external_signable`); promoted to **v1 critical - slice 2** (§5.3, §13). Should-fixes folded: bounded CL wait (§5.1/§4.1), claim submit wrapped in - CL-height retry (§4.1), create FFI drops the spurious identity signer (§4.3). -- **Security — no CRITICALs.** Two blockers folded: (1) the **dapk TTL contradiction** → - auto-accept dropped, plain contactRequest bootstrap (§2); (2) **export path-gating** to - `9'/coin'/5'/3'/idx'` with a negative test (§5.3). Hardening folded: Rust amount cap, advisory - voucher expiry, secret/no-log URI (§8 Finding 3/4); honesty fixes (self-claim = griefing/DoS, - reclaim = a race — §8 Finding 6). Proof-parse worry **downgraded to LOW** on re-verify (bincode - is already bounded; the length cap is the mitigation; pre-submit checks are fail-fast UX). -- **Reference/interop** — the production link format is dead (FDL shutdown); ship our own - self-contained versioned envelope, preserve only on-chain semantics (§7). -- **Scope** — scope levers threaded (single versioned blob §6; reuse over new code throughout). -- **Owner decisions (2026-07-08, sync gate):** (1) **InstantSend** proof, not ChainLock — - staleness handled by a short IS-scoped expiry (§5.1); (2) contact-bootstrap **opt-in on both - ends** — inviter checkbox + invitee "establish contact?" prompt (§2, §4.1); (3) **proper - wallet-persister** integration for invitations, not a KV blob (§4.2). All in v1. diff --git a/docs/dashpay/DIP_CONFORMANCE_GAPS.md b/docs/dashpay/DIP_CONFORMANCE_GAPS.md deleted file mode 100644 index 530c6bbea8c..00000000000 --- a/docs/dashpay/DIP_CONFORMANCE_GAPS.md +++ /dev/null @@ -1,359 +0,0 @@ -# DashPay — DIP-15 + DIP-16 conformance gaps (code-verified audit) - -> **Purpose.** A from-scratch re-audit of the DashPay implementation against the -> canonical [DIP-15](https://github.com/dashpay/dips/blob/master/dip-0015.md) -> (DashPay) and [DIP-16](https://github.com/dashpay/dips/blob/master/dip-0016.md) -> (Headers-First SPV synchronization — DIP-15 §12 is built on it), cross-checked -> against the **actual code** on `feat/dashpay-m1-sync-correctness` (not the -> self-reported status in `SPEC.md`/the backlog (now dashpay/platform#4020)). The goal was to catch anything the -> DIPs require that is **missing, stubbed, or only partially wired**, and to -> separate genuine gaps from deliberate divergences. -> -> **Date:** 2026-06-24. **Method:** six parallel code-reading passes (xpub/ECDH/ -> key-purpose; accountReference/multi-account/DoS/label; coreHeight/block-rescan/ -> sync-window; profile/contactInfo/DPNS; dash-spv rescan capability; full DIP-16 -> 9-step sync ordering), each citing `file:line`, plus direct verification of the -> contested findings. SPV evidence is from the pinned `dash-spv` rev `b4779fc` -> (`rust-dashcore`), which the platform-wallet drives via `spv/runtime.rs`. -> -> **Headline.** The DashPay (DIP-15) core flow **fully conforms** and is in places -> *ahead* of the reference clients. The SPV layer (DIP-16) implements the hard -> parts for real (headers-first + checkpoints + masternode-list/quorum -> verification + compact filters) but **deliberately diverges** from the DIP's -> literal phasing (event-driven parallel managers, BIP157 instead of BIP37 for the -> confirmed path, L2 decoupled from L1). Only **two** DIP-15 gaps are -> under-tracked (one a real incoming-payment-loss risk); the rest are correctly -> tracked as deferred (blocked on external resources) or are well-reasoned -> divergences. The §12.6 block-rescan gap (§1.1) turns out to need only a small -> wallet-side trigger — the rescan engine already exists in dash-spv. - ---- - -## 0. Conformance matrix (by DIP-15 section) - -| DIP-15 area | § | Verdict | Evidence | -|---|---|---|---| -| Encrypted xpub = 69-byte compact `fp(4)‖cc(32)‖pk(33)` → 96-byte ciphertext | 8.6 | ✅ **FULLY** | `rs-platform-encryption/src/compact_xpub.rs` (`COMPACT_XPUB_LEN=69`); send asm `network/contact_requests.rs:480-491`; SDK 96-byte assert `rs-sdk/.../contact_request.rs:311-316`; KAT `dip14.rs::compact_xpub_is_69_byte_dip15_plaintext_not_107_byte_encode` | -| ECDH `SHA256(((y&1)|2)‖x)` | 8.3 | ✅ **FULLY** | `rs-platform-encryption/src/ecdh.rs:16-26` + hand-recomputed KAT `:56-85` | -| `senderKeyIndex`/`recipientKeyIndex` purpose policy (liberal receive, ENCRYPTION send fallback, no permanent break on purpose mismatch) | 8.3 | ✅ **FULLY** | send sel `contact_requests.rs:846-871`; validator `crypto/validation.rs:141-251`; purpose-only ≠ broken `:90-92` + drain `:1743-1764` | -| Friendship path `m/9'/coin'/15'/0'/owner256/cp256/index`, DIP-14 256-bit non-hardened CKD | 8.9 | ✅ **FULLY** (account 0) | `crypto/dip14.rs`; byte-identical to dashj per `INTEROP_DESK_CHECK.md` | -| `profile` (displayName/publicMessage/avatarUrl/avatarHash/avatarFingerprint) | 9 | ✅ **FULLY** | `types/dashpay/profile.rs:85-123` — real SHA-256 hash **and** real 8-byte dHash; non-destructive update `network/profile.rs:319-378` | -| Batched profile fetch `$ownerId in [ids]` (counterparties of new requests) | 9.10 | ✅ **FULLY** | `network/profile.rs:738-812` (`In` + required `orderBy`) | -| `contactInfo` (ECB `encToUserId`, CBC `privateData`, `65536'/65537'`, ≥2-contacts gate, varint privateData) | 10 | ✅ **FULLY** | `crypto/contact_info.rs:45-48,232-283`; `rs-platform-encryption/src/contact_info.rs`; gate `network/contact_info.rs:548-556` | -| `accountReference` value + version-bump rotation on re-send | 7, 8.4 | ✅ **send** / ⚪ **receive ignores (by design)** | `account_reference.rs:41-51`; version bump `contact_requests.rs:514-547` | -| `$createdAt` incremental fetch with 10-min skew back-off | 8.8, 8.12 | ✅ **FULLY** | `SYNC_OVERLAP_MS=600_000` → `contact_requests.rs:770-776`; `StartAfter` paging `contact_request_queries.rs:54-108` | -| `$createdAtCoreBlockHeight` populated | 8.7 | ✅ **FULLY** | server-side `document_create_transition/v0/mod.rs:253-256`; client sends `None` `rs-sdk/.../contact_request.rs:478` | -| DPNS name↔identity resolve/search/cache | 11 | 🟡 **PARTIAL** | works (`network/dpns.rs:281-362`); QR-build doesn't fall back to on-chain name | -| **L1 block re-scan from `min(coreHeightCreatedAt)` on new contact** | **8.7, 12.6** | ❌ **MISSING** | never read to drive a rescan; SPV exposes no rescan entry point | -| `encryptedAccountLabel` (48–80B, padded, decrypted) | 8.5 | ✅ **FULLY** | send length-normalized in the crypto primitive (`account_label.rs`); receive decrypted + surfaced via `store_contact_account_label` (incoming-only) → `ContactDetailView` (SPEC.md Milestone 3) | -| `acceptedAccounts` + first-request bloom gating / flood mitigation | 8.4, 10.8 | ❌ **MISSING** | codec only; unpopulated + dropped on ingest | -| Multi-account contacts (`Account ≠ 0`) | 7.1, 8.9 | 🟡 **DEFERRED** | `account_index` hardcoded `0`; blocked on upstream | -| QR auto-accept (`autoAcceptProof`, `m/9'/5'/16'/expiry'`, BIP21/72 URI) | 8.13 | ✅ **FULLY** (iOS-first) | `crypto/auto_accept.rs`; see `QR_AUTO_ACCEPT_SPEC.md` | -| Invitations (asset-lock voucher + claim onboarding, DIP-13) | — | ❌ **NOT STARTED** | queued as "NEXT" in the backlog (dashpay/platform#4020) | - ---- - -## 1. Under-tracked gaps (the value of this audit) - -### 1.1 🔴 No L1 block re-scan from `coreHeightCreatedAt` on new contacts — DIP-15 §8.7 + §12.6 - -**Status: MISSING and not mentioned anywhere in the existing docs.** This is the -only finding with an incoming-**payment-loss** character. - -DIP-15 §8.7 / §12.6 require: when a wallet learns of a new contact request, it must -**resynchronize L1 blocks from the minimum `$coreHeightCreatedAt`** across the new -requests *after* inserting the new address spaces into its filters, so it doesn't -miss payments sent in the device-sync-speed-skew window (a payment that landed on a -DashPay address before that address was being watched). - -What the code actually does: -- `$createdAtCoreBlockHeight` **is** captured and persisted on every request - (`types/dashpay/contact_request.rs:38`), but is **never read** to drive a - re-request. No "minimum across new contacts" is computed anywhere. -- Both account-registration paths — `register_external_contact_account` - (`network/contacts.rs:389`) and `register_contact_account` (`:140`), called from - the G1b sweep at `network/contact_requests.rs:1630,1820` — watch **forward only** - and aren't even passed the height. -- A newly registered contact's addresses **do** enter the compact-filter match set - (`monitored_script_pubkeys` enumerates `all_accounts()`), but only from the - current scan pointer forward — nothing rewinds the pointer to backfill. - -Consequence: the `G1(b)` sync fix rebuilds the address *watch* on restore-from-seed, -but does **not** backfill *history*. An incoming DashPay payment that arrived before -the receiving account was (lazily) registered — restore-from-seed, second device, or -the offline-accept→pay window — can be silently missed until some unrelated full -rescan happens to cover it. - -**The fix is small — the rescan engine already exists.** dash-spv's `FiltersManager` -already performs a targeted backfill rescan whenever a wallet's `synced_height` drops -below the filter scan pointer: `tick` calls `wallets_behind(committed)`, takes the -min stale height, runs `reset_for_rescan()` + `start_download()`, and re-downloads -BIP157 filters from there, re-matches against the now-larger script set, and -re-requests the matching blocks (`dash-spv .../sync/filters/sync_manager.rs:213-236`, -`manager.rs:129-139`). So DIP-15 §12.6 is **a wiring task, not an SPV build**: -1. **platform-wallet (the actual gap):** when the G1b sweep registers a new DashPay - account, lower that wallet's `synced_height` to - `min($coreHeightCreatedAt over the just-built accounts) − 1`. The height is - already on the `ContactRequest`; the existing `FiltersManager` does the rest. -2. **one small upstream piece (`key-wallet-manager`):** `WalletInterface:: - update_wallet_synced_height` is **forward-only by contract** — "a value below the - current is silently ignored" (`wallet_interface.rs:127-129`). A backward rescan - needs a new guard-bypassing method (e.g. `reset_wallet_synced_height_to(id, h)`), - a small upstream change in the vein of rust-dashcore#813. (Optionally expose a - thin `DashSpvClient::rescan_wallet_from(id, h)` convenience wrapper; the - `SpvRuntime` would forward it.) - -Constraints to respect: the backfill floor is the checkpoint the headers were seeded -from (`manager.rs:192`), and the BIP157 filter-headers/filters for that range must be -re-downloadable from peers. Per DIP-15 §12.6, re-request slightly beyond the minimum -height and avoid re-requesting the final ~10 blocks near the tip. It is a genuine -correctness gap, but a contained one — see §6.4 for how it relates to the DIP-16 -filter layer. - -### 1.2 🟡 `encryptedAccountLabel` — the "DONE" padding fix is dead code - -**Status: PARTIAL, and it contradicts a backlog ("DONE + tests pin it") claim (now dashpay/platform#4020).** - -The backlog P1 item records label padding to ≥16 chars (commit `2419159bb3`) as done. In -reality: -- The padded helper `IdentityWallet::encrypt_account_label` + `pad_account_label` - (`network/account_labels.rs:19,49-64`) has **zero live callers** (verified by grep; - only its own unit tests reference it). -- The **live** path — FFI `platform_wallet_send_contact_request_with_signer` - (`rs-platform-wallet-ffi/src/dashpay.rs:236-269`) → `send_contact_request_with_external_signer` - (`network/contact_requests.rs:374`) → `sdk_writer` → rs-sdk — passes the host label - **raw**. The SDK encrypts it unpadded and hard-rejects `<48 || >80` bytes - (`rs-sdk/.../contact_request.rs:319-330`). A **1–15-character label therefore errors - the entire contact-request send** (16-byte plaintext block → 16 ciphertext + 16 IV = - 32 < 48). The FFI accepts a label, so this is reachable, not theoretical. -- The label is **never decrypted on receive**: the ingest path stores - `encrypted_account_label` as raw bytes (`contact_requests.rs:2515`) and nothing calls - `decrypt_account_label` (also dead code in `account_labels.rs:78-107`). The field is - effectively write-only. - -A later refactor (the seedless `ContactCryptoProvider`/`sdk_writer` seam) appears to -have orphaned the padded helper. - -**Resolution (2026-06-24) — send side ✅ fixed; receive surfacing 🟡 remaining.** -The DIP-15 length normalization now lives in the single primitive -`platform_encryption::{encrypt,decrypt}_account_label`: a short/empty label is -space-padded to clear the 48-byte floor **and** an over-long label is truncated (on a -char boundary) to stay under the 80-byte cap — so **no** host-supplied label can error -the broadcast anymore (the review caught that the floor fix alone left a symmetric -`>80` long-label failure). The dead `network/account_labels.rs` helper was deleted (it -duplicated the convention). Red→green test -`account_label_is_always_a_valid_48_to_80_byte_field` pins both bounds + multi-byte + -the exact-48 boundary. - -**Receive-side surfacing — RESOLVED (2026-06-24, 5-lens reviewed; folded into SPEC.md -Milestone 3).** The label is now decrypted in Rust at the two signer-bearing -register sites (drain `RegisterExternal` Ok-branch + `accept_register_external_validated`, -where the ECDH `shared` already lives) and stored on -`EstablishedContact.contact_account_label`. It is **direction-specific** — derived -strictly from the *incoming* request and projected onto the **incoming FFI row only** -(the outgoing row's label is one *we* sent and is never surfaced), so it does **not** -copy the symmetric `alias`/`payment_channel_broken` both-rows pattern. Decrypt -failures / non-printable garbage coerce to `None` (cosmetic — never breaks the -channel); rotation pre-clears the field so it never goes stale. Surfaced through -`ContactRequestFFI.contact_account_label` → `PersistentDashpayContactRequest -.contactAccountLabel` → a read-only "Their account" row in `ContactDetailView`. -Backfill of pre-feature contacts deferred (dev-only; DashPay unreleased). - -**On-device UAT (paloma, 2026-06-25) found a SECOND, decisive bug + fixed it.** -The receive-side surfacing above had nothing to decrypt because the **recurring -sweep's ingest parser `parse_contact_request_doc` silently dropped -`encryptedAccountLabel`** (it read `encryptedPublicKey` + `autoAcceptProof` but not -the label). The send always attached the label and the decrypt was always correct — -the label just never reached the recipient's stored request. (This audit's earlier -"ingest works" claim cited the *sent*-request parser at `:2515`, missing that the -*received* path uses `parse_contact_request_doc`.) **Fix:** the parser now reads -`encryptedAccountLabel`; the sender's local bookkeeping also stores it off the -broadcast doc; and `AddContactView` gained an optional "Account label" field so -labels can be sent in-app. Unit tests missed the bug (they built the incoming -request *with* the label, bypassing the parser) — now pinned by -`parse_contact_request_doc_carries_encrypted_account_label` (red→green). **Verified -full e2e on paloma:** send (48-byte label on-chain) → fresh sweep ingest -(`enc=48`) → accept decrypt (`contactAccountLabel="Bob savings acct"`, incoming row -only / outgoing null) → ContactDetail shows "Their account: Bob savings acct". - ---- - -## 2. Tracked-and-deferred gaps (acknowledged; blocked on external resources) - -These are real DIP-15 gaps, but the existing docs already record them with a correct -blocker — not oversights. - -| Gap | DIP-15 § | Blocker | Doc ref | -|---|---|---|---| -| **True multi-account (`Account ≠ 0`)** — `account_index` hardcoded `0` at the only send site (`contact_requests.rs:476`); friendship path structurally `…/15'/0'/…`. (Key *rotation* via version-bump **is** live.) | 7.1, 8.9 | upstream `rust-dashcore#813` (honor the `index` field) | backlog dashpay/platform#4020 P1/P2 | -| **`acceptedAccounts` + §10.8 flood mitigation** — varint codec carries the field, but publish hardcodes it empty (`network/contact_info.rs:499-506`) and `set_contact_metadata` (`managed_identity/contact_requests.rs:289-299`) **drops** it on ingest. No "first request → bloom filter, additional → require acceptance" gating. | 8.4, 10.8 | query-level DoS filter needs a registered contract change | backlog dashpay/platform#4020 Contract track | -| **Cross-device ignore sync** — ignore is local-only; a per-sender `contactInfo` leaks the ignored target (timing correlation, R1). | 10.7 | needs an encrypted field on the `profile` contract (governance) | backlog dashpay/platform#4020 Contract track | -| **DPNS-name on-chain fallback in QR auto-accept build** — `build_auto_accept_qr` (`rs-platform-wallet-ffi/src/dashpay.rs:801`) uses the locally-cached name; empty for imported/devnet identities. `resolve_name` exists but isn't called from the QR path. | 11 | none (small follow-up) | backlog dashpay/platform#4020 P3 | -| **DashPay Invitations** — asset-lock voucher + claim onboarding (DIP-13 sub-feature `3'`). | — | new feature (L1 funding + identity registration + deep-link) | backlog dashpay/platform#4020 "NEXT" | -| **Devnet/testnet e2e + full add→approve→pay XCUITest** | 11 | funded test harness | backlog dashpay/platform#4020, `SPEC.md` Part 7 | - ---- - -## 3. Deliberate divergences (correct decisions, not bugs) - -- **`accountReference` ASK28 byte order** uses the **iOS** convention - (`be(ASK[28..32])>>4`); iOS and Android genuinely disagree, and the field is a - sender-private one-time-pad the **recipient ignores** (`unmask_account_reference` is - only ever called by the sender's own re-send path), so there is no on-chain interop - break. Documented + KAT-pinned. -- **Reject → reversible local-only `ignore`** (per-sender mute), matching Android's - Accept/Ignore model. No on-chain artifact (R1 privacy). -- **Retained 78/107-byte xpub `decode()` fallback** in `network/contacts.rs:447-461` — - documented insurance for local-only legacy rows; never participates in on-wire - encoding (send only ever emits 69 bytes; the SDK rejects non-69 before encryption). - -### 3.1 Reference-client (dashj / kotlin-platform) source pointers - -For re-checking our behavior against the canonical Android stack — `dashpay/kotlin-platform` -(`org.dashj.platform.dashpay`, the live lib), `dashpay/dashj` (core crypto/keychains), -and `dashpay/dash-wallet` (the app: sync, UI, DAOs), all on `master`. (`android-dashpay` -is the **stale** predecessor, last push 2024-01 — do not diff against it.) The -reference-side anchors that pin each cross-client comparison: - -| Concern | Reference-client anchor | -|---|---| -| `accountReference` ASK28 byte order | `BlockchainIdentity.getAccountReference` = `wrapReversed(ASK).toBigInteger().toInt() ushr 4` (= `u32_le(ASK[0..4])>>4`; we use the iOS `be(ASK[28..32])>>4` — §3 above) | -| Friendship path (receive vs send account) | `FriendKeyChain.getContactPath` — `contact.getUserAccount()` (receive) / `getFriendAccountReference()` (send) | -| `contactRequest` pagination (drain past 100) | `Documents.getAll` loops `startAt = last.id` while `size >= 100`; `retrieveAll` ⇒ `limit(-1)` | -| High-water + 10-min skew overlap | `PlatformSyncService.kt:346-372`, `DashPayContactRequestDao.kt:50-54` (`MAX(timestamp)` per direction) | -| Batched contact-profile fetch | `updateContactProfiles` → `Profiles.getList` (chunks of 100, `whereIn $ownerId`) | -| Non-destructive profile update | `Profiles.replace` — read-modify-write (`profileData.putAll(currentProfile.toObject())`, then overlay) | -| `encryptedAccountLabel` padding | `padAccountLabel()` — pad to ≥16 chars with spaces, always emit | -| Recipient-key selection | kotlin = ENCRYPTION-first with AUTH/HIGH fallback | -| Sent-tx status (live, not stored) | derived from `TransactionConfidence` | -| tx→contact reverse (both directions) | `getFriendFromTransaction` scans sent + received pools | -| Account/keychain self-heal | `checkDatabaseIntegrity` | - -**Perceptual-hash caveat — do NOT write a cross-client exact-match test on -`avatarFingerprint`.** The dHash byte/bit layout coincidentally matches dashj, but the -pixel pipeline differs (greyscale **average vs luma-weighted**, resize filter, 9×9 vs -9×8), so fingerprints **will not be byte-identical cross-client**. That is inherent to -perceptual hashing — the fingerprint is used for Hamming distance, never equality — so a -cross-client exact-match assertion is wrong by construction. - ---- - -## 4. Correction to the existing docs - -- **`SPEC.md` G3 ("`accountReference` hardcoded to 0", deferred to M3) is STALE.** - Code verification shows the send path computes a **real** `accountReference` and - does **version-bump rotation** on re-send (`contact_requests.rs:514-551`, - `account_reference.rs:41-51`). Only the *account-number* multi-account case remains - at `0` (§2 above). The leftover comment `sdk_writer.rs:114` ("DashPay account - reference (currently 0)") is rotted and should be corrected. - ---- - -## 5. Where the implementation is *ahead* of the reference clients - -For calibration (don't "fix" these): -- A real `contactInfo` document type — `kotlin-platform`/`dashj` have **none**. -- A genuine 8-byte dHash `avatarFingerprint` (commonly stubbed/zeroed elsewhere). -- Hand-recomputed ECDH + 69-byte-xpub known-answer tests (not just doc-comment trust). -- Stricter sync re-entrancy/shutdown discipline and a more robust - `reconcile_incoming_payments` self-heal than dashj. - ---- - -## 6. DIP-16 (Headers-First SPV synchronization) conformance - -DIP-15 §12 requires sync to follow **DIP-16**. The SPV client lives in the -`dash-spv` crate (rev `b4779fc`), driven by `packages/rs-platform-wallet/src/spv/`. - -**Two architectural facts frame every verdict:** -1. dash-spv is **not** a literal 4-phase sequential state machine. It is an - **event-driven coordinator** that spawns 8 independent managers (block-headers, - filter-headers, filters, blocks, masternode, chainlock, instantsend, mempool), - each in its own tokio task, progressing reactively off a `SyncEvent` bus - (`dash-spv/src/sync/sync_coordinator.rs:33-62,197-250`). DIP-16's *phases* are - realized as concurrent managers, not ordered stages. -2. The **confirmed-tx receive path uses BIP157/158 compact filters** (pulled from - peers, matched locally against wallet scripts) — **not** BIP37 bloom. BIP37 - `filterload` exists *only* in the optional mempool (unconfirmed-tx) manager. - DIP-16 step 8 literally says "construct a bloom filter"; the implementation - substitutes BIP157 for the confirmed path — a deliberate, stronger-privacy - deviation. - -### 6.1 Conformance matrix (DIP-16 9-step + phasing + locator) - -| DIP-16 element | Verdict | Evidence (`dash-spv` unless noted) | -|---|---|---| -| Step 1 — chain height from **multiple** peers | ✅ IMPLEMENTED (uses `max`, not soft-consensus) | `network/pool.rs:105-151` | -| Step 2 — headers-first from checkpoints + chain/PoW validation | ✅ IMPLEMENTED | `chain/checkpoints.rs:158-725`; `sync/block_headers/pipeline.rs:56-113`; `validation/header.rs:17-46` | -| Step 3 — terminal masternode list + quorums | ✅ IMPLEMENTED | `sync/masternodes/sync_manager.rs:255`; `manager.rs:575` | -| Step 4 — intermediate MN lists to verify quorums | ✅ IMPLEMENTED | `sync/masternodes/sync_manager.rs:42-147,369` | -| Step 5 — verify quorums (real, not stubbed) | ✅ IMPLEMENTED | `sync/masternodes/manager.rs:487,577` | -| Step 6 — retrieve identities | 🟡 PARTIAL (independent, best-effort) | platform-wallet `manager/identity_sync.rs:76,397-437`; `wallet_lifecycle.rs:421` | -| Step 7 — retrieve platform data | ✅ IMPLEMENTED (independent timer) | platform-wallet `manager/dashpay_sync.rs:404-474` | -| **Steps 2–7 ordered in one phase** | ⚪ NOT MODELED (intentional) | `manager/mod.rs:103-195` — no cross-coordinator gating | -| Step 8 — compact-filter build (confirmed path) | ✅ IMPLEMENTED | `sync/filters/manager.rs:654,734,779` | -| Step 8 — **DashPay/contact addresses in filter** | ✅ IMPLEMENTED (once receival acct exists) | `key-wallet .../wallet_info_interface.rs:302-316`; reg `network/contacts.rs:223,233` | -| Step 8 — filter set on **all** peers | 🟡 PARTIAL (eventually-all, looped not atomic; BIP37 mempool only) | `sync/mempool/sync_manager.rs:173-193`; `network/mod.rs:174-176` | -| Step 9 — sync-from block / wallet-birthday checkpoint | ✅ capability present; birthday auto-drive soft | `chain/checkpoints.rs:138-145`; `sync/filters/manager.rs:171-175` | -| Block-locator shape ("last 10 + prev checkpoint + genesis") | 🟡 PARTIAL (single-hash, checkpoint-segmented) | `network/mod.rs:102-107`; `sync/block_headers/segment_state.rs:67-69` | -| Named 4-phase state machine | 🟡 PARTIAL (generic `SyncState`, no named phases) | `sync/progress.rs:9-18` | - -No `todo!`/`unimplemented!`/stub markers were found in the masternode/quorum or -header/filter sync paths — the hard cryptographic parts are real. - -### 6.2 DIP-16 deviations (audit findings — mostly intentional, none are dead stubs) - -1. **No 4-phase ordering; L2 sync decoupled from L1.** Identity/platform sync run on - independent timers with zero gating on SPV header/masternode completion. Notably, - platform-data **proof verification does not consume the local SPV quorum state** — - `SpvRuntime::get_quorum_public_key` exists but no sync manager calls it; proofs go - through the SDK/DAPI path. This is the largest DIP-16 conformance gap, but appears - to be a deliberate UX choice (don't block L2 on full L1 sync). -2. **Single-hash block locator** instead of the DIP's multi-hash fork-recovery - locator. Safe under checkpoint-segmented parallel download (each segment anchor is - a validated checkpoint/tip), but a literal non-conformance with no genesis/previous - fallback hashes in a request. -3. **Height aggregation is `max`, not soft-consensus** — one dishonest peer - advertising a high `start_height` inflates the sync target. Minor, but worth a note. -4. **BIP37 mempool filter is set per-peer in a loop**, not an atomic broadcast. -5. **Birthday-by-timestamp start is available but not obviously auto-driven** from - platform-wallet (`get_sync_checkpoint(creation_time)` exists; default start resumes - from persisted `synced_height`/config height). -6. **DashPay address coverage is conditional** — addresses are watched only *after* - the contact's funds-bearing receival account is registered; there is no pre-emptive - watch. This is the DIP-16-layer facet of the §1.1 gap (below). - -### 6.3 DIP-16 does NOT mandate the §12.6 rescan — confirmed - -Direct fetch of DIP-16 confirms it specifies **no** "re-request blocks from height N -after the address set grows" mechanism. Its filter section says only that Platform-app -address spaces "can be used" in the filter and "a client should set this filter on all -connected peers." The rewind-on-new-address behavior is a **DIP-15 §12.6** obligation -layered on the DIP-16 base — so §1.1 is a DIP-15 gap, not a DIP-16 one. - -### 6.4 The rescan engine already exists at the DIP-16 filter layer - -Relevant to §1.1: dash-spv's filter manager **already implements** the rescan -machinery — `reset_for_rescan()` rolls `committed_height` back and replays when a -wallet's `synced_height` drops below scan progress, and an in-flight `rescan_batch` -re-scans when new gap-limit scripts appear mid-batch -(`sync/filters/manager.rs:129-139,468-505`). It is just never *triggered* for the -DashPay backfill case, because nothing lowers `synced_height` to the contact's -`$coreHeightCreatedAt`. That is why §1.1's fix is a small wallet-side trigger plus one -upstream guard-bypass method, not an SPV build. - ---- - -## 7. Recommended priority - -1. **§1.1 coreHeight block re-scan (DIP-15 §12.6)** — the only untracked - correctness/payment-loss item. Now scoped small: a wallet-side `synced_height` - rewind on new-contact registration + one upstream `reset_wallet_synced_height_to` - method; the dash-spv `FiltersManager` rescan engine already does the rest. -2. **§1.2 account-label** — ✅ DONE. Send length-normalization fixed; receive-side - decryption + UI surfacing implemented (incoming-only) per - SPEC.md Milestone 3. DIP-15 §8.5 now fully conforms. -3. **DIP-16 deviations (§6.2)** — mostly intentional; if any is worth hardening it is - #1 (consider sourcing proof-verification quorum keys from the local SPV engine) and - #3 (height soft-consensus). Track, don't rush. -4. Everything in §2 stays blocked on its external dependency; §3 is intentional. diff --git a/docs/dashpay/IDENTITY_KEY_SCALAR_ELIMINATION_SPEC.md b/docs/dashpay/IDENTITY_KEY_SCALAR_ELIMINATION_SPEC.md deleted file mode 100644 index 885c213a433..00000000000 --- a/docs/dashpay/IDENTITY_KEY_SCALAR_ELIMINATION_SPEC.md +++ /dev/null @@ -1,493 +0,0 @@ -# Identity-Key Scalar Elimination — derive-sign-destroy for discovered keys - -Status: draft, rev 2 (review must-fixes folded) -Scope: removes the carried 32-byte ECDSA scalar -(`IdentityKeyEntry.private_key` / `KeyWithBreadcrumb.verified_scalar`) from the -identity-key discovery → persist → sign flow, replacing it with a -derive-sign-destroy model in which the per-key secret only ever exists in the iOS -Keychain (as the wallet seed), is derived on demand at sign time, and is never -carried across the Rust→FFI→Swift boundary or stored per-key. - -Supersedes the earlier carried-scalar storage posture (the carry-the-verified-scalar -fix this spec reverses). Aligns with the seed-elimination §4.9-blocker item 3 design -and the sibling decision to stop persisting the DashPay friendship xpub and re-derive -on load. - -> **Review outcome (rev 2).** Four independent reviewers (feasibility, scope, -> security, crypto/domain) audited rev 1 against the code. The crux correctness -> claim — pubkey-compare is byte-for-byte equivalent to -> `validate_private_key_bytes(scalar)` — was **verified exact** for both -> `ECDSA_SECP256K1` (33-byte compressed compare) and `ECDSA_HASH160` -> (`ripemd160_sha256(pubkey)` vs `key.data()`; the double-hash gotcha does **not** -> apply because we compare `key.data()`, not `entry.public_key_hash`). No key is -> wrongly authorized and no wallet-derivable key is wrongly dropped to watch-only. -> The must-fixes folded below are all about the **migration**, where removing the -> carried scalar trades an *intrinsic* scalar↔pubkey binding for a *trusted-path* -> binding — the real lockout surface. The five load-bearing corrections: -> **(MF-1)** the backfill reads the Keychain **metadata blob** (named fields), not -> a parse of the account-string label; **(MF-2)** the backfill **and** the sign -> path **re-derive the pubkey at the path and compare to the row's -> `publicKeyData`** before trusting/signing — a present-but-wrong path otherwise -> signs silently and consensus rejects it (silent lockout); **(MF-3)** the -> scalar-field-deletion gate is a **runtime migration stamp**, not a dev-time -> check, and the field-deleted build still runs the Keychain-driven backfill on -> first launch (the Keychain survives a SwiftData store rebuild); **(MF-4)** the -> SwiftData column addition must be a verified-clean lightweight migration (or an -> explicit `MigrationStage`), since this app has historically rebuilt the V1 store -> from scratch; **(MF-5)** the schema delta is exactly `walletId` + -> `identityDerivationPath`, and the FFI layout guard recomputes to exactly **184**. - ---- - -## 1. Problem - -Identity-key discovery carries a verified 32-byte ECDSA scalar **Rust → FFI → -Swift** so the iOS Keychain stores it directly: - -- `discovery.rs::derive_key_breadcrumbs` derives a candidate scalar per on-chain - key; `breadcrumb_decisions` gates each via - `IdentityPublicKey::validate_private_key_bytes(scalar, network)` and carries the - reproducing scalar as `KeyWithBreadcrumb.verified_scalar` - (`changeset.rs:391`). -- It rides `IdentityKeyEntry.private_key` (`changeset.rs:434`, `#[serde(skip)]`, - redacting `Debug`), is copied by value into `IdentityKeyEntryFFI.private_key:[u8;32]` - (`identity_persistence.rs:312`, with `private_key_is_some`), and Swift writes the - 32 bytes to the Keychain via `storeCarriedIdentityKey` → - `KeychainManager.storeIdentityPrivateKey` under account - `identity_privkey..`. -- At sign time the `keyType < 5` branch reads that stored scalar back out - (`KeychainSigner.swift::lookupIdentityPrivateKey` → `ffiSign`). - -This is not a resident keystore — the scalar transits same-tick, is `Zeroizing`, -never serialized, never written to SQLite, and **no Rust signing path reads it** -(Rust signs via the external `VTableSigner`). It exists because the imported-wallet -flow ran `createWallet` (→ discovery) *before* `storeMnemonic`, so the old -Swift re-derive-from-mnemonic produced 23/23 watch-only keys; carrying the -already-verified scalar removed Swift's mnemonic dependency (commit `c567981c46`). - -**Why change it anyway.** The carried scalar is still a raw secret crossing the FFI -ABI and stored per-key at rest. The clean model — already proven for platform -addresses — keeps the only secret (the seed) in the Keychain and derives each -signing key on demand. Removing the carry yields: the raw scalar never crosses the -FFI; one fewer class of secret at rest (no per-key scalar); Rust discovery never -materializes the scalar at all (verify via public key). - -**The hard part.** This is the single most safety-critical path in the wallet: a -wrong key-storage/resolution change **locks users out of signing**, and the change -is only *validatable* against the iOS Keychain signer (iOS-gated). The design must -make the cutover non-lockout **by construction**. - ---- - -## 2. Current vs. target architecture - -### Current (carried scalar) - -``` -discovery.rs derive candidate scalar ── validate_private_key_bytes(scalar) ──┐ - │ verified_scalar: Some -changeset KeyWithBreadcrumb.verified_scalar ─► IdentityKeyEntry.private_key│ -FFI IdentityKeyEntryFFI.private_key[32] + private_key_is_some (by value) -Swift store storeCarriedIdentityKey ─► Keychain item identity_privkey.. (32 raw bytes) -Swift sign keyType<5 ─► lookupIdentityPrivateKey (read scalar back) ─► ffiSign -``` - -### Target (derive-sign-destroy) - -``` -discovery.rs derive candidate PUBLIC key ── compare to on-chain pubkey ──┐ (no scalar materialized) - │ breadcrumb: Some, scalar: ABSENT -changeset KeyWithBreadcrumb{ key, breadcrumb } (no verified_scalar) -FFI IdentityKeyEntryFFI{ …, wallet_id, identity_index, key_index } (breadcrumb only — already crosses) -Swift store persistIdentityKeys ─► PersistentPublicKey.{walletId, identityDerivationPath} (queryable columns) -Swift sign keyType<5 ─► resolveIdentityKeyContext ─► dash_sdk_sign_with_mnemonic_resolver_and_path - (resolve mnemonic in-callback ─► derive ─► sign ─► zeroize; only the signature returns) -``` - -The breadcrumb `(wallet_id, identity_index, key_index)` **already crosses the FFI** -(`identity_persistence.rs` `wallet_id`/`identity_index`/`key_index`, gated behind -`wallet_id_is_some` / `derivation_indices_is_some` — present whenever the entry has -a breadcrumb, independent of the scalar). It is currently used only to build the -Keychain account label and is then discarded. So `persistIdentityKeys` must guard -the new-column write on `entry.derivationIndices != nil` (the snapshot already -exposes `derivationIndices` and `walletId`). - ---- - -## 3. Chosen approach - -Mirror the **platform-address** derive-sign-destroy path, which already works -end-to-end, and reuse its Rust primitive. - -### 3.1 Discovery verifies via public key (no scalar) - -The DIP-9 identity-auth path `m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'` -is fully hardened, so the candidate pubkey must still be derived from a master -xpriv (resolved on demand inside the FFI, wiped before return — already the case -for external-signable wallets). The change is local to discovery: compute the -candidate **compressed public key** -(`derive_ecdsa_identity_auth_keypair_from_master(..).public_key`) and compare to -the on-chain key — `key.data() == pubkey` for `ECDSA_SECP256K1`, -`ripemd160_sha256(pubkey) == key.data()` for `ECDSA_HASH160` — instead of calling -`validate_private_key_bytes(scalar)`. **Do not populate `candidate_scalars`.** - -This is byte-for-byte the same decision `validate_private_key_bytes` makes -internally; it just never needs the scalar to leave the derive function. The -transient master resolution in discovery is **not** what we eliminate — the -persisted/carried per-key scalar is. - -An uncompressed externally-registered ECDSA key (65-byte on-chain `data()`) -correctly stays watch-only because the wallet only ever derives the **compressed** -form, so the compare gracefully fails — *not* because Platform forbids uncompressed -identity keys (it does not; `UncompressedPublicKeyNotAllowedError` is an asset-lock -constraint only). Stating the real reason avoids a future "optimization" that -assumes uncompressed identity keys can't exist on-chain. - -### 3.2 Sign via the existing resolver primitive (no new FFI) - -`dash_sdk_sign_with_mnemonic_resolver_and_path` -(`rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`) is **generic over the -derivation path and ECDSA-only**. Every wallet-derivable identity auth key is ECDSA -(guaranteed by the discovery verify gate), so the identity signing branch calls -this primitive **unchanged** with the DIP-9 identity-auth path string. No new FFI -signing entry point is required. - -**Sign-time binding check (MF-2).** Today's stored-scalar lookup is *intrinsically* -correct — the scalar it returns was the one verified to reproduce that exact pubkey -at discovery. The resolver path loses that: it routes by `wallet_id_bytes` and -derives at `identityDerivationPath`, but never confirms the result matches the key -being signed for. A mis-mapped resolver slot or a stale path would derive a -*different, valid* scalar and produce a signature consensus silently rejects. So the -identity sign path **must verify the derived compressed pubkey equals the row's -`publicKeyData` before signing** (derive-and-compare inside the FFI, or a -pubkey-preview call before `sign`), and fail loud on mismatch rather than emit a -wrong-key signature. This restores the intrinsic binding the stored scalar gave for -free. - -`signIdentityKeyOnDemand` and `signPlatformAddressOnDemand` differ only in their -SwiftData lookup; the `sigBuf` setup + FFI call + error handling are identical. -Extract a shared private `signOnDemandWithContext(walletId:path:expectedPubKey:data:)` -so the two branches don't duplicate ~30 lines (the identity branch passes -`expectedPubKey`, enabling the MF-2 check; the address branch passes `nil`). - -### 3.3 Persist the breadcrumb as queryable columns - -`PersistentPlatformAddress` carries `walletId: Data` + `derivationPath: String` and -the signer reads them in `resolvePlatformAddressContext`. `PersistentPublicKey` -carries only `privateKeyKeychainIdentifier` — no breadcrumb. Add **exactly two** -columns: `walletId: Data?` and `identityDerivationPath: String?`. Both are required -by the resolver FFI — `wallet_id_bytes` is a mandatory parameter (it keys the -mnemonic-resolver callback), and the full path string is what -`dash_sdk_sign_with_mnemonic_resolver_and_path` derives at. **Do not** add separate -`identityIndex`/`keyIndex` columns — they are redundant with the path string (the -inverse of `getIdentityAuthenticationPath`) and the path is authoritative. -`persistIdentityKeys` writes the two columns, building the path with -`KeyDerivation.getIdentityAuthenticationPath` — the same path -`storeCarriedIdentityKey` already computes and currently throws away — and **always -overwrites** when the FFI breadcrumb is present, so a backfilled value and a -fresh-persister value for the same row are byte-identical (a string-format drift -between the two would otherwise desync the stored path from what the resolver -re-derives). - -**Migration safety (MF-4).** Two optional columns are the additive shape SwiftData -lightweight migration handles — *but* this app's `DashModelContainer` runs -`DashSchemaV1` with `stages: []` and has historically rebuilt the dev store from -scratch on any model-hash change. Adding columns must be confirmed to -lightweight-migrate **a real persisted production store on upgrade** (not just a -fresh install); if SwiftData instead rebuilds the store, every -`PersistentPublicKey` row vanishes and the §5 backfill has no rows to heal. Either -verify the clean lightweight path on a real upgrade or bump to `DashSchemaV2` with -an explicit additive `MigrationStage`. Because the backfill is **Keychain-driven** -(§5) and the Keychain survives a SwiftData rebuild, a wiped row set degrades to -re-materialization from the Keychain rather than to lockout — but the migration -shape must still be pinned, not assumed. - -### 3.4 Alternatives rejected - -- **Keep the carried scalar (status quo).** Rejected: leaves a raw secret crossing - the ABI and a per-key secret at rest; diverges from the platform-address model - and the broader "derive on load, don't persist secrets" direction. -- **Re-derive in Swift from the mnemonic at sign time (the pre-`c567981c46` - path).** Rejected: this is exactly the anti-pattern `swift-sdk/CLAUDE.md` forbids - (Swift running `mnemonic → seed → path → key`), and it was the original - imported-identity bug. The resolver primitive keeps the derive inside Rust with - only the path crossing. -- **New identity-specific FFI signing call carrying `(identity_index, key_index)`.** - Rejected as unnecessary now: the generic path primitive already covers every - ECDSA identity key. (A non-ECDSA wallet-derivable identity key — none exist today - — would be the only reason to add one.) - ---- - -## 4. Phased delivery - -**Hard ordering invariant:** the FFI/changeset scalar field is deleted **last**, -only after every already-materialized identity key is proven to sign via the -resolver path on a real device. Deleting it earlier — even though Rust still -compiles — bricks the still-scalar-based Swift signer = lockout. - -### Phase 1 — headless-safe (Rust + FFI only; additive, removes nothing Swift reads) - -Gate: `cargo test -p platform-wallet` + `-p rs-platform-wallet-ffi` green; -cross-compile `aarch64-apple-ios-sim`. No ABI change. - -1. Compute the pubkey-compare decision in `breadcrumb_decisions` and **assert in - tests** it is byte-equivalent to the scalar decision (`reproduces`). This is - *not* a second parallel derive: the candidate pubkey is already a byproduct of - the existing keypair derivation, so the only change is what the decision logic - compares. Production emission is unchanged — `verified_scalar: Some` still - ships in Phase 1 because Swift still reads it; the switch to pubkey-only happens - in Phase 2 step 6. -2. Test the identity DIP-9 path through `dash_sdk_sign_with_mnemonic_resolver_and_path` - (the existing happy-path test already uses `m/9'/1'/5'/0'/0'/0'/0'` — confirm it - covers the identity case or augment it; this step may be test-only, no new code). -3. Test that the FFI breadcrumb round-trips with `private_key_is_some == false`. - -Steps 1–3 have no internal ordering dependency and can land as one atomic Rust commit. - -### Phase 2 — iOS-gated (Swift + on-device), ordered - -1. Add `walletId` + `identityDerivationPath` columns to `PersistentPublicKey` (both - optional ⇒ SwiftData lightweight migration; existing rows get `nil`; no data - loss). -2. Write the breadcrumb columns in `persistIdentityKeys` — **both** old (Keychain - scalar) and new (columns) during the transition window. -3. **Backfill migration (the lockout defense — see §5).** One-time, Keychain-driven, - self-verifying pass populating the new columns from each `identity_privkey.*` - item's `IdentityPrivateKeyMetadata` blob, re-deriving the pubkey at the path and - comparing to the row's `publicKeyData` before trusting it — no network, no seed. -4. Re-route the `keyType < 5` signer branch through `signIdentityKeyOnDemand` + - `resolveIdentityKeyContext` (mirroring the platform-address pair), calling the - existing resolver primitive. **Resolver-first with legacy fallback:** a row with - no `identityDerivationPath` falls back to `lookupIdentityPrivateKey → ffiSign`; - every fallback hit is logged (count only, no key material). -5. **Validation gate:** transitional build on a funded testnet wallet; exercise - signing for every identity (DPNS register, profile set, contact-request - send+accept, payment); confirm **zero fallback hits** after backfill. -6. **Only after the gate:** flip discovery to pubkey-only; delete - `storeCarriedIdentityKey`, the Swift scalar copy/scrub, and the legacy signer - path; then delete the scalar field from FFI (recompute the layout guard from - `const _: [u8; 224]` to exactly `const _: [u8; 184]` — removing - `private_key_is_some` at offset 184 + `private_key: [u8; 32]` + trailing padding - drops bytes 184–223, alignment stays 8) and changeset; regenerate the header; - rebuild. - -**Runtime deletion gate (MF-3) — not a dev-time gate.** Step 6 must not assume every -device passed through the transitional build. A user can upgrade straight from the -scalar-only build to the field-deleted build, skipping the backfill; their rows have -`identityDerivationPath == nil` and there is no legacy signer left → lockout. So: -(a) the field-deleted build **still runs the Keychain-driven backfill on first -launch** (the Keychain items survive any SwiftData rebuild, so the path is -recoverable even with no transitional run); and (b) deleting the *legacy signer -fallback* is gated on a **persisted migration stamp** (set only after a backfill -pass leaves zero un-pathed rows / after the scalar Keychain items are purged), so a -binary without the stamp keeps the fallback and schedules a backfill. The fallback, -not just the field, is the safety net — it lives until the stamp guarantees the -resolver path covers 100 % of the live key set at runtime. - ---- - -## 5. Migration / back-compat — the lockout defense (new design) - -**Danger:** existing installs have scalars in the Keychain under -`identity_privkey..` and `PersistentPublicKey` rows whose -new breadcrumb columns are `nil` after the lightweight migration. If signing flips -to resolver-only and a row has no `identityDerivationPath`, that key is unsignable -→ the user is locked out of an already-working identity. - -Three layers, all required: - -1. **Keychain-metadata-driven, self-verifying backfill (no network, no seed) - (MF-1, MF-2).** Each `identity_privkey.*` Keychain item carries a first-class - `IdentityPrivateKeyMetadata` JSON blob (`kSecAttrGeneric`) with named `walletId`, - `derivationPath`, `identityIndex`, `keyIndex`, `publicKey` fields — - `KeychainManager.identityPrivateKeyAccount` already walks every row and decodes - it. The one-time backfill reads `walletId` + `derivationPath` from the **blob** - (not from a parse of the `identity_privkey..` account - label, which is fragile and has a legacy no-`walletId` variant). It is driven by - the **Keychain item set**, not the SwiftData row set, so it heals even if the - SwiftData store was rebuilt (MF-4) — it can re-create the `PersistentPublicKey` - linkage from the blob's `publicKey`. Crucially it is **self-verifying**: before - writing `identityDerivationPath`, re-derive the compressed pubkey at that path - (resolver / pubkey-preview FFI) and require it to equal the row's - `publicKeyData`; on mismatch leave the column `nil` so the row falls through to - the fallback / re-discovery rather than to a wrong-key sign. A non-zero count of - parse-or-verify failures is a **hard blocker** on the deletion gate (it is not - enough to test that signing works — every existing item must be accounted for). -2. **Resolver-first with legacy fallback.** During the transition the signer tries - the resolver path first and falls back to the stored scalar when the breadcrumb - is absent, so a row can always sign via at least one path. Non-lockout by - construction. (Note: the fallback covers an *absent* path, not a *present-but- - wrong* one — MF-2's sign-time binding check is what catches the latter.) -3. **Re-discovery heals the rest.** Any row not covered by (1) re-materializes on a - from-0 rescan (now writing the breadcrumb columns, needing only the resolver - mnemonic). Surface a "re-scan identities" affordance. - -**Population that blocks deletion (MF-3 / R5).** A wallet with a materialized scalar -but **no readable mnemonic** (the import-flow case that motivated the carried scalar -originally) can never reach zero resolver-fallbacks — the resolver needs the -mnemonic. For that population the legacy scalar fallback must be **retained**, or an -explicit mnemonic-import step required, before its scalar field/path can be removed. -The "zero fallback hits" criterion is otherwise unachievable for exactly the wallets -the carried scalar was introduced to serve. - -The legacy fallback and the scalar field are deleted **only** after the runtime gate -(MF-3) confirms, per device, that the resolver path covers the full live key set — -not merely after a dev-time test pass. - ---- - -## 6. Failure modes & risk register - -| ID | Risk | Mitigation | -|----|------|------------| -| R1 | Pubkey-verify diverges from scalar-verify → a key wrongly breadcrumbed (signable with an unauthorized key) or wrongly watch-only | Phase-1 byte-equivalence test over `ECDSA_SECP256K1` + `ECDSA_HASH160` + foreign key; assert decision set identical to `breadcrumb_decisions` | -| R2 | Existing rows lack breadcrumb columns → resolver-only signer locks out already-materialized keys | §5: backfill migration + resolver-first-with-fallback + zero-fallback gate before deleting legacy | -| R3 | Wrong network → wrong DIP-9 path → wrong key / sign failure | Resolve network from `PersistentWallet` exactly as `storeCarriedIdentityKey` does; unit-test the built path equals the Keychain account string | -| R4 | ABI/layout drift on field removal → `EXC_BAD_ACCESS` in the callback | Recompute `const _: [u8; N]` + the byte-offset comment; cbindgen regen; round-trip test | -| R5 | Resolver mnemonic missing/locked at sign time (watch-only, biometric-gated, import-only wallet with a scalar but no mnemonic) → sign fails where the stored scalar succeeded; zero-fallback gate unachievable for this population | Existing `mnemonicMissing` UX; **retain the legacy scalar fallback for the no-mnemonic population** (§5) — do not delete its scalar/path until a mnemonic-import step runs | -| R6 | A non-ECDSA wallet-derivable identity key appears (future) → the ECDSA-only resolver rejects it | Pre-existing constraint, **not introduced by this change** (the scalar path is already ECDSA-only via `validate_private_key_bytes`); discovery only breadcrumbs ECDSA; a non-ECDSA key would need a new resolver FFI | -| R7 | Old per-key scalars linger in the Keychain indefinitely → negates "one fewer secret at rest" | **Required (not optional)** purge of `identity_privkey.*` items, gated on the same runtime stamp; also doubles as the MF-3 migration-completed signal | -| R8 | **Skip-version upgrade lockout** — user jumps from scalar-only to field-deleted build, skipping the backfill; rows have `identityDerivationPath == nil` and no legacy signer remains | MF-3: field-deleted build still runs the **Keychain-driven** backfill on first launch (Keychain survives a SwiftData rebuild); legacy-fallback deletion gated on a persisted migration stamp, not a dev-time check | -| R9 | **Wrong-mnemonic / present-but-wrong-path silent signing** — resolver routes by `wallet_id_bytes` and derives a valid-but-wrong scalar; signature fails only at consensus, no local diagnostic | MF-2: derive-and-compare the pubkey to the row's `publicKeyData` before signing (and in the backfill before trusting a path); fail loud on mismatch | -| R10 | SwiftData store rebuild on column add wipes `PersistentPublicKey` rows → backfill has nothing to heal | MF-4: verify clean lightweight migration on a real upgrade or declare a `MigrationStage`; backfill is Keychain-driven so a wiped row set degrades to re-materialization, not lockout | - ---- - -## 7. Test / verification plan (red→green) - -**Phase 1 (headless):** -- `discovery.rs` `#[cfg(test)] mod tests` — `breadcrumb_via_pubkey_equivalence`: - derive a multi-key identity, run both the scalar path and the new pubkey path, - assert identical `(breadcrumb, key)` decisions and that the pubkey path carries no - scalar; extend the existing HASH160 + non-reproducible-key tests. **Red first** - (new path wrong), then green. This is the most important Rust correctness gate (R1). -- `sign_with_mnemonic_resolver.rs` tests — `signs_with_dip9_identity_auth_path` - (identity path string, verify signature). Confirms no new FFI is needed. -- `identity_persistence.rs` tests — breadcrumb survives `from_entry` with - `private_key_is_some == false`; (Phase-2 step 6) update the size guard to the new - `N` and assert no scalar field. -- `rs-platform-wallet-storage` round-trip — `IdentityKeyWire` still has no secret - field; compiles after `private_key` removal. - -**Phase 2 (iOS/sim):** -- `KeychainSignerIdentityResolveTests` — `signIdentityKeyOnDemand` resolves - `(walletId, identityDerivationPath)` from a seeded row and signs via a mock - resolver; `canSign` is true with breadcrumb+mnemonic, false without. **Plus the - MF-2 binding test:** a resolver returning a *wrong* mnemonic (or a row with a - *wrong* path) yields a **sign-failure, not a wrong-key signature** — assert the - pre-sign pubkey compare rejects it. -- `PersistentPublicKeyBreadcrumbMigrationTests` — seed a Keychain - `identity_privkey.*` item (with its `IdentityPrivateKeyMetadata` blob) + a row; - run backfill; assert columns are populated **from the blob** and that the - backfilled path **re-derives to the row's `publicKeyData`** (MF-2 self-verify); - assert a blob whose path does *not* re-derive to its pubkey leaves the column - `nil`; assert a row whose backfilled value and a fresh-persister value are - byte-identical; assert a row without a Keychain item falls back to legacy during - the transition; assert backfill works with the **SwiftData row set empty** - (Keychain-driven, MF-4). -- `BackfillCoverageTests` — every existing `identity_privkey.*` item is accounted - for; a non-zero parse-or-verify-failure count blocks the deletion gate (MF-1). -- `persistIdentityKeys` writes the two columns from a breadcrumb-only - (scalar-absent) entry, guarded on `derivationIndices != nil`. - -**On-device acceptance (the real gate):** -- Transitional build over an existing store with already-materialized identities → - backfill runs → exercise signing for every identity (DPNS / profile / contact - request / payment) → **zero legacy-fallback hits** logged. -- Fresh wipe → import funded testnet seed → discover (pubkey-verify, no scalar - carried) → sign → success. -- Wrong-seed rejection (`verify_seed_binds`) still holds. - -**Field deletion is gated:** `git grep verified_scalar` / -`IdentityKeyEntry.private_key` empty only after the zero-fallback on-device gate -passes. If fallbacks > 0, **do not delete** — the scalar is the safety net until the -resolver path is proven for 100 % of the live key set. - ---- - -## 8. Critical files - -- `packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs` — - pubkey-verify in `breadcrumb_decisions` / `derive_key_breadcrumbs`; equivalence test. -- `packages/rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs` — reuse the - signing primitive; add an **optional `expected_pubkey` param** for the MF-2 - derive-and-compare-before-sign check (the address path passes none); add a - DIP-9-path sign test + a wrong-seed-rejects test. -- `packages/rs-platform-wallet-ffi/src/identity_persistence.rs` — (Phase 2 step 6) - delete `private_key` / `private_key_is_some`; recompute the layout guard - `const _: [u8; 224]` → `const _: [u8; 184]` (drops bytes 184–223; align stays 8); - update the byte-offset comment. -- `packages/rs-platform-wallet/src/changeset/changeset.rs` — (Phase 2 step 6) delete - `KeyWithBreadcrumb.verified_scalar` + `IdentityKeyEntry.private_key`. -- `packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs` - — `add_keys`: drop the scalar from the destructure + entry literal. -- `packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift` - — add `walletId` + `identityDerivationPath`. -- `packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift` — - pin the lightweight migration / `MigrationStage` (MF-4); host the persisted - migration stamp gating legacy-path deletion (MF-3). -- `packages/swift-sdk/Sources/SwiftDashSDK/Security/KeychainManager.swift` — - Keychain-driven backfill reads the `IdentityPrivateKeyMetadata` blob (`walletId`, - `derivationPath`); required purge of `identity_privkey.*` after the gate (R7). -- `packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift` — - `signIdentityKeyOnDemand` + `resolveIdentityKeyContext` mirroring the - platform-address pair; fallback dispatch; delete `lookupIdentityPrivateKey` / - `ffiSign` in step 6. -- `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift` - — write breadcrumb columns; delete `storeCarriedIdentityKey` + the scalar - copy/scrub. - ---- - -## 9. As-built notes (what shipped vs. this spec) - -The additive, fallback-protected work (Phase 1 + Phase 2 steps 1–5) shipped on -`feat/dashpay-identity-key-scalar-elimination`. Two deliberate deviations from the -rev-2 design above, plus what is explicitly **not** done: - -- **Resolver FFI accepts `ECDSA_HASH160` (key_type 2), not just SECP256K1.** Full - scalar deletion is impossible otherwise — discovery breadcrumbs both ECDSA key - types. The MF-2 binding disambiguates by `expected_key_data` length: 33 bytes = - compressed-pubkey equality, 20 bytes = `ripemd160_sha256(pubkey)` equality. The - param is nullable (the address path passes none). This widens §3.2's "reuse the - primitive unchanged." -- **The backfill self-check is canonical-path-from-indices, not a pubkey - re-derivation.** §5 layer 1 specified re-deriving the pubkey at the path and - comparing to `publicKeyData`; that needs the seed, which the backfill - deliberately avoids. Instead it rebuilds the canonical DIP-9 path from the - metadata's `(network, identityIndex, keyIndex)` and requires it to equal the - stored `derivationPath` (rejecting format drift), and **relies on the sign-time - MF-2 binding as the real guard** — a present-but-wrong path yields - `ERR_PUBKEY_MISMATCH` at sign time → a logged `IDENTITY_SIGN_FALLBACK`, never a - wrong-key signature. A non-zero backfill failure count is still surfaced. -- **Phase 2 step 6 — the carried scalar IS deleted; the legacy fallback signer is - KEPT (partial by design).** `KeyWithBreadcrumb.verified_scalar`, - `IdentityKeyEntry.private_key`, and `IdentityKeyEntryFFI.{private_key, - private_key_is_some}` are removed (FFI layout guard recomputed `224` → `184`; the - `from_entry` copy + free-scrub gone). Discovery now derives-verifies-**drops** the - candidate scalar instead of emitting it — verification is still - `validate_private_key_bytes`, the scalar just never leaves discovery. The legacy - Keychain-scalar signer (`lookupIdentityPrivateKey` / `ffiSign`) is **retained** so - keys already materialized on existing installs still sign: the §5 lockout defense - is preserved *without* removing the legacy path. New keys are resolver-only. - Merged into `feat/dashpay-m1-sync-correctness` (`fb11783706`; commits `930c100c64` - deletion + `fa1098c081` test). -- **The funded-testnet zero-`IDENTITY_SIGN_FALLBACK` gate (§4 step 5 / §7) was - un-runnable and was substituted.** idb cannot actuate this app's SwiftUI - confirmation controls (the toolbar Create/Cancel, the backup-seed "I wrote it - down" switch), and the macOS-click fallback needs Accessibility / Automation / - Screen-Recording TCC the tmux-hosted shell lacks — so the automated UAT could not - even create a wallet. With explicit sign-off ("delete the field if it passes"), - the gate was replaced by a HEADLESS Swift integration test - (`IdentityResolverSignIntegrationTests`): it seeds a real mnemonic in - `WalletStorage` + a consistent breadcrumb row and asserts `signIdentityKeyOnDemand` - → resolver → on-demand derive → MF-2 binding → valid 65-byte signature (plus a - wrong-path → `.failure`). It stays green *after* the deletion — proof the resolver - path signs through the Swift layer with no stored scalar. Verified end to end: - platform-wallet 314 + FFI (125/26/9) tests, Swift IdentityResolverSign 2/2 + - IdentityKeyBreadcrumb 4/4 + 30 Identity tests, clippy clean, SwiftExampleApp sim - BUILD SUCCEEDED. Because the legacy fallback was retained, the `DashModelContainer` - migration-stamp (MF-3) stays deferred (it only matters once the fallback is - removed). Still unproven: a live on-device discover→materialize→sign over an - existing store. diff --git a/docs/dashpay/INTEROP_DESK_CHECK.md b/docs/dashpay/INTEROP_DESK_CHECK.md index 205b40c5f7c..4a42145d843 100644 --- a/docs/dashpay/INTEROP_DESK_CHECK.md +++ b/docs/dashpay/INTEROP_DESK_CHECK.md @@ -4,8 +4,8 @@ > transient `research/` directory was trimmed — older citations of > "`research/06`" refer to this file. Kept in the shipped docs because it is > the evidence base for the consensus-facing wire-format decisions -> (69-byte compact xpub, key-purpose envelope, ASK28 byte order) cited by -> `SPEC.md` and `DIP_CONFORMANCE_GAPS.md`. +> (69-byte compact xpub, key-purpose envelope, ASK28 byte order) the wallet +> implements. Research date: 2026-06-10 (Milestone 1, task 5 — verify-only). Question: do THIS stack's DashPay wire formats match the reference clients (iOS DashSync, @@ -463,3 +463,48 @@ is unbound, and mobile recipients have no DECRYPTION key for us to select when s (e.g. legacy 2024 AUTHENTICATION docs), degrade to a warning/skip — do **not** permanently mark the payment channel broken, since on-chain history demonstrably contains nonconforming-but-honest documents. + +## Addendum (2026-08-11): the receive side accepts legacy key purposes + +The alignment recommendation above is superseded on the receive side by +[#4372](https://github.com/dashpay/platform/pull/4372). A mainnet wallet with 29 +contacts established through the legacy Android/dashj client had 27 inbound +requests that reference the recipient's AUTHENTICATION or TRANSFER key, and the +documents are immutable, so rejecting them left those contacts unpayable. The +rules now are (rs-sdk `platform/dashpay/contact_request.rs`): + +- **Send (documents we create):** unchanged. The sender key must be ENCRYPTION + (`sender_key_purpose_is_valid`) and the recipient key DECRYPTION or ENCRYPTION + (`recipient_key_purpose_is_valid`). +- **Receive (documents already on chain):** the recipient key may be DECRYPTION, + ENCRYPTION, AUTHENTICATION or TRANSFER + (`recipient_key_purpose_is_acceptable_on_receive`), and the sender key + ENCRYPTION or AUTHENTICATION (`sender_key_purpose_is_acceptable_on_receive`). + Any other purpose is a mismatch that is skipped and retried, never a + permanently broken channel. + +## Re-checking against the reference clients + +The Android sources to compare against are `dashpay/kotlin-platform` +(`org.dashj.platform.dashpay`, the live library), `dashpay/dashj` (core crypto +and keychains) and `dashpay/dash-wallet` (the app: sync, UI, DAOs), all on +`master`. `android-dashpay`, cited in the source table at the top, is the stale +predecessor (last push 2024-01); do not diff against it. + +| Concern | Reference-client anchor | +|---|---| +| `accountReference` ASK28 byte order | `BlockchainIdentity.getAccountReference` = `wrapReversed(ASK).toBigInteger().toInt() ushr 4` (= `u32_le(ASK[0..4])>>4`; we use the iOS `be(ASK[28..32])>>4`, see section (3)) | +| Friendship path (receive vs send account) | `FriendKeyChain.getContactPath`: `contact.getUserAccount()` (receive) / `getFriendAccountReference()` (send) | +| `contactRequest` pagination (past 100) | `Documents.getAll` loops `startAt = last.id` while `size >= 100`; `retrieveAll` means `limit(-1)` | +| High-water + 10-minute skew overlap | `PlatformSyncService.kt` (high-water sync), `DashPayContactRequestDao.kt` (`MAX(timestamp)` per direction) | +| Batched contact-profile fetch | `updateContactProfiles` calls `Profiles.getList` (chunks of 100, `whereIn $ownerId`) | +| Non-destructive profile update | `Profiles.replace`: read-modify-write (`profileData.putAll(currentProfile.toObject())`, then overlay) | +| `encryptedAccountLabel` padding | `padAccountLabel()`: pad to at least 16 chars with spaces, always emit | +| Recipient-key selection | ENCRYPTION first, with an AUTHENTICATION/HIGH fallback | +| Sent-tx status | derived live from `TransactionConfidence`, not stored | +| Transaction to contact (both directions) | `getFriendFromTransaction` scans the sent and received pools | +| Account/keychain self-heal | `checkDatabaseIntegrity` | + +Never assert that `avatarFingerprint` bytes match across clients: the dHash +pixel pipelines differ, so fingerprints are compared by Hamming distance (see +`calculate_dhash_fingerprint`). diff --git a/docs/dashpay/KOTLIN_INVITATIONS_SPEC.md b/docs/dashpay/KOTLIN_INVITATIONS_SPEC.md deleted file mode 100644 index 58ee9fa7d11..00000000000 --- a/docs/dashpay/KOTLIN_INVITATIONS_SPEC.md +++ /dev/null @@ -1,442 +0,0 @@ -# DashPay Invitations (DIP-13 3') — Kotlin/Android Port Spec - -Port the shipped iOS invitation feature (create + claim + reclaim + sent-invitations -persistence, PR #4041, `docs/dashpay/DIP15_INVITATIONS_SPEC.md`) to the Kotlin SDK and -KotlinExampleApp, on branch `feat/kotlin-dashpay-invitations` (base `v4.1-dev`). - -Status: **v3 — synced with owner (2026-07-22), all open questions resolved (§10); -slices 1–5 implemented** (JVM + cargo gates green; instrumented + funded-testnet -QA are the remaining environment-bound gates). (v2 was the post-review draft.) -Reference implementation: `packages/swift-sdk` + SwiftExampleApp (source of truth per the -parity doctrine in `packages/kotlin-sdk/CLAUDE.md`). -Feature behavior spec: `docs/dashpay/DIP15_INVITATIONS_SPEC.md` §0/§0A/§0B (as-built truth). - ---- - -## 1. Problem - -The Kotlin DashPay migration (K1–K3, `KOTLIN_MIGRATION_SPEC.md`) ported all 10 DashPay -screens and declared invitations out of scope (§8) because iOS hadn't shipped them yet. -iOS has since shipped the full feature. Today the Kotlin stack is deliberately -**fail-closed**: `rs-unified-sdk-jni/src/persistence.rs:174` sets -`on_persist_invitations_fn: None`, so the backend never attests the `INVITATIONS` -capability and `create_invitation`'s up-front -`persistence_capabilities().contains(INVITATION_CREATION)` gate refuses to run on -Android — rather than mint a voucher whose one-time bearer key could be re-exported -after a restart (the defect class fixed by 55937e15c1 on iOS). - -Every layer is pre-seamed for this port: -- All three invitation FFI entry points (`create`/`claim`/`parse`) plus the two reclaim - exports already exist in `rs-platform-wallet-ffi` and are consumed by iOS through the - same C ABI. -- The JNI natives for resume/top-up already declare a `consumeInvitationVoucher` - parameter (currently guard-rejected when `true`); the Kotlin *public* wrappers hardcode - `false` and structurally gate invitation locks out (see §3.1 — they need new variants, - not a flag flip). -- Room schema v8 is reserved for invitations (`PARITY.md:49`; DB is at v7). -- The handler already attests 3 of the 4 capability bits in `INVITATION_CREATION` - (`ATOMIC_CHANGESETS 0x01 | ASSET_LOCK_FUNDING_INDICES 0x04 | WALLET_RESTORE 0x80`); - only `INVITATIONS (0x02)` and its callback are missing. - -**Goal:** feature parity with iOS invitations — same flows, same persisted shape, same -crash-safety semantics, same testTags — with **zero changes to `rs-platform-wallet` and -`rs-platform-wallet-ffi`** (shared logic stays shared; the port is bindings + persistence -+ UI only). - -## 2. What does NOT need porting (all shared Rust, already on this branch) - -- Link codec (`dashpay://invite?du=…&assetlocktx=…&pk=…&islock=…`, emit-strict / - parse-lenient, applink host, WIF + byte-order leniency) — `crypto/invitation.rs`. -- Create orchestration: amount caps (`MIN=300_000` / `MAX=5_000_000` duffs), capability - gate, funding-index persist+flush **before broadcast** (abort on failure), invitation - row persisted **after broadcast, before the proof wait** (hard error if it fails), - Instant-proof requirement, path-gated voucher-key export (`9'/coin'/5'/3'/idx'`), - key scrubbing — `network/invitation.rs`. -- Claim orchestration: claim-by-fetch with bounded retry + byte-reversed retry, - credit-output selection by script match, Instant/Chain proof reconstruction, - raw-key identity registration — `network/invitation.rs`. -- Reclaim authorization: `AssetLockFunding::FromExistingAssetLock` + - `authorized_invitation_reclaim` (invitation-typed locks are consumable **only** with - `consume_invitation_voucher = true`, and only into register/top-up; every generic - path refuses them regardless of the flag) — `wallet/asset_lock/orchestration.rs:493,511`. -- The persistence wire type `InvitationEntryFFI` (repr(C), ABI-pinned 64 B, all-POD) - and the `on_persist_invitations_fn` callback slot + `INVITATIONS` capability bit — - `rs-platform-wallet-ffi/src/invitation_persistence.rs`, `persistence.rs:655-664,822`. - -Kotlin re-implements **none** of this. Per doctrine, no JNI function stitches Rust calls -together; each new export is a thin marshaler over exactly one existing C-ABI entry point. - -## 3. Design decisions - -1. **Reclaim: forward the flag in JNI + add outpoint-taking reclaim wrappers.** - Two layers, two different changes: - - *JNI*: the resume/top-up bindings (`identity.rs:496` → hardcoded `false` at `:552`, - guard at `:524`; `credits.rs:286` → `:331`, `:311`) forward the already-declared - `jboolean` verbatim and drop the `generic_asset_lock_recovery_allowed` call — an - interim fail-closed measure pending this port. Rust core enforces the real policy - independently (verified: `authorized_invitation_reclaim` requires - invitation-typed lock ∧ flag ∧ register/top-up target; all other - `FromExistingAssetLock` constructors hardcode `false` Rust-side), and Swift's - wrappers forward the identical boolean with no extra guard — this reproduces the - already-reviewed iOS trust model, not a weaker one. - - *Kotlin wrappers*: the existing public `resumeWithExistingAssetLock` / - `resumeTopUpWithExistingAssetLock` **cannot be reused** — they hardcode `false` - (`IdentityRegistration.kt:158`, `IdentityCredits.kt:115`), `require()` non-invitation - funding types, and `TrackedAssetLock.FundingType` has no `INVITATION(3)` (unknown - types are silently dropped at `TrackedAssetLock.kt:62`). Add two **outpoint-taking - reclaim variants** mirroring Swift (`resumeIdentityWithAssetLock:3939`, - `resumeTopUpWithAssetLock:4128`): raw txid+vout + `consumeInvitationVoucher`, no - funding-type gate, freely chosen `identityIndex`; each still one FFI call — no - doctrine violation. Generic-recovery wrappers and all existing call sites stay - exactly as they are. - *Alternative rejected:* adding `FundingType.INVITATION` and relaxing the three - `require()` gates on the generic-recovery wrappers — that weakens crash-recovery - invariants shared by non-invitation flows to serve one caller. -2. **Status transitions are client-written, exactly as on iOS.** Rust emits only - `Created` rows (create is the sole `InvitationChangeSet` emitter today). `Claimed` - and `Reclaimed` are written locally by the app via the reclaim-outcome classifier. - Room is the UI's source of truth; there is **no Rust→Kotlin rehydrate** (a Room wipe - loses list visibility only — never funds; `funding_index` re-derives the key). -3. **Claim entry = paste + QR scan first-class, deep link additionally** (decided, - §10.2). Add a `dashpay://invite` `VIEW` intent-filter routing to the claim sheet, - mirroring `SwiftExampleAppApp.swift:148`, **plus an intent-filter for the legacy - AppsFlyer host `https://invitations.dashpay.io/applink`** (the form production - dashwallet-iOS emits; our parser already accepts it) — unverified until the domain - serves `assetlinks.json` (dashpay/platform#4096), so it participates in the app - chooser rather than auto-opening; upgradeable to a verified App Link when #4096 - lands. Two further deliberate deviations from iOS: - - **Walletless parking (iOS behavior is a bug, not parity):** iOS clears - `pendingInviteURL` before its no-wallet guard returns - (`DashPayTabView.swift:149-153`), silently discarding the link — on Android the - intent-filter's headline scenario *is* a fresh install tapping an invite. Kotlin - parks the pending URI until a wallet exists (cleared only when the claim sheet is - actually seeded) and shows "create a wallet to claim this invitation". Flag the - drop as an upstream iOS bug to fix separately. - - **Honest Android framing:** a custom scheme can't use App Links auto-verify; any - app may register the same filter. Android shows a chooser on collision (per-tap - visibility iOS doesn't have), **but** one "Always" tap for a malicious app silently - routes every future invite link to it. Documented as an Android-specific - persistence risk, not "the same caveat iOS documents". - The mid-claim deferral gate (analog of `invitationClaimInFlight`) is ported as-is. -4. **Persistence writes go through the round buffer.** On FFI hosts the durable boundary - is `onChangesetEnd`'s single `withTransaction` replay of the per-wallet - `ChangesetBuffer` (`PlatformWalletPersistenceHandler.kt:298-353`); `onFlush` stays the - inherited base-class no-op (verified: Rust's `flush()` after a successful `store()` - round is a post-commit bookkeeping notification — the round's commit result is - honored, not advisory). Hard rule: the invitation handler **stages via `stage {}` - into the round buffer; never writes in its own transaction and never defers past the - round** (no `launch`/`post`) — an immediate standalone write would break round - atomicity, a deferred one would break durability. -5. **One PR, sliced commits** (§5 order). The feature is cohesive; the slices carry - independent compile/test gates. (If review load demands, slice 4 can split - screens+nav vs deep-link+classifier — optional.) - -## 4. Interfaces per layer - -### 4.1 JNI (`rs-unified-sdk-jni`) — new exports in `src/dashpay.rs` - -All under `support::guard`, errors via `take_pwffi_error` → `DashSDKException(code+1000)`. -FFI structs are read via the rlib types directly (no manual offset math). -**Secret-hygiene rule (normative): the `uri` argument is the bearer secret — it must -never be interpolated into any exception message, log line, or debug output from -`createInvitation`, `claimInvitation`, or `parseInvitation`** (the existing -`"$field must be N bytes"` convention covers byte params only; no precedent exists for -a String param that *is* the secret, so this is easy to get wrong). - -| Export (`Java_…_DashpayNative_…`) | Wraps | Notes | -|---|---|---| -| `parseInvitation(uri: String) -> String` | `platform_wallet_parse_invitation` | Returns the preview per the crate's compact-JSON convention (`structurallyValid`, `isInstant`, `hasInviter`, `inviterUsername?`); frees `inviter_username` after copy. Malformed link ⇒ `structurallyValid=false`, not an exception. | -| `createInvitation(walletHandle: Long, amountDuffs: Long, fundingAccountIndex: Int, inviterIdentityId: ByteArray?, inviterUsername: String?, nowUnix: Int, coreSignerHandle: Long) -> String` | `platform_wallet_create_invitation` | Returns the URI (bearer secret). The out-outpoint (caller-owned POD, nothing to free) is ignored — the persistence callback records the row, as on iOS. `now_unix` from a real clock read (Rust rejects 0). | -| `claimInvitation(walletHandle: Long, uri: String, identityIndex: Int, pubkeyRowsBlob: ByteArray, signerHandle: Long, nowUnix: Int)` | `platform_wallet_claim_invitation` | Pubkeys via the existing `decode_registration_pubkeys_blob` (`pubkey_rows.rs:320`); returns id + handle via the **`resumeIdentityWithExistingAssetLock` convention** — `IdentityRegistrationNativeResult` (`[BJ)V`) + `ManagedIdentityHandleGuard` (`identity.rs:569-576, :57-76`). (Not `registerIdentityWithFunding`, which destroys the handle and returns only the id.) | - -Plus the reclaim-flag forwarding change in `identity.rs` / `credits.rs` (§3.1). - -### 4.2 JNI persistence bridge (`src/persistence.rs`) - -- New trampoline `tramp_persist_invitations`, modeled on `tramp_persist_asset_locks` - (`:1233`): loop the `InvitationEntryFFI` upsert slice and the `[u8;36]` removal slice, - calling **per-row** bridge methods (the bridge has no array-of-struct convention): - `onPersistInvitationUpsert(walletId, outPoint: ByteArray(36), fundingIndex: Int, - amountDuffs: Long, expiryUnix: Int, createdAtSecs: Int, hasInviter: Boolean, - status: Int)` and `onPersistInvitationRemoval(walletId, outPoint: ByteArray(36))`. - Any nonzero Kotlin return fails the round so `create_invitation` surfaces - funded-but-unrecorded instead of silently losing the row. -- Set `on_persist_invitations_fn: Some(tramp_persist_invitations)` (replacing the `None` - at `:174` and its fail-closed comment). -- ⚠ Lockstep rule: the Rust `call_method` descriptors and the Kotlin bridge signatures - must land in the same commit — a mismatch is a runtime failure, not a compile error. - **Descriptor coverage is tested, not eyeballed (adversarial M4):** an instrumented - test resolves every `NativePersistenceBridge` (name, signature) pair via a test-only - JNI export (`GetMethodID` over the loaded class — covering all slots, not just the - new ones), so a descriptor typo fails CI instead of failing the first funded create. -- **Address-pool silent-skip fix (adversarial M1, security-critical):** - `onPersistAccountAddressPoolEntry` currently early-returns when the parent account row - is missing (`fetchAccount(...) ?: return@stage`, - `PlatformWalletPersistenceHandler.kt:509`). For the invitation flow that skip is a - **lie to the pre-broadcast durability gate**: Rust treats the round's success as - "funding index durably recorded" and broadcasts; on restart `next_unused` resets and - the already-exported bearer key is re-exported — the 55937e15c1 bug class with no - crash needed. Fix in slice 1: for asset-lock funding account types, a missing parent - account row **fails the round** (nonzero) or upserts the row — never silently skips. - Add the upgrade-path test: a v7-era wallet with no invitation-account Room row → - first `createInvitation` must abort **before broadcast**, not succeed non-durably. - (Audit whether Swift's handler shares the skip; if so, flag upstream.) - -### 4.3 Kotlin SDK - -- `ffi/DashpayNative.kt`: three new `external fun`s matching §4.1. -- `ffi/NativePersistenceBridge.kt`: the two per-row slots from §4.2 - (`open fun … : Int = 0`). -- `persistence/PlatformWalletPersistenceHandler.kt`: - - implement both slots — stage upsert/delete of `InvitationEntity` rows via `stage {}` - keyed by `outPointHex` (the upsert-key ↔ removal-key seam, pinned by test as on - iOS). **Upserts are partial**: only the FFI-fed columns are written on conflict — - client-written columns (`statusRaw`, `reclaimInFlight`) are preserved, so a future - Rust re-emit of an existing outpoint can't reset local status; - - add `CAPABILITY_INVITATIONS: Long = 0x02` and OR it into - `persistenceCapabilitiesBits()` **in the same commit that wires the full path** - (bit + path are inseparable; attesting early re-opens the fail-closed hole). -- New wrapper surface (idiom of `IdentityRegistration.kt` / `IdentityCredits.kt`, suspend - on `Dispatchers.IO`, KDoc citing the Swift source): - - `createInvitation(amountDuffs, fundingAccountIndex, inviterIdentityId: ByteArray?, inviterUsername: String?): String` (Swift `ManagedPlatformWallet.createInvitation:2062`) - - `claimInvitation(uri, identityIndex, identityPubkeys, signer): ByteArray /* identityId */` (Swift `:2145`) — adopts then releases the managed handle per the resume idiom (the claimed identity is already folded into the identity manager + persisted Rust-side); key set = the existing `RegistrationKeys` **6-key** layout (4 base + DashPay enc/dec at keyIds 4–5), pre-persisted via the same path registration uses. - - `parseInvitation(uri): InvitationPreview` (Swift `:2211`) - - **New reclaim variants (§3.1):** `reclaimInvitationAsNewIdentity(outPointTxid: ByteArray(32), outPointVout: Int, identityIndex, identityPubkeys /* 4-key set — iOS reclaim-register uses authKeyCount=4, no DashPay pair */, signer)` and `reclaimInvitationAsTopUp(identityId: ByteArray(32), outPointTxid, outPointVout): ULong /* new balance */` — both passing `consumeInvitationVoucher = true`; the only call sites that ever do. - -### 4.4 Room (`persistence/`) — DB v7 → v8 - -`InvitationEntity` — field-exact port of `PersistentInvitation.swift` (and of -`InvitationEntryFFI` for the callback-fed fields): - -| Column | Type | Notes | -|---|---|---| -| `outPointHex` | String, `@Unique` | `:` via the same encode as the asset-lock entity — the upsert/removal join key | -| `rawOutPoint` | ByteArray(36) | raw `txid_le ‖ vout_le`; reclaim rebuilds the outpoint without re-parsing hex | -| `walletId` | ByteArray, indexed | | -| `fundingIndexRaw` | Int | display metadata; the key is re-derived Rust-side, **no secret column** | -| `amountDuffs` | Long | | -| `expiryUnix` | Int | inviter-side display only (not on the wire) | -| `createdAtSecs` | Int | | -| `hasInviter` | Boolean | | -| `statusRaw` | Int | **0=Created, 1=Claimed, 2=Reclaimed** — pinned Rust-side by `status_to_u8`; discriminants must match byte-for-byte; client-written after create | -| `reclaimInFlight` | Boolean, default false | crash-forensics marker, §4.6 — never a concurrency guard | -| `createdAt` / `updatedAt` | Long | | - -Additive migration `MIGRATION_7_8` + exported schema `8.json` + migration test against -`7.json` (instrumented tier, following `DashDatabaseMigrationTest`). DAO exposes a -`Flow` sorted by `createdAtSecs` desc for the list screen. - -### 4.5 KotlinExampleApp UI (Compose, `app/…/ui/dashpay/`) - -One screen/sheet per Swift file; testTags = iOS accessibility identifiers verbatim; -screens driven by Room Flows + snapshot data — never retained native handles. -**Coroutine-scope rule (all three network flows):** create/claim/reclaim run in an -app-/container-scope (not `rememberCoroutineScope`, which cancels on leaving -composition), with `withContext(NonCancellable)` around the -marker-write → consume → status-write sequence — a mid-flow dismissal must not strand -a half-done reclaim (mirrors the `performDashPaySend` double-send guard precedent). - -| Swift | Compose target | Notes | -|---|---|---| -| `InvitationsView.swift` | `InvitationsScreen` | Room Flow over all invitations filtered to loaded wallets (multi-wallet aware — each row reclaims via its own `walletId`); rows show amount, short outpoint, contact-request badge, expiry, status badge (Created/Claimed/Reclaimed); create entry hidden only when no wallet (an active identity is NOT required). Tags `dashpay.invitations.{list,create,reclaim}`, entry `dashpay.openSentInvitations` on `DashPayTabScreen`. | -| `CreateInvitationSheet.swift` | `CreateInvitationSheet` | amount field default **0.03 DASH**, UI range [0.003, 0.05] mirrored for display (Rust enforces); "Send a contact request back to me" toggle (default on, disabled without a username — inviter id+username passed only when opted in); result = QR (ZXing, in-memory bitmap only) + **share as text** (no image export → no `FileProvider` temp file holding the secret) + copy per the clipboard rules in §6; UI single-flight (submit disabled while creating). Tags `dashpay.invite.create.{amount,sendBack,submit,share,copy,done}`. | -| `ClaimInvitationSheet.swift` | `ClaimInvitationSheet` | URI via paste, the existing `QrScanner` route (`savedStateHandle` result), or a parked deep link; `parseInvitation` preview gated only on `structurallyValid` (amount shows "—"); claim wallet selection pins the iOS rule: the active identity's wallet, else the first loaded wallet, entry disabled when none (`DashPayTabView.swift:131-134`); identity index = next unused (reuse the existing registration index logic); claim pre-persists the 6-key `RegistrationKeys` set then calls `claimInvitation`; on success, if `hasInviter && inviterUsername != null` → "Add \?" prompt → DPNS resolve → `sendContactRequest` (both already ported); works with **no active identity** (fresh invitee); back/dismiss gated while claiming. Tags `dashpay.invite.claim.{uriField,submit}`. | -| `ReclaimInvitationSheet.swift` | `ReclaimInvitationSheet` | reachable only from `statusRaw == 0` rows; segmented target Top-up existing (identity picker) / Register new (**4-key set**); calls the new reclaim wrappers (§4.3); **in-memory `isReclaiming` single-flight gating submit AND dismissal** (Swift `:37,92-109,168-181`) — the persisted marker is crash forensics, never the concurrency guard (an unguarded recomposition off the Room Flow re-emit could double-consume and let the loser's classifier overwrite Reclaimed with Claimed); marker + classifier per §4.6. Tags `dashpay.invite.reclaim.{target,identityPicker,submit}`. | - -Navigation: new routes in `Routes.kt` + `AppNavHost.kt`; entry points on -`DashPayTabScreen` ("Sent invitations" + "Claim invitation", ids as on iOS). Deep link -per §3.3: `dashpay`/`invite` `VIEW` intent-filter on `MainActivity` → pending-invite -state → parked until a wallet exists → claim sheet, with the claim-in-flight deferral. - -### 4.6 Reclaim crash-safety: marker + classifier (verbatim port) - -- **Marker discipline:** capture `hadPriorReclaimInFlight`; persist - `reclaimInFlight = true` (the Room write **must succeed** — abort the reclaim if it - doesn't) only **immediately before** the on-chain consume; the register arm pre-persists - its identity keys **before** setting the marker. On observed success: `statusRaw = 2`, - clear the marker, save. -- **Classifier:** a pure `internal fun classifyReclaimFailure(error, hadPriorReclaimInFlight)` - in the app layer, arms identical to Swift (`ReclaimInvitationSheet.swift:406`): - 1. typed `DashSdkError.PlatformWallet.AssetLockAlreadyConsumed` (the mapped class for - FFI code 24, `DashSdkError.kt:246` — the local tombstone written only after our own - successful consume; match the type, not a numeric code) → **Reclaimed** - (`statusRaw = 2`); - 2. message contains `"already completely used"` (consensus 10504, exact canonical - phrase, lowercased-contains — the typed FFI code for this remains the known - follow-up) → **Claimed** if no prior marker (provably a foreign claim, neutral - "already claimed" copy, claimant never named); **ambiguous** if the marker was set - (resolves to the conservative terminal `Claimed` + ambiguity message, never an - inferred `Reclaimed`); - 3. message contains `"is not tracked"` with the marker set → explicit ambiguity error, - state unchanged; - 4. else generic error; clear a stale marker only when `!hadPrior && isNotTracked`. - -## 5. Work plan (commit slices) - -1. **Persistence spine (the unblocker):** `InvitationEntity` + DAO + `MIGRATION_7_8` + - `8.json`; per-row bridge slots + handler impl (partial upsert); JNI trampoline; flip - `on_persist_invitations_fn` to `Some`; attest `CAPABILITY_INVITATIONS`; **the - address-pool silent-skip fix + upgrade-path test (§4.2)** — all one commit - (capability bit and path are inseparable). Gate: migration test (instrumented) + - handler mapping tests + the descriptor-resolution instrumented test + - `cargo check -p rs-unified-sdk-jni`. -2. **Parse + create + claim bindings:** three JNI exports + `external fun`s + SDK - wrappers + `InvitationPreview` type. Gate: `./build_android.sh --verify` + - symbol-load smoke. -3. **Reclaim:** JNI flag forwarding (guard dropped in exactly the two bindings) + the - two new outpoint-taking Kotlin reclaim wrappers. Gate: compile + tests pinning that - every generic-recovery path still passes `false` and the generic wrappers still - reject invitation locks. -4. **App UI:** four screens + routes + DashPay-tab entry points + deep-link filter with - walletless parking + classifier + marker discipline + single-flight/scope rules. - Gate: `:app:assembleDebug` + classifier/UI tests. -5. **QA + parity bookkeeping:** PARITY.md rows, emulator QA runs (§7), doc updates. - -## 6. Security invariants preserved (review checklist) - -- **Voucher-key-reuse defense (55937e15c1):** honest capability attestation + durable - round commits + **no silent skips anywhere in the funding-index persist chain** - (§4.2 M1 fix). `CAPABILITY_INVITATIONS` is attested only in the commit that wires the - full path; the handler stages into the round buffer (§3.4); a persist path that cannot - complete must fail the round, never no-op. -- **Durability residual (documented, accepted):** Room runs WAL + - `synchronous=NORMAL` — safe across process death (the relevant Android failure mode), - but a hard **power loss** can roll back the most recent committed transaction. This is - pre-existing for every already-attested capability bit and the same class of residual - iOS carries; documented rather than silently assumed. (Optional hardening — - `synchronous=FULL` for the wallet DB — is Open Question 4.) -- **Bearer-secret hygiene:** the URI embeds the voucher WIF. Never logged (no - logcat/android_logger, no exception messages carrying the URI — §4.1 rule — no - crash-report breadcrumbs), never persisted. Clipboard: Android has **no** - local-only or auto-expiring clipboard primitive (unlike iOS's - `localOnly + expirationDate:+60s`), and `ClipDescription.EXTRA_IS_SENSITIVE` is - API 33+ with minSdk 29 — so: set the flag when `SDK_INT >= 33`, and actively - compare-and-clear the clipboard after ~60 s (matching the iOS window); the missing - device-scoping half is an accepted, documented platform gap. Share = text-only - (no secret-bearing temp image files). QR rendered from an in-memory bitmap - (`util/QrCode.kt` precedent, no file/cache writes). -- **Backup:** the example app's manifest already sets `android:allowBackup="false"` - (Room DB has no secret column regardless). Note for integrators: this is an app-level - property the SDK cannot enforce — production apps must set it themselves. -- **Path gate untouched:** zero changes to `rs-platform-wallet` / `rs-platform-wallet-ffi` - / `rs-sdk-ffi`, so the `9'/coin'/5'/3'/idx'` export gate and the amount caps stay - exactly as reviewed on iOS. -- **`consume_invitation_voucher` discipline:** `true` appears at exactly two call sites - (the two reclaim wrappers, reached only from the reclaim sheet); every generic - resume/top-up path stays `false` and Rust refuses invitation locks there regardless. -- **Single-flight everywhere funds move:** create sheet (submit disabled while - creating; Rust's per-wallet build-persist mutex is the backstop) **and** reclaim - sheet (`isReclaiming` gating submit + dismissal — §4.5); claim sheet gates - back/dismiss while claiming. - -## 7. Test / verification plan - -- **JVM unit:** classifier matrix (port `ReclaimInvitationClassifierTests` — all arms, - incl. marker/no-marker ambiguity split and stale-marker clearing); - `InvitationEntity` upsert/removal key-seam test (outPointHex form drift, mirror of - `InvitationPersistenceTests`); handler mapping incl. removal → DAO delete and the - **partial-upsert preserves `statusRaw`/`reclaimInFlight`** pin; status discriminant - pin (0/1/2); generic wrappers still reject invitation locks. -- **Rust:** `cargo check -p rs-unified-sdk-jni` (+ clippy). -- **Instrumented (CI emulator):** Room `MIGRATION_7_8` against `7.json`; - `FfiSmokeTest`-style symbol load for the new externs; the **descriptor-resolution - test over every bridge slot** (§4.2); a capability-bits assertion that the handler - satisfies `INVITATION_CREATION`; the **upgrade-path test** (missing invitation-account - row → create aborts before broadcast). -- **Testnet-gated (`-Ptestnet=true`) / emulator QA** (`emulator-control` skill; faucet is - rate-limited → self-fund): mirror the iOS rows DP-12..DP-19 — - create (funded, row lands in Room + list), claim (second wallet, no funds → funded - identity; optional contact bootstrap), malformed/reused/wrong-network rejection (fail - loud, no side effects), sent-list persistence + upsert-in-place, reclaim-as-top-up - (balance rises, row → Reclaimed), reclaim-as-register (4-key set), already-consumed - race (second consume → deterministic "already completely used" → neutral Claimed - copy). The funded two-wallet race remains manual, as on iOS. -- **Acceptance gate:** the funded create→claim e2e on the emulator against testnet. - -### Funded e2e evidence (2026-07-23, arm64 emulator + testnet) - -- Instrumented tier: 32/32 green (Room `MIGRATION_7_8` + full v1→v8 chain, - `persistenceBridgeDescriptorsAllResolve`, FFI smoke; 3 testnet-gated skips). -- Create (DP-12/16): 0.03 DASH voucher funded + InstantSend-locked, legacy link - emitted, row landed in Room + Sent list as `Created` (outpoint `f9ef7f5b…:0`). -- Claim (DP-13): link pasted → valid preview → new identity `292ebab4…` - registered with 2 818 643 580 credits, funded solely by the voucher. -- Already-consumed (DP-19 classifier, live): reclaiming the claimed voucher hit - consensus 10504 → neutral "This invitation was already claimed.", row → - `Claimed`, reclaim affordance gone. -- Interrupted create: a second create timed out waiting for its InstantSend - lock — the funded row (`732bf38c…:0`) was already persisted and reclaimable, - proving the persist-before-proof-wait ordering on Android. -- Reclaim-as-top-up (DP-17): that voucher consumed into identity `7023bed1…`, - balance 49 818 637 700 → 52 736 112 114 credits, row → `Reclaimed`. -- Malformed link (DP-15): garbage URI → "Invalid invitation link.", claim - disabled, no side effects. -- Not exercised here: the two-wallet contact-bootstrap (DP-14 — inviter had no - DPNS username, so the opt-in toggle was correctly disabled) and - reclaim-as-register (DP-18 — same FFI + wrapper as the seam-tested claim - path); both remain manual QA, matching iOS. - -## 8. Failure modes - -- **Capability over-attestation / silent persist skips** → voucher-key reuse (the - pre-port bug class; adversarial M1 shows a no-crash variant via the address-pool - skip). Guard: bit + path in one commit; the M1 fail-the-round fix; instrumented - capability + upgrade-path assertions. -- **Handler write outside the round buffer** → broken round atomicity or lost-on-crash - rows reported as persisted. Guard: §3.4 rule + mapping tests exercise `stage {}`. -- **JNI descriptor mismatch** → every invitation persist fails at runtime — after real - funds broadcast, if untested. Guard: lockstep rule + the descriptor-resolution - instrumented test **before** any funded QA. -- **Crash between broadcast and the invitation-row round** → a funded voucher with no - Room row: invisible in Sent invitations, unreclaimable from the UI (reclaim is - row-driven), funds stranded pending manual recovery. Inherent to the shared - persist-after-broadcast ordering (iOS carries the same window); the - funded-but-unrecorded error copy must surface the **outpoint** so support/manual - recovery is possible. -- **Process death mid-reclaim** → marker semantics resolve it (our-tombstone → - Reclaimed; foreign claim → Claimed; genuinely ambiguous → conservative Claimed + - message). Double-tap/recomposition races are excluded by the in-memory single-flight - (§4.5), not by the marker. -- **URI leakage via logs/exceptions/clipboard/share files** → bearer theft. Guard: - §4.1 exception rule + §6 clipboard/share rules. -- **ChainLock fallback at create** → Rust rejects the link, lock stays reclaimable; - surface the error copy as iOS does. -- **Deep link with no wallet** → parked, not dropped (§3.3); "create a wallet to claim" - copy. -- **QR/paste garbage** → `structurallyValid=false` preview, claim button disabled, no - side effects. - -## 9. Out of scope - -- Typed FFI code for consensus 10504 already-consumed (shared iOS/Android follow-up). -- A typed funded-failure result carrying the recovery outpoint from - `create_invitation` (adversarial-review follow-up): on the rare - funded-but-row-persist-failed path the outpoint doesn't cross the JNI boundary - (the FFI fills out-params only on success — changing that is a shared-Rust - change). Recovery exists meanwhile via the Rust-side tracked-lock list - (diagnostics surface); the common interrupted-create case persists the row - and is reclaimable from the UI (funded-e2e verified). -- Driving the Rust pre-broadcast abort from a JVM test (the broadcast boundary - is Rust-side; the Kotlin tier pins the round-failure half — - `invitationPoolEntryWithoutWalletFailsTheRound` — and Rust's own - `create_invitation_requires_durable_persistence` pins the abort). -- Any `rs-platform-wallet` / `rs-platform-wallet-ffi` change (incl. Rust-emitted - Claimed/Reclaimed status changesets — latent on iOS too). -- Fixing the iOS walletless deep-link drop (flagged upstream, §3.3). -- AppsFlyer/OneLink or App-Links-style verified deep-link transport (tracked separately - for iOS as well). -- Contested-name claim tier (deferred on iOS). - -## 10. Decisions (RESOLVED — owner sync, 2026-07-22) - -1. **Reclaim JNI guard drop: approved.** `generic_asset_lock_recovery_allowed` is - removed from exactly the two forwarding bindings; Rust core's - `authorized_invitation_reclaim` remains the (independently unit-tested) enforcement — - the same single-gate trust model shipped on iOS. -2. **Deep link: both filters.** `dashpay://invite` custom scheme + the legacy AppsFlyer - `https://invitations.dashpay.io/applink` host (unverified chooser participation until - #4096 serves `assetlinks.json`), with walletless parking (§3.3). -3. **PR strategy: one PR, five sliced commits** (§5). -4. **WAL durability: accept + document** the `synchronous=NORMAL` power-loss residual - (§6) — platform-wide status quo, same class as iOS; `synchronous=FULL` would tax - every wallet write and belongs to a separate measured change if ever. diff --git a/docs/dashpay/KOTLIN_MIGRATION_FOLLOWUPS_SPEC.md b/docs/dashpay/KOTLIN_MIGRATION_FOLLOWUPS_SPEC.md deleted file mode 100644 index db993c19c1e..00000000000 --- a/docs/dashpay/KOTLIN_MIGRATION_FOLLOWUPS_SPEC.md +++ /dev/null @@ -1,219 +0,0 @@ -# Kotlin DashPay Migration — Follow-up Fixes Spec - -**Historical status:** Items A, B, and C are resolved in the consolidated -migration; the completed work is recorded in -[`KOTLIN_MIGRATION_LEFTOVERS.md`](KOTLIN_MIGRATION_LEFTOVERS.md). Item D remains -deferred. The problem/approach sections below preserve the reviewed rationale -and pre-implementation baseline rather than describing current open work. - -Scope: three DashPay follow-ups folded into the consolidated migration PR -(`feat/kotlin-sdk-dashpay-migration`, stacked on the base Kotlin SDK PR -`feat/kotlin-sdk-and-example-app`). A fourth item (durable contact-crypto -queue persistence) is **deferred** with rationale in §D. - -All three items were verified **still open at the base PR HEAD** (`1fd86fbae5`) -and **not** addressed by the parent PR — none duplicates parent work. - ---- - -## A. Platform-address signing: eliminate the JVM-String seed exposure - -### Problem -`KeystoreSigner.signPlatformAddressOnDemand` retrieves the wallet mnemonic as a -`java.lang.String` (`WalletStorage.retrieveMnemonic`) and passes it across JNI to -`SignerNative.signWithMnemonicAndPath(mnemonic: String, …)`. An immutable `String` -cannot be scrubbed; the phrase sits on the JVM heap until GC (if ever), -recoverable from a heap dump. This is the exact anti-pattern the K2 resolver path -eliminated with `resolveMnemonicInto`, and it is explicitly flagged as the tracked -follow-up in three places (`KeystoreSigner.kt:151-157`, `SignerNative.kt:36-38`, -`signer.rs:436-437`). Every on-demand platform-address signature re-exposes the seed. - -### Approach (out-buffer discipline, input direction) -Pass the phrase as a **scrubbable `ByteArray`** the caller owns and zeroes, so no -`String` of the seed ever exists on the signing path. - -- **Rust JNI** (`packages/rs-unified-sdk-jni/src/signer.rs`): replace - `Java_…_SignerNative_signWithMnemonicAndPath` with - `Java_…_SignerNative_signWithMnemonicAndPathInto`; the mnemonic argument becomes - `JByteArray`. Decode the non-secret args (path, payload) **first** and the - mnemonic **last**. **Do NOT use `convert_byte_array` + `reserve_exact`** — that - pattern orphans an unscrubbed plaintext heap copy: `convert_byte_array` returns a - `cap==len==N` `Vec`, and `CString::new`'s NUL append then forces a growth realloc - that frees the original buffer unscrubbed (real on Android's Scudo allocator). - Instead keep the phrase in a `Zeroizing>` **end-to-end** — never launder - it through a `CString` (whose `into_boxed_slice` shrink-to-fit can silently realloc - and free the plaintext unscrubbed, and which sits outside any zeroize guard across - the FFI call): - ```rust - let n = env.get_array_length(&mnemonic)? as usize; // reject Err - let mut plain: Zeroizing> = Zeroizing::new(Vec::with_capacity(n + 1)); - plain.resize(n, 0); // len=n, cap=n+1 - let dst = unsafe { slice::from_raw_parts_mut(plain.as_mut_ptr().cast::(), n) }; - env.get_byte_array_region(&mnemonic, 0, dst)?; // reject Err - if plain.contains(&0) { throw; return } // reject interior NUL - plain.push(0); // NUL-terminate in place, no realloc - // pass plain.as_ptr().cast::() to the FFI; drop(plain) scrubs the sole - // copy after signing — and on any panic unwind, since it never leaves Zeroizing. - ``` -- **Kotlin FFI decl** (`ffi/SignerNative.kt`): replace the `external fun` with - `signWithMnemonicAndPathInto(mnemonicUtf8: ByteArray, derivationPath: String, - network: Int, data: ByteArray): ByteArray?`. -- **Caller** (`security/KeystoreSigner.kt`): switch - `storage.retrieveMnemonic(...)` → `storage.retrieveMnemonicUtf8(...)` (returns - `ByteArray?`, already exists) and wrap the sign in - `try { … } finally { mnemonicUtf8.fill(0) }`. Delete the KNOWN-residual comment - block (151-157) — it is now resolved. - -The old String function has a **single caller** (verified), so it is removed -outright; no String seed path remains. The derived key still never crosses JNI — -Rust derives, signs, and scrubs internally, unchanged. - -### Failure modes -- Interior NUL in the phrase bytes → reject before the FFI call; dropping the - `Zeroizing>` scrubs the phrase copy. -- Empty byte array → a NUL-only buffer reaches the inner FFI, whose mnemonic parse - fails with an invalid-mnemonic error. There is **no size cap** (a large array just - allocates and then fails the parse); the caller's `retrieveMnemonicUtf8` only ever - returns real phrase bytes or null, so this is a defense-in-depth path. -- Caller forgetting to scrub → mitigated by the `finally` block; the array is - caller-owned per `retrieveMnemonicUtf8`'s contract. The Kotlin `null`-check must - precede the `try` (nothing fallible between the retrieve and entering the `try`). -- Note: the JNI symbol name (`Java_…_signWithMnemonicAndPathInto`) must match the - Kotlin `external fun` exactly — a mismatch is a runtime `UnsatisfiedLinkError`, not - a compile error, and the JVM helper test won't catch it (pinned by `cargo check` + - an instrumented/on-device sign smoke). - -### Test plan (red→green) -`KeystoreSigner` cannot be constructed on the JVM tier (its constructor eagerly -calls native `createSigner`), and `WalletStorage` is a final class with no mock -framework in the module — so a seam *on the class* is untestable. Instead extract -a **pure `internal` scrub-and-sign helper** that owns the discipline: - -```kotlin -internal inline fun signWithScrubbedMnemonic( - mnemonicUtf8: ByteArray, - derivationPath: String, - network: Int, - data: ByteArray, - sign: (ByteArray, String, Int, ByteArray) -> ByteArray?, -): ByteArray? = try { sign(mnemonicUtf8, derivationPath, network, data) } - finally { mnemonicUtf8.fill(0) } -``` - -`KeystoreSigner.signPlatformAddressOnDemand` calls it with -`sign = SignerNative::signWithMnemonicAndPathInto`. JVM test -(`SignerMnemonicScrubTest`): call the helper with a fake `sign` that records a -**copy** of the bytes it received and returns a dummy signature, then assert the -input array is all-zero afterward, and that the dummy signature is returned -(scrub happens after the sign, not before). Red→green is demonstrated by toggling -only the `finally { fill(0) }` — without it the array retains the phrase (red), -with it the array is zeroed (green). This pins the scrub invariant honestly (the -String-path removal itself is a structural guarantee, verified by the code + the -absence of any `retrieveMnemonic`/String-sign call on the signing path). -Plus `cargo check -p rs-unified-sdk-jni` for the Rust side. - ---- - -## B. Payment-sheet dispose-mid-send: pin the double-send guard - -### Problem -`SendDashPayPaymentSheet.send()` broadcasts a payment and then records the txid + -triggers the durability refresh (`onSent` → `refreshDashPayPayments`). The K3 fix -wraps the broadcast + bookkeeping in `withContext(NonCancellable)` (plus a Compose -dismissal gate in `ContactDetailScreen`) so a dispose-mid-send cannot skip the -durability refresh — which would otherwise invite a **double-send** on retry (the -JNI broadcast is uncancellable; the coin leaves the wallet regardless). This guard -has **no regression coverage**; a future refactor could drop `NonCancellable` -silently. - -### Approach (extract the send body + inject the sender) -`send()` is a local function inside a `@Composable`, uncallable from `runTest`. -**Extract the coroutine body** (`SendDashPayPaymentSheet.kt:90-120`) into a -non-composable `internal suspend fun performDashPaySend(...)` that takes a minimal -`fun interface PaymentSender { suspend fun send(...): ByteArray? }` plus the -`onSent`/`onClose`/status callbacks. The composable calls it inside its existing -`scope.launch { … }`, so runtime behavior (including the launching Job) is -identical; the production `PaymentSender` closes over `wallet`/`manager` and calls -`w.dashpay.sendPayment(...)`. `performDashPaySend` keeps the `withContext( -NonCancellable) { sender.send(...); onSent() }` wrapper — the guard under test. - -**Critical extraction constraints** (else the test is invalid): -- `performDashPaySend` is a plain `suspend fun` inheriting the caller's Job — it - must NOT introduce a new `CoroutineScope`/`coroutineScope { }`, which would - change the cancellation semantics being tested. -- The `CancellationException` rethrow stays; the best-effort tail - (`kickDashPaySync`/`delay`/`onClose`) stays OUTSIDE the `NonCancellable` block. - -### Test plan (red→green) -JVM `runTest` (`PerformDashPaySendDoubleSendGuardTest`): launch -`performDashPaySend` in a child `Job` with a fake `PaymentSender` that records the -broadcast **before** suspending on a test gate (models "broadcast completed, -bookkeeping pending" — the real hazard, not "cancelled before broadcast"): -1. launch the send; await the recorded broadcast entry; -2. cancel the child Job (simulate dispose-mid-send); -3. release the gate; join. -Assert **`onSent` fired exactly once** (count 0 pre-fix vs 1 post-fix — the -decisive assertion; the `NonCancellable` block completed despite cancellation). -Against the pre-fix code (no `NonCancellable`), cancellation observed on resume -after the broadcast skips `onSent` → count 0 → red. Assert on `onSent` count, NOT -`sendPayment` count (which is 1 either way). - -The Compose **dismissal gate** (secondary, defense-in-depth) is left to the -existing instrumented UI tier; it is not the double-send regression and is not -deterministically JVM-testable without Robolectric (not configured). - ---- - -## C. DataContractRef: add the NativeCleaner GC backstop - -### Problem -`DataContractRef` (`queries/PlatformQueries.kt:621-633`) destroys its native handle -inline in `close()` but registers **no** `NativeCleaner`, so a leaked (never-closed, -never-`use{}`) ref leaks the native contract handle forever. Every other owned -handle — `Sdk`, `ManagedPlatformWallet`, and the K3 `ContactRequestRef`/ -`EstablishedContactRef` — has the GC backstop. Consistency gap. - -### Approach -Match the established idiom exactly: `import NativeCleaner`; register -`private val cleanable = NativeCleaner.register(this, HandleCleanup(handleRef))`; -`close()` → `cleanable.clean()`; standalone inner -`private class HandleCleanup(handleRef: AtomicLong) : Runnable` whose `run()` does -`handleRef.getAndSet(0)` then `QueriesNative.dataContractDestroy(h)` iff non-zero -(destroys exactly once, whichever of close/GC fires first). `AtomicLong handleRef` -and `value` are unchanged; no call sites change. - -### Test plan -`close()` idempotency + registration are the testable surface; the native destroy -and GC-timing of the phantom backstop are not deterministically unit-testable. Rely -on parity with the already-shipped `ContactRequestRef` pattern + compile; add a -`NativeCleaner`-level idempotency assertion only if a fake action seam already -exists. (Noted as best-effort per the untestable-path carve-out.) - ---- - -## D. Durable contact-crypto queue persistence — DEFERRED (own PR) - -The DashPay deferred contact-crypto queue (`PlatformWalletChangeSet. -pending_contact_crypto_added/_cleared`) is not durable across process death on FFI -hosts. Research (see below) shows only the **write** half is cleanly addable; the -**restore** half is blocked upstream: - -- The in-repo SQLite persister already writes the queue, but its reader is - `#[cfg(test)]`-only "because production load restore is blocked upstream - (`LOAD_UNIMPLEMENTED: ClientStartState::wallets`)". -- `rs-platform-wallet/src/wallet/apply.rs:111-116` drops the queue fields on the - changeset-replay path; restore is meant to flow through the wallet **start-state** - path, which isn't wired for this field. - -Adding the FFI `on_persist_pending_contact_crypto_fn` slot + JNI trampoline + a -`DashDatabase` v3→v4 Room migration + Swift SwiftData model would persist rows that -**nothing reads back** — a large, irreversible, cross-cutting surface (including a -schema migration) for **zero cross-restart durability** until the upstream -start-state restore exists. This is worse than the current honest deferral: the -recurring signerless sweep already re-enqueues the work after restart, so the only -exposure is delayed (not lost) contact-crypto work between sweeps. - -**Decision:** keep as a documented leftover; implement as a dedicated PR once the -`rs-platform-wallet` start-state restore path lands. Full data-path map (producers, -SQLite schema, FFI/JNI/Kotlin/Swift layers, the add-a-persisted-field recipe) is -recorded for that future PR. diff --git a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md b/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md deleted file mode 100644 index 7113bb97c58..00000000000 --- a/docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md +++ /dev/null @@ -1,98 +0,0 @@ -# Kotlin DashPay Migration — Known Leftovers & Follow-ups - -Durable record of what is and isn't done as of the consolidated DashPay migration -PR (K1 + K2 + K3 + the follow-up fixes below). Kept so nothing is silently lost -when the four stacked PRs collapse into one. - -## Resolved in this PR (follow-up fixes) - -- **Seed hygiene on the platform-address signing path** — the mnemonic now crosses - JNI as a scrubbable UTF-8 `byte[]` (`signWithMnemonicAndPathInto`) that the caller - zeroes after use, never an un-scrubbable `java.lang.String`. Rust copies it - directly into a pre-sized `Zeroizing>`, NUL-terminates that buffer in - place, and never routes the phrase through `CString`. Pins: - `SignerMnemonicScrubTest` (JVM, red→green). -- **Payment dispose-mid-send double-send guard** — the send flow was extracted to - `performDashPaySend` + a `PaymentSender` seam so the `withContext(NonCancellable)` - guard (broadcast + durability bookkeeping stay atomic against a mid-send teardown) - has a deterministic regression test: `PerformDashPaySendDoubleSendGuardTest` (JVM, - red→green). -- **`DataContractRef` GC backstop** — now registers a `NativeCleaner` like every - other owned handle, so a leaked (never-closed) ref no longer leaks the native - contract handle. -- **`signWithMnemonicAndPathInto` instrumented sign smoke** — - `FfiSmokeTest.mnemonicAndPathSignerSymbolLoadsAndSigns` loads the exact JNI - symbol on-device, derives from a valid BIP-39 vector, and returns a compact - recoverable signature. The direct test caller scrubs its JVM-owned mnemonic - array in `finally`; JNI scrubs only its Rust-owned copy. - -## Deferred to dedicated follow-up PRs - -- **Durable contact-crypto queue persistence (item D).** The DashPay deferred - contact-crypto queue (`PlatformWalletChangeSet.pending_contact_crypto_added/ - _cleared`) is not durable across process death on FFI hosts. Only the *write* half - is cleanly addable; the *restore* half is blocked upstream — the in-repo SQLite - persister's reader is `#[cfg(test)]`-only "because production load restore is - blocked upstream (`LOAD_UNIMPLEMENTED: ClientStartState::wallets`)", and - `rs-platform-wallet/src/wallet/apply.rs` drops the queue fields on the - changeset-replay path. Adding the FFI slot + JNI trampoline + a `DashDatabase` - v3→v4 Room migration + Swift model would persist rows nothing reads back — a large, - irreversible surface (incl. a schema migration) for zero durability until the - upstream start-state restore lands. The recurring signerless sweep already - re-enqueues the work after restart, so the exposure is *delayed*, not *lost* - contact-crypto work between sweeps. Do it as its own PR once the start-state - restore exists. Full data-path map + add-a-persisted-field recipe recorded during - the follow-ups research. - -- **PARITY: 94 ported / 5 partial / 0 deferred views** - (`packages/kotlin-sdk/PARITY.md`). All 10 DashPay screens remain fully ported. - The five partials are `CreateIdentityView`, `IdentityDetailView`, - `TransitionDetailView`, `WalletMemoryExplorerView`, and `CoreContentView`. - All 23 transition-catalog definitions now execute; `TransitionDetailView` is - partial only because `identityUpdate`'s add-keys sub-path remains on the - dedicated AddIdentityKey flow rather than the catalog form. Each partial row - names its concrete remaining FFI, persistence, UI/catalog-adaptation, device, - or restart gate. - -- **`SigningKeyUnavailable` MESSAGE_MARKER fallback removal.** The signer's - "missing key" failure now travels as a typed completion code (rs-sdk-ffi - `DashSDKSignerErrorCode::SigningKeyUnavailable` → platform-wallet code 31 → - `DashSdkError.PlatformWallet.SigningKeyUnavailable`). The message-marker - sniff on the catch-all codes is retained ONLY for the #4191 merge-order - transition and for conversion paths that lose the machine prefix — NOT for - mixed old-native/new-Kotlin builds, which the completion JNI arity change - (3→4 args) makes unsupported outright; delete it (and `MESSAGE_MARKER`'s - matcher role) in the next minor release. Accepted residual until rs-dpp grows a typed variant: the - Rust-internal segment rides the `signer_error:key_unavailable: ` prefix - through `ProtocolError::Generic` (typed at both ABI edges, one Rust-owned - constant bridging the string segment). - -- **On-device `KeyPermanentlyInvalidatedException` coverage.** The - invalidation recovery (generation-checked alias deletion + re-derive via - forced repair) is pinned at the unit tier through the fake Keystore seam; - a REAL KPIE requires biometric re-enrollment mid-test, which CI's emulator - cannot do — same residual #4172 accepted. Exercise manually per the device - test plan when touching the invalidation path. - -## Environment-bound (cannot be code-fixed here) - -- **End-to-end send→accept→pay testnet UAT** — device/testnet-bound; not runnable in - CI. Must be exercised on a real testnet wallet before relying on the full DashPay - flow. -- **Live-network paths** (`searchDpnsNames`, `dashPaySyncNow`, the DashPay write - paths) are exercised only under the `-Ptestnet=true` instrumented tier. - -## Behavioral notes to carry (from the base Kotlin SDK PR) - -- `rs-sdk-ffi` / `rs-sdk-trusted-context-provider` use rustls + webpki roots instead - of the platform TLS stack (OpenSSL doesn't exist on Android). This also changes the - iOS trust roots — no API change, but worth an iOS-side look. - -## Review findings — all resolved upstream - -Every P0/P1/P2 from the base-PR reviews (invalid Cargo `--features` arg, JNI -local-frame leaks, negative-amount/index/selector validation across credits/tokens/ -funding/identity, `sendDashPayPayment` guard) was verified **already fixed** at the -base PR HEAD before this consolidation — none was outstanding. The only carried -review finding was the contact-crypto durability suggestion, addressed as item D -above. diff --git a/docs/dashpay/KOTLIN_MIGRATION_SPEC.md b/docs/dashpay/KOTLIN_MIGRATION_SPEC.md deleted file mode 100644 index 784ff8897d8..00000000000 --- a/docs/dashpay/KOTLIN_MIGRATION_SPEC.md +++ /dev/null @@ -1,441 +0,0 @@ -# DashPay — Kotlin/Android Migration Spec - -**Historical baseline:** This document preserves the reviewed pre-migration plan -and its then-current 88/90 inventory. It is not the current open-work tracker: -subsequent implementation changed the contact-crypto persistence boundary, -shipped invitation support on iOS, and closed the cited transition FFI gaps. See -[`packages/kotlin-sdk/PARITY_SUMMARY.md`](../../packages/kotlin-sdk/PARITY_SUMMARY.md) and -[`docs/sdk/sdk-parity-manifest.json`](../sdk/sdk-parity-manifest.json) for current -capability status. - -Port the complete DashPay feature (as shipped for iOS in PR #3841, merged to -`v4.1-dev` 2026-07-06) to the Kotlin/Android SDK and KotlinExampleApp -(PR #3999, branch `feat/kotlin-sdk-and-example-app`). - -Status: **v2 — post five-lens review** (feasibility, scope, security, -adversarial, domain-fit); all must-fixes folded in. -Reference implementation: `packages/swift-sdk` + SwiftExampleApp. -Feature spec: `docs/dashpay/SPEC.md` (+ companion specs in `docs/dashpay/`). - ---- - -## 1. Problem - -The Kotlin SDK (PR #3999) is a one-for-one Android port of SwiftExampleApp, -snapshotted **before** PR #3841 landed. #3841 completed DashPay on iOS: - -- Replaced the utilitarian `FriendsView` (which the Kotlin `FriendsScreen` - mirrors — now a port of a **deleted** Swift view) with a first-class - **DashPay tab**: 10 views, ~3.8k LOC (`Views/DashPay/`). -- Added a recurring **DashPay background sync** service - (`platform_wallet_manager_dashpay_sync_*`, 7 FFI fns). -- Added **payment history**, **cached contact profiles**, **contactInfo** - (alias/note/hidden, cross-device), **ignore tombstones**, **seedless - unlock + deferred contact-crypto drain**, and **DIP-15 auto-accept QR**. -- Net: 23 new C exports in `rs-platform-wallet-ffi`; 6 old exports removed - (incl. the `*reject_contact_request` pair — reject became ignore). - -Today the Kotlin stack has: 3 of 5 DashPay Room entities (field-exact with -SwiftData), the send/accept/ignore/sync contact-request pipeline bridged -(17 JNI exports in `tokens.rs`), and one compressed `FriendsScreen`. It -lacks: profile/contactInfo writes, payments, the contact-profile cache, the -sync service, QR, seedless unlock, wallet-scoped DPNS search, and the -DashPay tab. `PARITY.md` still claims 88/90 ported against the stale -pre-#3841 view list. - -**Compatibility baseline:** the existing 17 exports were reconciled with -the #3841 Rust in commit `2298a2059f` (reject→ignore, `coreSignerHandle` -threading, contacts-vtable ignored-sender deltas + contactInfo metadata -fields, Room v1→v2). That reconciliation was verified by compile + 3 -Robolectric tests only — no runtime exercise of the Android -send/accept/ignore path against a network has been recorded since the -merge. K1 therefore opens with a runtime revalidation gate (§4). - -**Goal:** feature parity with iOS DashPay per the parity doctrine -(`kotlin-sdk/CLAUDE.md`): cite Swift sources in KDoc, reuse iOS -accessibility identifiers as Compose `testTag`s, keep all orchestration in -Rust. - -## 2. What does NOT need porting - -All DashPay *logic* is shared Rust, already compiled into this branch and -consumed by iOS through the same C ABI: - -- Contact-request crypto (ECDH, AES-256-CBC, DIP-15 69-byte compact xpub), - DIP-14/15 derivation, accountReference — `rs-platform-wallet` + - `platform-encryption` + `key-wallet`. -- Accept → reciprocal request → `register_external_contact_account` - (friendship address derivation) — automatic inside Rust. -- The recurring sync sweep (`manager/dashpay_sync.rs`), seed binding - verification, deferred contact-crypto queue, auto-accept QR - build/parse/proof. -- The bundled DashPay contract. - -Kotlin re-implements **none** of this. Per doctrine, no JNI function may -stitch Rust calls together — any composite gap found during the port goes -into `rs-platform-wallet-ffi` as its own reviewed change (none are -currently known to be needed; iOS ships on the existing exports). - -**Deliberately not bridged** (zero non-README callers in the Swift app — -the app reads this data from persisted rows, not handles; verified -function-by-function during review): - -- The 8 `contact_request_*` field getters + `contact_request_create`. -- The 14 `established_contact_*` accessors + - `managed_identity_get_established_contact` / - `_get_sent_contact_request` — `ContactDetailView` reads alias/note/ - hidden/paymentChannelBroken off `PersistentDashpayContactRequest` rows - and writes via `set_dashpay_contact_info_with_signer`. -- `managed_identity_is_contact_established`, - `platform_wallet_pubkey_hash_from_private_key` (no app consumers), - and the `managed_identity`-level send/accept/ignore variants (Kotlin - uses the `platform_wallet_*` composites, as iOS does). - -Consequence: **no new handle-wrapper types.** The existing -`ContactRequestRef` / `EstablishedContactRef` (`tokens/Dashpay.kt`) stay -as-is for the accept path; new screens are driven by Room Flows and -snapshot data classes. Rule: never retain a native handle in Compose -composition — snapshot fields at the JNI boundary (a Cleaner can free a -handle mid-read otherwise). - -## 3. Approach - -Three milestones, re-cut after review so that **every bridged function -lands in the milestone that first consumes and tests it** (the original -bottom-up "bridge everything first" cut concentrated all marshaling -defects at final UAT — rejected). - -### Alternatives rejected - -- **Re-orchestrating DashPay flows in Kotlin**: forbidden by doctrine; - Swift ships proof that single-call composites suffice. -- **Keeping `FriendsScreen` and bolting features onto it**: its Swift - counterpart was deleted; keeping a dead view's port violates parity. -- **uniffi / JNA instead of hand-rolled JNI**: the crate has 160 - hand-rolled exports with established support patterns (`support::guard`, - handle-as-jlong, GlobalRef callbacks); mixing binding generators adds - toolchain cost for no capability gain. -- **Bridging the full ~47-function FFI sweep**: 24 of them have no - consumer anywhere in the reference app (see §2); bridging them would - add dead surface + a speculative handle-wrapper abstraction. - -## 4. Work plan - -New JNI exports go in a new `rs-unified-sdk-jni/src/dashpay.rs` + -`ffi/DashpayNative.kt`; the 17 existing DashPay exports stay in -`tokens.rs` (moving them is a follow-up refactor). Array-free FFI -counterparts (`dashpay_payment_array_free`, `dpns_search_results_free`, -`platform_wallet_manager_free_account_balances`) are consumed Rust-side -inside the JNI wrappers, as the existing exports do. - -⚠ Lockstep rule: extending the identity-entry persist callback changes the -`onPersistIdentityUpsert` JNI method descriptor (hand-written string, -`persistence.rs:866-893`) — the Rust descriptor and the Kotlin -`NativePersistenceBridge` signature must change in the same commit or -every identity persist fails at runtime. - -### Milestone K1 — Persistence completion + the read surface it can prove - -**Entry gate:** one runtime revalidation of the existing 17-export -pipeline — the FriendsScreen send→accept→ignore flow against testnet -(`-Ptestnet=true`), or its instrumented equivalent — before any new -bridging. Also the 15-minute PARITY.md interim fix: drop the stale -`FriendsView.swift` row claims, mark the DashPay section -"in migration — see KOTLIN_MIGRATION_SPEC.md". - -**Bridge (6 exports):** - -| Group | Functions | -|---|---| -| Payments | `managed_identity_get_dashpay_payments` | -| Profile reads | `platform_wallet_get_contact_profile`, `managed_identity_get_dashpay_profile`, `managed_identity_get_dashpay_sync_state` | -| DPNS search | `platform_wallet_search_dpns_names` (wallet-scoped; the existing `QueriesNative.dpnsSearch` wraps the *SDK-scoped* `dash_sdk_dpns_search` — a different call path; AddContactScreen must use the wallet-scoped one for parity) | -| Account balances | `platform_wallet_manager_get_account_balances` (DashPayTabView's per-account balance display; not DashPay-prefixed, easy to miss) | - -**Persistence** (mirrors `PersistentDashpayContactProfile.swift` / -`PersistentDashpayPayment.swift`): - -- New Room entities `DashpayContactProfileEntity` - (`(networkRaw, ownerIdentityId, contactIdentityId)` unique, `checkedAtMs` - backoff) and `DashpayPaymentEntity` - (`(networkRaw, ownerIdentityId, txid)` unique) + DAOs + Flows; - `DashDatabase` v2→v3 additive migration, exported schema `3.json`. -- **Persist direction — contact profiles:** extend identity-entry - marshaling to carry `IdentityEntryFFI.contact_profiles` (slot exists, - currently skipped at `persistence.rs:825-895`). **Tombstone semantics - are load-bearing:** the projection emits `is_present == false` rows that - mean DELETE the persisted row (`identity_persistence.rs:600-608`) — an - upsert-only implementation compiles, passes upsert tests, and leaves - stale contact names/avatars forever. Required: `is_present=false` → DAO - delete (with test) + `checkedAtMs` round-trip fidelity test. (Note: the - outer doc comment at `identity_persistence.rs:146` contradicts the - projection code and should be corrected in passing.) -- **Persist direction — payments:** pull-based, mirroring iOS exactly. - **Invariant:** payment rows reach Room *only* via the - `refreshDashPayPayments` equivalent (FFI read → Room upsert); - `dashpay_sync_now` reconciles payments **in-memory without persisting** - (identity persist skips payments, `identity_persistence.rs:37-39`). - Android process death is aggressive, so K3's send flow must call - refresh-after-send, and the K1 test suite pins the invariant. -- **Restore direction:** populate the null-stubbed `contact_profiles` / - `payments` arrays in `rs-unified-sdk-jni/src/persistence.rs` - (staging comment :1476-1480, stubs :1848-1853, free-path :2049-2051), - mirroring the Swift restore blocks - (`PlatformWalletPersistenceHandler.swift` ~4918, ~4978). - -**Gate (re-tiered after review):** the persist→wipe→restore→re-read -round-trip runs as an **instrumented test** (extend -`sdk/src/androidTest/.../WalletManagerRoundTripTest.kt`, which already -drives the real native lib on the CI emulator) — payload includes payments -with memo/direction/status, a contact-profile `is_present=false` tombstone, -and ignored senders; the new K1 getters read back restore-injected fixtures -and assert field equality. JVM/Robolectric tests cover handler↔Room mapping -only (they never load the native lib, so they cannot see marshaling bugs — -this was the original spec's top-listed risk guarded by the wrong tier). - -### Milestone K2 — Sync service, seedless unlock, writes - -**Pre-req (security must-fix): mnemonic-handling discipline.** The -existing Kotlin resolver materializes the mnemonic as an immutable JVM -`String` (`WalletStorage.retrieveMnemonic` does `decodeToString()` and -scrubs only the ByteArray; `MnemonicResolverAndPersister.kt:36-42` returns -the String to native). iOS never creates a string-shaped copy: it keeps -XOR-masked UTF-8 bytes (`MaskedMnemonicUTF8`) and writes into Rust's -out-buffer, scrubbing on every access. K2 multiplies resolver calls (the -drain runs it once per queued entry), so **before** wiring the automatic -drain: port the out-buffer + masked-bytes discipline to `WalletStorage.kt` -/ `MnemonicResolverAndPersister.kt` / `mnemonic.rs` (whose "same residual -exposure as iOS" comment is false and must be corrected), and zeroize the -`mnemonic_str`/`mnemonic_c` copies in `signer.rs:342-422` (currently only -`sig_buf` is scrubbed). - -**Bridge (13 exports):** the 7 `platform_wallet_manager_dashpay_sync_*` -fns; the seedless trio `platform_wallet_verify_seed_binds_to_wallet`, -`platform_wallet_pending_contact_crypto_count`, -`platform_wallet_drain_pending_contact_crypto` (takes **both** a -`SignerHandle` and a `MnemonicResolverHandle` — -`rs-platform-wallet-ffi/src/dashpay.rs:757-761`); -`platform_wallet_create_or_update_dashpay_profile_with_signer`, -`platform_wallet_set_dashpay_contact_info_with_signer`; -`dash_sdk_resolver_supports_key_type` (rs-sdk-ffi; consumed by the -production signer — Swift `KeychainSigner.swift`). - -**`DashpaySyncService`** (`sdk/services/`, mirroring -`PlatformWalletManagerDashPaySync.swift`): - -- Owned by the `PlatformWalletManager` instance; started when platform - wallets are present (after load / on the rebind path), stopped and - disposed on the `WalletManagerStore` manager swap. **Not gated on - process lifecycle** — iOS keeps the sweep running while backgrounded - (the OS suspends the process; Android freezes/kills similarly under - modern app-standby), and this matches the manager-owned ownership - doctrine. (The v1 spec's "mirrors scenePhase" claim was wrong — Swift - drives start/stop off wallet-presence/rebind `.onChange`, not - scenePhase. No `lifecycle-process` dependency needed.) -- `isSyncing` / `lastSync` / `pendingAccountBuilds` exposed as `StateFlow`, - updated by a **1 Hz poll with change-gated assignment and stale-key - pruning** — pinned now, not "verify later": that is exactly how iOS does - it (`PlatformWalletManager.swift:1140-1186`; the comment at :1133-1139 - documents why naive re-assignment burned CPU). Natural home: the - `SpvProgressPublisher` pattern. - -**Seedless unlock — invocation topology (domain must-fix; the API method -alone is not the feature):** - -- `unlockWalletFromKeystore(walletId)` = scoped verify → drain, and is - called **automatically, per restored wallet, inside - `loadPersistedWallets`**, best-effort/never-throwing (mirrors - `PlatformWalletManager.swift:477-506`; Kotlin's - `PlatformWalletManager.kt:457` currently documents the absence). This is - load-bearing: the deferred contact-crypto queue is **in-memory by - design** (no persisted table on either platform — do not "fix" that) and - recovery is self-healing only if every launch runs - load → unlock (verify+drain) → sweep. Banner-triggered-only unlock would - leave contacts never finishing establishment after process restart. -- **Seed-mismatch contract:** Rust `SeedMismatch` surfaces as - `ErrorInvalidParameter` (`rs-platform-wallet-ffi/src/dashpay.rs:960-965`); - like Swift, Kotlin disambiguates *only* by scoping the catch to the - verify call (the JNI error code arrives as - `code + PWFFI_CODE_OFFSET`). Publish per-wallet - `draining` / `seedMismatch` / `pendingAccountBuilds` as `StateFlow` - (Swift: `dashPayUnlockStatus`). -- **Re-entrancy guard:** a second unlock while `draining == true` returns - immediately (Swift :622-624) — load-time auto-drain and a user banner - tap must not double-run the ECDH work. -- **Biometric interaction (Android-only failure mode):** Kotlin's - identity-key Keystore alias is auth-gated (30 s validity + - `BiometricGate`) — *stricter than iOS*, whose identity keys are not - auth-gated at all. The reciprocal-accept signing inside a background - drain can therefore throw on an expired auth window with no Activity to - re-prompt. Required behavior: catch, leave the entry queued (the sweep - self-heals), reflect it in the unlock status; instrumented test for the - auth-expired path. -- **Breadcrumb backfill — explicitly not ported.** Swift's unlock also - schedules `scheduleBackfillIdentityKeyBreadcrumbs`, an iOS-legacy - Keychain healing step for pre-breadcrumb installs. The Kotlin SDK is new - — every identity key it has ever created is breadcrumbed at creation — - so there is nothing to heal. Recorded here so its absence is a decision, - not an oversight. - -**Gate:** unit tests + instrumented sync-service lifecycle tests -(start/stop/isRunning; manager-swap disposal; double-start), unlock state -machine with a wrong-seed fixture (seedMismatch path) and the -auth-expired-drain path; write paths against testnet behind -`-Ptestnet=true`. - -### Milestone K3 — DashPay tab UI + parity bookkeeping - -**Bridge (2 exports):** `platform_wallet_build_auto_accept_qr`, -`platform_wallet_send_contact_request_from_qr` (first consumed here). - -Navigation restructure (mirrors Swift `ContentView.swift`): - -- `RootTab`: `SYNC, WALLETS, IDENTITIES, DASHPAY, SETTINGS` — **Contracts - tab is demoted into Settings** (`ContractsHome` becomes an entry in the - Settings screen's Platform section, as on iOS). -- Retire `FriendsScreen` + its route; entry points repoint at the DashPay - tab/contact flows. -- Reset the DashPay tab's identity-picker selection on network switch — - retained Compose state would otherwise query a wallet absent on the new - network-locked manager. - -Port the 10 Swift views (Compose screens under `ui/dashpay/`, one file per -Swift file, testTags = iOS accessibility identifiers, screens driven by -Room Flows / snapshot data classes — never by retained native handles): - -| Swift (`Views/DashPay/`) | Compose target | Notes | -|---|---|---| -| DashPayTabView (909) | `DashPayTabScreen` | identity picker, per-account balances, pull-to-refresh → `syncNow`, unlock banner (reads unlock-status Flow), sub-sheet navigation | -| ContactsView (299) | `ContactsScreen` | Room Flow over established contacts (both-direction join) | -| ContactRequestsView (391) | `ContactRequestsScreen` | incoming accept/ignore + outgoing pending | -| AddContactView (497) | `AddContactScreen` | wallet-scoped DPNS prefix search (300 ms debounce), raw id, QR entry | -| ContactDetailView (561) | `ContactDetailScreen` | payment history (refresh→Room), alias/note editors **surfacing the `ContactInfoPublishOutcome`** — `DeferredUntilTwoContacts` means local-only until ≥2 established contacts and the UI must say so (parity), `SkippedWatchOnly` likewise; hide; send-payment entry | -| SendDashPayPaymentSheet (386) | `SendDashPayPaymentSheet` | amount/memo → `sendDashPayPayment` → txid, then **refresh-after-send** (payments-durability invariant, §K1) | -| DashPayProfileView (188) | `DashPayProfileScreen` | own profile display/edit + auto-accept QR render (ZXing — already a dependency) | -| IgnoredContactsView (181) | `IgnoredContactsScreen` | unignore | -| HiddenContactsView (240) | `HiddenContactsScreen` | unhide | -| DashPayContactMeta (183) | `DashPayContactMeta.kt` | meta store (UserDefaults → SharedPreferences/DataStore; plaintext is parity — iOS documents UserDefaults as "the honest backing" for device-local data), display-name precedence, avatar composable | - -QR scan reuses the already-ported `QrScannerScreen`; the auto-accept -scan-to-send path calls `sendContactRequestFromQR`. - -**New dependency:** Coil (`coil-compose`) for avatar loading — not -currently in `libs.versions.toml`; flagged here because it is the plan's -only new third-party dependency. (ZXing is already present.) - -Parity bookkeeping: full `PARITY.md` DashPay rewrite — a `Views/DashPay/` -section with one row per view, corrected totals (interim stale-claims fix -already landed in K1). - -**Gate:** `:app:assembleDebug` + Compose UI tests mirroring -`DashPayTabUITests.swift`; manual UAT next to the iOS simulator per -`QA_TESTCASES_SPEC.md` flows, including the end-to-end -send→accept→pay testnet run. - -## 5. Interfaces & data flow (summary) - -``` -Compose UI ── StateFlow/Room Flow ── PlatformWalletManager / Dashpay.kt - │ │ (thin, marshal-only) - │ DashpayNative.kt (external fun) - │ │ JNI - ▼ rs-unified-sdk-jni/src/dashpay.rs - Room (Dashpay* entities) │ rlib call - ▲ rs-platform-wallet-ffi (C ABI) - │ persistence callbacks │ - └── NativePersistenceBridge ◄── rs-platform-wallet (all logic) -``` - -- Writes require two callback handles: identity signer (`signer.rs`) and - mnemonic resolver (`mnemonic.rs`) — both exist; DashPay adds no new - callback *types*, only new call sites. Handles are kept strongly - referenced for the duration of each call (GC hazard). -- Contact profiles ride the identity persist/restore callback path - (with tombstone-delete semantics); payments are pull-persisted and - array-restored. The deferred contact-crypto queue is deliberately - not persisted (in-memory + sweep self-heal, both platforms). -- Cold-start contract: `loadPersistedWallets` → per-wallet best-effort - unlock (verify → drain) → sync service start → recurring sweep. -- Threading: FFI calls on `Dispatchers.IO`; Rust→Kotlin callbacks attach - as JVM daemon threads; persistence-handler read-modify-writes run in - Room transactions (callback threads race UI-triggered refreshes - otherwise). - -## 6. Failure modes / risks - -- **Restore-path marshaling bugs** corrupt Rust wallet state on load. - Guard: the K1 instrumented round-trip (real native lib) — JVM tests - cannot see this code. -- **Contact-profile staleness:** upsert-only persist misses tombstones → - stale names/avatars forever. Guard: `is_present=false` delete test (K1). -- **Payment loss on process death:** `syncNow` does not persist payments. - Guard: refresh-after-send + kill/relaunch test (K1/K3). -- **Never-draining wallets:** unlock not wired into load → contacts stuck - pending after every restart. Guard: unlock topology spec (§K2) + - restore→unlock instrumented test. -- **Background drain vs biometric gate:** auth-expired signing during - drain must requeue, not fail silently. Guard: auth-expired test (K2). -- **GC vs callback lifetime** during long drains: strong refs on - signer/resolver bridges per call site; drain stress test. -- **Sync-service leak across network switch:** manager-owned lifecycle, - disposed on `WalletManagerStore` swap; double-start test. UI-side: - identity-picker reset on network change. -- **JNI descriptor lockstep** on the identity persist callback (§4 note): - Rust descriptor + Kotlin signature in one commit. -- **Room migration:** additive-only v3; migration test against `2.json`. -- **Build env:** exFAT gotcha (`build_android.sh` sparse image), NDK r28+, - 16 KB alignment — K1 ends with `./build_android.sh --verify` passing. - -## 7. Test plan - -1. **Instrumented (`connectedDebugAndroidTest`, CI emulator)** — the - load-bearing tier: extend `WalletManagerRoundTripTest` with the DashPay - persist→wipe→restore→re-read round-trip (payments incl. memo/direction/ - status, contact-profile tombstone, ignored senders); K1 getters read - restore-injected fixtures; K2 sync-service lifecycle, unlock - state machine (wrong-seed → seedMismatch; auth-expired drain). -2. **JVM unit (`:sdk:testDebugUnitTest`)** — handler↔Room mapping only - (explicitly *not* the marshaling tier): contact-profile - upsert/delete/backoff, payment row mapping, Room v2→v3 migration. -3. **Compose UI tests** — port `DashPayTabUITests.swift` flows using the - shared testTags (tab presence, add-contact form, requests accept path - with a fake bridge). -4. **Testnet opt-in (`-Ptestnet=true`)** — K1 entry gate: existing - FriendsScreen send→accept→ignore revalidation. K3 exit: end-to-end - send→accept→pay between two fixture identities, mirroring iOS UAT. -5. **CI** — existing `kotlin-sdk-build.yml` runs tiers 1–3. - -## 8. Out of scope - -- Invitations (SPEC.md Milestone 5) — not implemented on iOS either. -- The 24 unconsumed FFI functions listed in §2 (and any new handle-wrapper - types for them). -- `managed_identity_get_contested_dpns_names` — its consumers - (SelectMainName / WalletMemoryExplorer / IdentityDetail) are outside - `Views/DashPay/`; it belongs to the existing non-DashPay PARITY-partial - bucket, not this migration. -- Identity-key breadcrumb backfill (justified in §K2 — no pre-breadcrumb - Android installs can exist). -- Migrating the 17 pre-existing DashPay JNI exports out of `tokens.rs`. -- The 5 non-DashPay `TransitionDetailView` FFI gaps and other PARITY - "partial" items. -- Any change to Rust crates other than `rs-unified-sdk-jni` (a genuine - composite gap, if found, becomes its own reviewed `rs-platform-wallet-ffi` - change). - -## 9. Decisions taken in this spec (previously open) - -- **One PR per milestone** — the milestones carry independent gates by - design; review units should match. -- **Sync lifecycle: manager-owned, not process-lifecycle-gated** (§K2). -- **Poll (1 Hz, change-gated), not events, for sync/unlock status** (§K2). -- **Coil added** as the single new dependency (§K3). - -## 10. Open questions (for Ivan) - -1. **Branch/PR strategy:** land the K-milestones as stacked PRs on top of - `feat/kotlin-sdk-and-example-app` (PR #3999 is already ~50k insertions), - or fold into #3999? Recommendation: **stacked PRs**. -2. **Tab restructure confirmation:** mirroring Swift means demoting the - Contracts tab into Settings on Android too. Confirm parity wins over - Android-specific navigation taste. diff --git a/docs/dashpay/MULTI_ACCOUNT_SPEC.md b/docs/dashpay/MULTI_ACCOUNT_SPEC.md deleted file mode 100644 index dfdf657a79e..00000000000 --- a/docs/dashpay/MULTI_ACCOUNT_SPEC.md +++ /dev/null @@ -1,330 +0,0 @@ -# DashPay simultaneous multi-account contacts — implementation spec - -> **Problem.** DIP-15 lets a contact expose **multiple DashPay accounts** at once — -> each a separate `contactRequest` with a distinct `accountReference` (DIP-15 §8.4, -> §8.9, §10.8). Our contact-state layer collapses everything to **one channel per -> counterparty** (`BTreeMap`, a single-channel `EstablishedContact`), -> and the rotation machinery actively *supersedes* a contact's prior request rather -> than letting accounts coexist. So we can neither represent nor pay across a -> contact's multiple accounts, we always send our own account `0`, and we drop -> `contactInfo.acceptedAccounts` on ingest. -> -> **Status.** REVIEWED (4 lenses, 2026-06-24) — **KEEP DEFERRED** (see *Review -> outcome* below: a foundational blocker B-1 + reopened DoS + abuse surface, and no -> requirement). This feature was **deliberately -> deferred** by the team as *"conditional, not a requirement"* (backlog -> dashpay/platform#4020 multi-account item; `DIP_CONFORMANCE_GAPS.md` §2). There is **no current product -> requirement** forcing simultaneous multi-account. This spec exists so the work is -> *scoped and reviewed* and can be implemented when a requirement appears — and so -> the decision to keep deferring is an informed one. **Do not implement before this -> spec is reviewed and a requirement exists.** -> -> **Source.** Scope map from the 2026-06-24 blast-radius audit (all file:line below -> verified against `feat/dashpay-m1-sync-correctness`, pinned rust-dashcore `b4779fc`). - ---- - -## Review outcome (2026-06-24, 4 lenses) — **KEEP DEFERRED** - -A four-lens review (DIP-15 domain-fit, state-machine feasibility, scope/go-no-go, -security/abuse), each grounded against the code, converged: **the spec is an -accurate scope map, but the feature must NOT be built as designed, and there is no -product requirement driving it. Keep it deferred.** The reviews also corrected -several claims in §0–§4 below (annotated inline as ⚠**REV**). If a requirement ever -appears, **the first deliverable is a focused "channel identity under an opaque -`accountReference`" design note (resolving B-1) — not code.** - -### Blocking findings (must be resolved in a revision before any code) - -- **B-1 — Channel identity is unsolvable from the wire (the foundational blocker).** - The design keys channels by the raw `accountReference`, but DIP-15's - `accountReference = (version<<28) | (ASK28 ^ account)` is a sender-private one-time - pad: the version nibble is cleartext, but `ASK28 = HMAC(sender_secret, compact_xpub)` - is uninvertible by the recipient, **and a rotation ships a new xpub**, so the - low-28-bit value is *uncorrelated* across a rotation. Result: "rotation of added - channel B" is **information-theoretically indistinguishable** from "a brand-new - account." So channels cannot be keyed by `accountReference` and still collapse - rotations. Channel identity must be **out-of-band** (user-assigned at accept time; - every later rotation re-prompts "which channel does this replace?"). This is - permanent UX, not a TODO — and it gates B-2/B-3 below. (`account_reference.rs:41-66`.) -- **B-2 — Keying the collapse by `accountReference` re-opens the PR #3841 sweep - thrash.** Immutable on-chain docs never disappear; a rotated sender leaves both - old+new docs returning every sweep. `newest_received_per_sender` collapses - per-sender *because rotation mutates the reference*; keying the collapse by the - reference produces two survivors that flip-flop the stored channel forever — the - exact regression #3841 fixed. A fixpoint exists only with a rotation-stable key → - loops back to B-1. (`contact_requests.rs:811-829,1087-1117,3103-3105`.) -- **B-3 — The "local channel index" corrupts the receiving derivation path.** - `DashpayReceivingFunds.index` is a **hardened path component** - (`account_type.rs:489`), not just a map key — it selects the BIP32 path the - counterparty derives against. A fabricated local index desyncs our *advertised* - receiving addresses from our *watched* ones → incoming payments to that channel - become invisible. The receiving index must be our **real** DashPay account number - (the one masked into the published `accountReference`); only the *external* account - may use a namespace. The send-side real-account thread (§2.3) is the only correct - mechanism. (`key-wallet account_type.rs:472-526`.) -- **B-4 — `BTreeMap` silently overwrites on collision.** Keying - by a non-unique 28-bit value means two channels masking equal silently shadow each - other (fund misdirection). The on-chain unique index `($ownerId, toUserId, - accountReference)` (`dashpay.schema.json:148-163`) bounds this **per-sender** (a - sender can't broadcast two colliding docs), but the spec must *state and rely on* - that invariant and **reject-on-collision** (insert returning `Some` = loud error), - never overwrite. -- **B-5 — No per-sender flood cap; the re-key converts a flood into pending-queue - exhaustion.** Today `incoming_contact_requests` is one slot per sender + collapse → - a flood is structurally absorbed. The re-key to `(counterparty, accountReference)` - makes each new reference a pending triage prompt (a permanent doc returning every - sweep). Needs a `MAX_PENDING_ADDITIONAL_ACCOUNTS_PER_SENDER` (mirror - `MAX_AUTO_ACCEPT_QUEUED_PER_OWNER`, but per-(owner,sender)); over-cap → **silently - drop, not enqueue**; wire the gate to the existing `ignored_senders` block. -- **B-6 — "Add account" is a phishing / confused-deputy surface.** A *malicious - established contact* can send an add-request whose xpub points at an - attacker-controlled address space (the crypto binds the channel to the contact's - identity, not to the contact being honest). "Add account" must carry the same - trust gravity as accepting a brand-new contact (surface the derived first address; - no one-tap inline accept). The spec frames the gate as anti-flood only and omits - payment redirection. - -### Corrections to the body (factual) -- ⚠**REV §2.2 / Open Q2:** the version nibble is readable but does **not** correlate a - rotation to a specific channel (B-1). Don't claim "consult the version nibble" - resolves rotation-vs-new — it doesn't. -- ⚠**REV §2.1:** strike the "local channel index" for the receiving account (B-3). -- ⚠**REV §2.2:** `acceptedAccounts` per DIP-15 §10.4 stores **only non-version-0** - references — never write channel-0 into it. -- ⚠**REV §4.4:** same-sender collisions are *blocked on-chain* by the unique index; - state this invariant (it's the saving grace) and reject-on-collision (B-4). -- ⚠**REV §4.3:** migration is essentially free — there is **no in-repo SQLite schema**, - `DashMigrationPlan.stages == []` (dev stores recreate from scratch), and contacts - rebuild from chain (metadata rides `contactInfo`). The spec over-worries; the real - plan is "let the store rebuild," with a "wipe local → re-sync reconstructs" test. -- ⚠**REV §3:** T2 (`accepted_accounts` round-trip) is **not** independently valuable — - it writes a field nothing reads (inert). Fold it into T1; do **not** ship standalone. - T1 itself must split into ≥4 PRs (struct re-key / collapse-inversion / user-gate / - account-index thread), each with its own #3841-style fixpoint test. -- **Conformant lower-cost fallback (R1):** DIP-15 §8.4 allows *"either disregard all - future contact requests ... or preferably ask the user."* Silently disregarding - additional requests (≈ today's collapse) is **also conformant** and avoids the - entire B-2/B-3/B-5/B-6 surface — the cheapest path if multi-account is ever wanted - only nominally. - -### Verdict & recommendation -**KEEP DEFERRED.** Upstream (#813) is unblocked, but the feature has a foundational -information-theoretic blocker (B-1), re-opens a fixed DoS (B-2), and adds real abuse -surface (B-4/B-5/B-6) — for **no current requirement**. The review *prevented building -the wrong thing*, which is the point of the pipeline. **Next step only if a -requirement appears:** a B-1 channel-identity design note, then re-spec around it. - ---- - -## 0. What "multi-account" means here (and what it does NOT) - -Two distinct things share the "different `accountReference` from a known sender" -shape and must not be conflated: - -- **Rotation (LIVE today):** the sender rotated the payment xpub for the *same* - logical account; the new request **supersedes** the old (DIP-15 §8.10 immutability - → rotate via a new request). `apply_rotated_incoming_request` - (`state/managed_identity/contact_requests.rs:337-407`) replaces `incoming_request` - in place, tears down the stale external account, rebuilds from the new xpub. The - sync sweep's `newest_received_per_sender` (`network/contact_requests.rs:811-829`) - **discards all-but-newest per sender** — the comment (`:752-765`) calls this "the - idempotency keystone." -- **Simultaneous multi-account (THIS spec):** the sender exposes *additional* live - accounts that must **coexist** as separate channels (DIP-15 §8.4 "Recipients either - ignore subsequent requests or prompt users to select destination accounts"; - §10.8 "additional contact requests require user acceptance; upon approval the new - account reference joins `acceptedAccounts`"). - -These are **antithetical** — rotation's whole purpose is to *prevent* two live -channels per sender. Multi-account must *invert* that for **accepted** additional -accounts while keeping supersede for genuine rotations. The disambiguation is the -crux of this spec (§2.2). - -**Out of scope:** the §10.8 *query-level* flood mitigation ("only the first request -to the bloom filter; filter blocked senders server-side") needs a registered -`dashpay` contract change and stays blocked (Contract track). This spec covers the -**client-side** multi-account model only. - ---- - -## 1. Research — current state (verified) - -### 1.1 What's already multi-account-ready -- **Upstream derivation (#813, merged, in `b4779fc`):** `AccountType::derivation_path()` - for `DashpayReceivingFunds`/`DashpayExternalAccount` uses - `ChildNumber::from_hardened_idx(*account_index)` (`key-wallet/.../account_type.rs:472-531`) - — the friendship path honors a non-zero account. -- **Account collections** are keyed by `DashpayAccountKey { index, user_identity_id, - friend_identity_id }` (`key-wallet/.../account_collection.rs:25-29`) — the - account/UTXO layer already supports multiple accounts per (user, friend). -- **Provider/register signatures already take an account index:** - `receiving_xpub_for(…, account_index, …)`, `account_reference(…, account_index, - version)`, `register_contact_account(…, account_index, …)` (`network/contacts.rs:140`), - `register_external_contact_account` derives `DashpayAccountKey { index }`. - The `accountReference` masking already folds `account_index` into the low 28 bits - correctly (`network/contact_requests.rs:514-551`). - -### 1.2 The bottleneck — contact state collapses to `Identifier` -`ManagedIdentity` (`state/managed_identity/mod.rs:62-85`): -- `established_contacts: BTreeMap` -- `sent_contact_requests: BTreeMap` -- `incoming_contact_requests: BTreeMap` - -`EstablishedContact` (`types/dashpay/established_contact.rs:14-51`) holds **exactly -one** `outgoing_request` + **one** `incoming_request`. It carries a dead -`accepted_accounts: Vec` (`:34`) + `add/remove_accepted_account` (`:138-146`) -with **zero production callers**. - -### 1.3 Hardcoded account `0` on send/build (≈6 sites) -`network/contact_requests.rs:476` (`let account_index: u32 = 0;`), `contacts.rs:397`, -the build sweep `DashpayAccountKey { index: 0 }` (`contact_requests.rs:1398`), the -register-receiving builds (`:1614`, accept `:2259`). - -### 1.4 `accepted_accounts` is lossy -- Codec round-trips it (`crypto/contact_info.rs:133,238-281`, test `:346-366`). ✅ -- Publish hardcodes empty (`network/contact_info.rs:499-506`, "isn't populated yet"). -- `set_contact_metadata` (`state/managed_identity/contact_requests.rs:279-313`) - copies only `alias/note/display_hidden` — **drops `metadata.accepted_accounts`**. -- Not marshalled to FFI/Swift anywhere. - -### 1.5 The recipient-ignores-`accountReference` asymmetry -DIP-15 makes `accountReference` a sender-private one-time pad the recipient **cannot -reliably un-mask** (the 4-way convention split; `DIP_CONFORMANCE_GAPS.md` §3). So the -recipient **cannot** recover the sender's real account number from the wire. It can -only treat the **raw `accountReference` u32** as an opaque channel discriminator, and -derive the actual addresses from the **decrypted xpub** (which is account-correct). -→ multi-account channels must be keyed by the **raw `accountReference`**, not an -unmasked account number. - ---- - -## 2. Chosen approach - -### 2.1 Re-key contact state by `(counterparty, accountReference)` -Replace the single-channel model with a per-contact set of channels keyed by the raw -`accountReference`: -- `EstablishedContact` becomes multi-channel: a `BTreeMap` where `ContactChannel` holds the `{outgoing_request, - incoming_request, payment_channel_broken}` that are today flat on - `EstablishedContact`. Metadata (`alias`, `note`, `is_hidden`, `accepted_accounts`) - stays **per-contact** (one alias for the person, not per channel). -- `incoming_contact_requests` / `sent_contact_requests` re-key to - `(counterparty, accountReference)`. -- Account registration already keys by `DashpayAccountKey { index }`; the channel's - account index comes from the **decrypted-xpub-derived** account, but since we can't - unmask, we allocate a **local channel index** per accepted accountReference and use - it as the `DashpayAccountKey.index` (the xpub is account-correct regardless; the - index only namespaces our local account collection). - -### 2.2 Disambiguate rotation (supersede) vs new account (coexist) — by USER GATE -We cannot tell from the wire whether a new `accountReference` is a rotation or a new -account (§1.5). DIP-15 §8.4/§10.8 resolves this with a **user gate**: -- The **first** request from a sender → auto-established (channel 0), as today. -- A **subsequent** request with a new `accountReference` from an established contact → - surfaced as a **pending additional-account request**, NOT auto-applied. The current - auto-`apply_rotated_incoming_request` supersede is **replaced** by: enqueue as - pending; the user chooses **"replace addresses" (rotation)** or **"add account" - (coexist)**. - - "Replace" → supersede (today's behavior, the channel's request is swapped). - - "Add" → the `accountReference` joins `accepted_accounts`, a new coexisting channel - is built, and the receival/external accounts are registered under a fresh local - index. -- `accepted_accounts` is the **persistent record of which additional references the - user accepted** — so the gate is sticky across sweeps/restarts (an accepted ref is - never re-prompted; an un-accepted one is dropped per §10.8, not bloom-filtered). - -This **inverts the idempotency keystone** (`newest_received_per_sender` collapse) for -accepted references: the sweep must keep every *accepted* `accountReference`'s newest -doc, and collapse only *within* an accountReference (rotation of that channel). That -is the load-bearing, highest-risk change (§4.1). - -### 2.3 Send side — thread a real account (gated behind a UI affordance) -Thread an `account: u32` param from the send FFI through the ≈6 hardcoded sites. The -example app gains an optional "send from account N" affordance; default stays `0`. -**Not a standalone change** — only meaningful once §2.1 state can hold the result. - -### Alternatives rejected -| Approach | Why rejected | -|---|---| -| Unmask `accountReference` to recover the account number, key by that | Recipient can't reliably un-mask (4-way convention split, §1.5). | -| Auto-accept every new `accountReference` as a new account | Violates §10.8 flood mitigation; an attacker floods accounts. | -| Keep single-channel, just stop dropping `accepted_accounts` (Slice A) | Inert today (nothing produces a non-empty value); preserves a field nothing writes — YAGNI. | -| Reuse rotation as the foundation | Rotation *prevents* coexistence by design (§0); it's scaffolding to bypass, not build on. | - ---- - -## 3. Layered change map (task split) - -| Layer | Change | Rough size | -|---|---|---| -| **T1 — Rust contact state** | multi-channel `EstablishedContact`; re-key the 3 maps to `(counterparty, accountRef)`; per-contact metadata; invert the sweep collapse to per-accountRef; user-gate additional accounts; populate `accepted_accounts` | large, the core | -| **T2 — `accepted_accounts` round-trip** | `set_contact_metadata` copies it; publish reads it; (independently shippable as the data-layer floor of T1) | ~15-30 LOC | -| **T3 — Changeset/persistence** | accountRef in `SentContactRequestKey`/`ReceivedContactRequestKey` + `established` map key; carry `accepted_accounts` | medium | -| **T4 — FFI** | `account_index`/`accepted_accounts` on `ContactRequestFFI` + persist callbacks; +1 send param; pending-additional-account surface | medium | -| **T5 — Swift/SwiftData** | accountRef in `PersistentDashpayContactRequest` unique key; per-account grouping in ContactsView/ContactRequestsView/ContactDetailView/AddContactView/SendDashPayPaymentSheet; "add account vs replace" prompt; send-from-account picker | large, UI-heavy | -| **T6 — Tests** | unit (re-key, coexist, user-gate, accepted_accounts round-trip, rotation-still-supersedes-within-a-channel); `dp_*` e2e multi-account send/receive (devnet) | medium | - -The persistence (T3) + Swift (T5) layers need a **migration** for existing -single-channel rows (map the lone channel to `accountReference` of its stored -request). - ---- - -## 4. Failure modes & risks (for reviewers to stress) - -1. **Inverting the idempotency keystone (T1, highest risk).** `newest_received_per_sender` - collapse and `apply_rotated_incoming_request` supersede are the mechanism that keeps - the recurring sweep from thrashing. Splitting "collapse per sender" into "collapse - per (sender, accountRef), keep all accepted refs" must not reintroduce the - multi-doc sweep thrash that PR #3841 fixed (the `newest_received_per_sender` - comment at `:752-765`). Needs the same red→green pinning as the original fix. -2. **Rotation vs add ambiguity.** If the user picks "replace" we must supersede the - *right* channel; if "add" we must not later mistake the rotation of an added channel - for yet another new account. Channels keyed by raw `accountReference` make a - *rotation within a channel* indistinguishable from a *new account* unless the - version nibble is consulted — but the recipient ignores `accountReference`. Resolve: - does "rotation of an added account" even occur, and how is it keyed? -3. **Migration.** Existing persisted single-channel contacts (SQLite + SwiftData) must - map to the new keyed shape without losing alias/note/hidden/broken state or - double-counting payments. -4. **The `accountReference == 0` collision.** Today everything is accountRef `0`-ish; - re-keying must handle the legacy `0` channel and a genuinely-new `0`-masked account - (collisions are possible — `accountReference` uniqueness isn't guaranteed, DIP-15 §7). -5. **UI blow-up.** A contact rendering as N rows vs one row with N accounts; the send - sheet picking an account; the "add vs replace" prompt. Scope creep risk. -6. **No requirement = speculative surface.** Building this without a driving use case - risks shipping inert complexity (Rule 2). The spec must end with a go/no-go. - ---- - -## 5. Verification plan - -- **T2 (unit):** `set_contact_metadata` preserves `accepted_accounts`; publish emits the - contact's accepted set; round-trip through the codec. (TDD red→green.) -- **T1 (unit):** an established contact accepts a second `accountReference` → two live - channels; a rotation of channel 0 supersedes channel 0 only; the sweep does not thrash - across two recurring passes (mirror the PR #3841 idempotency pin); an un-accepted - additional request stays pending and is not watched. -- **Migration (unit):** a persisted single-channel contact loads as a one-channel - multi-account contact with metadata intact. -- **Integration (`dp_*` e2e, devnet-gated):** send from a non-zero account; receive + - accept a contact's second account; pay across both. - ---- - -## 6. Open questions for review (resolve before any coding) - -1. **Go/no-go:** is there an actual requirement for simultaneous multi-account, or does - this stay deferred? (The spec's existence shouldn't force the build.) -2. **Rotation-within-an-added-channel (§4.2):** does it occur in practice, and how is a - channel keyed if not by raw `accountReference`? (Possibly `(accountReference & - 0x0FFFFFFF)` ignoring the version nibble — but the recipient can't unmask… revisit.) -3. **Metadata granularity:** confirm `alias/note/is_hidden` are per-contact (per person) - and only `accepted_accounts` + `payment_channel_broken` are per-channel. -4. **UI model:** one contact row with N accounts (recommended) vs N rows. Send sheet - default account. -5. **Could T2 (accepted_accounts non-lossy) ship now** as a tiny data-preservation fix - ahead of the rest, or does shipping an inert field invite confusion? (Lean: ship with - T1, not standalone.) -6. **Migration safety** for existing devnet/testnet contacts. diff --git a/docs/dashpay/PENDING_CONTACT_CRYPTO_RELOCATION_SPEC.md b/docs/dashpay/PENDING_CONTACT_CRYPTO_RELOCATION_SPEC.md deleted file mode 100644 index 4ff5929ce53..00000000000 --- a/docs/dashpay/PENDING_CONTACT_CRYPTO_RELOCATION_SPEC.md +++ /dev/null @@ -1,188 +0,0 @@ -# Relocate the deferred-crypto queue from the wallet to the identity - -**Status:** implemented (historical reviewed spec). The in-memory relocation is -complete; durable cold-load restoration remains deferred as described in §7. -**Scope:** `packages/rs-platform-wallet` (+ a one-line doc-comment in `rs-platform-wallet-storage`). -Rust-only. FFI signatures, Swift, and whole-struct serialization are **unchanged**. - -## 1. Problem - -`pending_contact_crypto: Vec` lives on the **wallet-level** struct -`PlatformWalletInfo` (`wallet/platform_wallet.rs:57`), a sibling of `identity_manager`. Every -*other* DashPay artifact already lives **per-identity** on `ManagedIdentity` -(`state/managed_identity/mod.rs`): `established_contacts`, `sent_contact_requests`, -`incoming_contact_requests`, `dashpay_rescan_triggered`, `auto_accept_verify_failed`, -`dashpay_payments`. The queue is the lone exception, and each `PendingContactCrypto` entry carries -`owner_identity_id` — manually re-storing the exact container key that is *implicit* for the -others. Identity-network code (`IdentityWallet`) reaches *up* into wallet state to touch it. - -This is **cleanup, not a bug fix**. The queue is functionally correct where it is; the value is -consistency/maintainability. The risk is a routing regression on a signer-gated DashPay path (a -mis-routed drain → a contact account never gets built → a DashPay payment silently can't resolve -its external account). So it is speced, reviewed, isolated (its own commit/PR), and tested. - -## 2. Current architecture (verified in research + review) - -- **Type** (`changeset/changeset.rs:1075`): `PendingContactCrypto { owner_identity_id, contact_id, - op: PendingContactCryptoOp, enqueued_at_ms }`. Dedup key `PendingContactCryptoKey = - (owner_identity_id, contact_id, kind)`; `upsert_pending_contact_crypto` keeps ≤1 entry per key. -- **Receiver**: methods are on `IdentityWallet` (bound to a `wallet_id`, - NOT one identity); reaches the queue via `wm.get_wallet_info(&self.wallet_id)`. -- **`IdentityManager` has TWO buckets** (`state/manager/mod.rs:69-83`): - `wallet_identities: BTreeMap>` and - `out_of_wallet_identities: BTreeMap`. Today's flat wallet-level queue - is **bucket-agnostic**. This is the crux of the refactor's one real trap — see §3 D4 / §5 R1. -- **Enqueue** — exactly THREE production sites, each already holding `&mut PlatformWalletInfo` and - the owner id: `enqueue_pending_auto_accepts` (`contact_requests.rs:1553`), - `enqueue_deferred_contact_crypto` (`:1734`), `enqueue_contact_info_decrypt` - (`contact_info.rs:385`). Each pairs the in-memory `upsert_pending_contact_crypto(&mut - info.pending_contact_crypto, e)` with a changeset `pending_contact_crypto_added: vec![e]` and a - `persister.store(...)`. (`payments.rs:2757` is a **test**, not a production enqueue.) -- **Drain** (`drain_pending_contact_crypto`, `contact_requests.rs:1781`): read-lock → clone the flat - queue → **drop lock** → async match over the owned snapshot (each arm routes every side-effect by - `entry.owner_identity_id`; the loop body never touches the queue) → write-lock → single - `retain_drained_by_snapshot(&mut info.pending_contact_crypto, &cleared)`. - `drain_auto_accepts` (`:2160`) is the signer-gated sibling for `AutoAccept` ops; its removal block - also marks `auto_accept_verify_failed` per owner. -- **Count** (`pending_contact_crypto_count`, `:1763`): `count_account_build_ops` over the flat queue - (excludes `ContactInfoDecrypt`). Backs the "waiting for unlock" UI banner. -- **Op ownership asymmetry (subtle, load-bearing for R1):** `RegisterReceiving` / - `RegisterExternal` are owned-only (`build_contact_accounts` gates on `identity_index.is_some()`, - `contact_requests.rs:1661`); `ContactInfoDecrypt` is owned-only (`contact_info.rs` iterates only - `wallet_identities`). But `AutoAccept` is **NOT** gated — `enqueue_pending_auto_accepts` runs for - every identity in the sweep's `all_identities()` loop (both buckets), so an `AutoAccept` op can - legitimately land on an **out-of-wallet** identity's queue. -- **Changeset** (`PlatformWalletChangeSet.pending_contact_crypto_{added,cleared}`, - `changeset.rs:1189`): flat top-level Vecs; entries carry the owner. -- **Apply is a no-op for the queue** (`wallet/apply.rs:115`): the in-memory queue is mutated - *directly* at the enqueue/drain sites; the changeset deltas are for persistence, not in-memory replay. -- **The queue IS durably persisted** — via the `rs-platform-wallet-storage` SQLite backend: table - `pending_contact_crypto` keyed `(wallet_id, owner_identity_id, contact_id, kind)` - (`migrations/V001__initial.rs:87`), live writer `apply_pending_contact_crypto` - (`sqlite/schema/pending_contact_crypto.rs:49`) driven from `apply_changeset_to_tx` - (`sqlite/persister.rs:1063`), reader `all_pending_contact_crypto` (`:108`), round-trip test - (`:161`). The **FFI/SwiftData** backend has no callback for it, so on iOS it is not durably - persisted — but that is one backend, not "the field is vestigial." **The changeset fields are - load-bearing; nothing here gets deleted.** Because the SQLite writer keys on - `(wallet_id, owner_identity_id, contact_id, kind)`, moving the *in-memory* field per-identity - changes **zero** SQLite writes (the owner stays on every row — D2/D5). -- **Not restored on cold load** (`manager/load.rs:102-114`): starts `Vec::new()`; the sweep - re-enqueues. A restore path is half-wired but blocked upstream — see R6. -- **FFI** (`ffi/dashpay.rs:733, 798`): `platform_wallet_drain_pending_contact_crypto` / - `_count` take a **wallet** handle and call `wallet.identity().()`. Called from Swift - (`PlatformWalletManager.swift:640,968`). D4 keeps the `IdentityWallet` method signatures → - **no FFI or Swift change**. -- **Accessors** (`state/manager/accessors.rs`): `managed_identity(&Identifier)` / - `managed_identity_mut(&Identifier)` (`:70,75`) already resolve across **both** buckets via - `location_index`. Enumerators `all_identities() -> Vec<&Identity>` and `identity_ids() -> - Vec` exist, but **there is no iterator yielding `&ManagedIdentity`** — one is added - (D3). - -## 3. Design - -Move the in-memory Vec to `ManagedIdentity`, keyed by the owning identity. Keep -persistence/apply/FFI shapes unchanged to bound the blast radius. - -- **D1 — Field placement.** Add `pending_contact_crypto: Vec` to - `ManagedIdentity`; remove it from `PlatformWalletInfo`. Init `Vec::new()` in `ManagedIdentity::new` - + `new_out_of_wallet` (next to `established_contacts`); drop the 5 `PlatformWalletInfo` init sites. -- **D2 — Keep `PendingContactCrypto` unchanged (keep `owner_identity_id`).** It is the drain's - routing key (each op's side-effects derive from it) AND the SQLite key column; keeping it holds the - type, dedup key, changeset, and their tests stable. The in-memory redundancy (owner == container) - is benign. *Dropping it is out of scope* (§7). -- **D3 — Access by identity.** - - Enqueue + per-owner removal: existing `managed_identity_mut(&owner)` (spans both buckets). - - Drain-snapshot + count: **add** `IdentityManager::managed_identities(&self) -> impl Iterator` chaining `out_of_wallet_identities.values()` with - `wallet_identities.values().flat_map(|m| m.values())`. Iterate **both buckets** (R1). -- **D4 — Drain/count: flat snapshot → unchanged async loop → per-owner-grouped removal. Both - buckets. Same signatures, same wallet-wide semantics.** Concretely (do NOT write an outer - per-identity loop — it borrows `&mut ManagedIdentity`/holds the lock across `.await` and will not - compile): - 1. **Snapshot:** under a read guard, gather every resident identity's queue into one flat owned - `Vec` (`managed_identities().flat_map(|m| m.pending_contact_crypto.iter() - .cloned())`), then drop the guard. `count` sums `count_account_build_ops` per identity the same - way. - 2. **Async loop:** unchanged — it already keys every lookup/side-effect off - `entry.owner_identity_id` and touches the queue nowhere. - 3. **Removal:** under a write guard, group `cleared_snapshots` by `owner_identity_id` and, per - owner, `retain_drained_by_snapshot(&mut managed_identity_mut(&owner).pending_contact_crypto, - &subset)`. Fully synchronous under one guard — nothing crosses `.await`. - `retain_drained_by_snapshot`'s value-equality (which includes the owner) transfers unchanged. - For `drain_auto_accepts`, the same per-owner hop also carries the `auto_accept_verify_failed` - mark (already per-owner today). - - *Send-drain scope (Q1 resolved → wallet-wide):* keep `payments.rs:575` - `self.drain_pending_contact_crypto` draining every resident identity, not just the sender. It is - safe and useful — the Keychain provider is wallet-**seed**-scoped, so one identity's send - correctly finishes other identities' pending builds as a free, correct side effect; no - cross-identity dependency exists (accounts are keyed by both ids). Narrowing to sender-only is a - one-line snapshot filter with only a mild latency/UX argument — deferred. -- **D5 — Changeset + apply + FFI unchanged.** Keep `pending_contact_crypto_{added,cleared}` flat - (entries carry owner), keep `apply.rs` ignoring them, keep the FFI signatures. Only the *in-memory* - field + its access sites move. The SQLite writer is unaffected (owner-keyed). -- **D6 — `ManagedIdentity` field is persistence-inert. Do NOT add it to `IdentityEntry::from_managed`** - (`changeset/changeset.rs:332`), which explicitly enumerates the persisted per-identity fields. - Leaving it out keeps the queue in-memory-only per identity (like `established_contacts` / - `dashpay_rescan_triggered`), so it is NOT double-persisted (once via the flat changeset delta, - never via a snapshot). This preserves D5. - -## 4. Alternatives rejected - -- **Per-identity changeset routing:** unnecessary — apply ignores the queue deltas and the SQLite - writer already keys by owner. Adds churn + a migration question for zero benefit. -- **Drop `owner_identity_id`:** forces the changeset/SQLite key to carry the owner another way and - rewrites the dedup key + tests. Higher risk, separable, deferred (§7). -- **Move only to `IdentityManager`:** leaves the queue one flat list one struct deeper — still not - keyed by identity. Doesn't achieve the goal. -- **Defer / TODO:** legitimate (a reviewer's call, given this path just absorbed the scalar - elimination). Decision: proceed now as an isolated, tested, reviewed change so it's bisectable. - -## 5. Failure modes & risk register - -| ID | Risk | Mitigation | -|----|------|------------| -| R1 | **[Critical]** Drain/count iterate only the owned bucket → an `AutoAccept` op on an *out-of-wallet* identity is silently never drained/counted (auto-accept never fires; banner under-counts). This reproduces the exact silent signer-gated regression this refactor fears. | D3/D4 iterate **both** buckets (`managed_identities()`). Test: an out-of-wallet identity holding an `AutoAccept` entry is still counted and drained. | -| R2 | Drain restructure holds a `&mut ManagedIdentity` or the wallet-manager guard across `.await` → won't compile / deadlock (the register fns re-acquire the non-reentrant manager lock). | D4: flat owned snapshot → drop guard → async loop → re-lock → synchronous per-owner removal. Never a live `values_mut()` borrow across `.await`; snapshot ids/entries into owned Vecs, re-lookup per owner (mirrors the sweep). | -| R3 | Enqueue routes to a wrong/absent identity. | Owner is already in hand at all 3 sites; add `managed_identity_mut(owner)` before upsert. `None` (identity removed in the narrow collect-guard→write-guard window) → log + drop (benign; the identity is gone). Test: enqueue lands on the owner's queue and nowhere else. | -| R4 | `count`/`drain` totals drift (miss an identity). | Same signatures + wallet-wide semantics over both buckets; test with 2 identities each holding entries asserts the aggregate equals the sum. | -| R5 | Auto-accept verify-failure marking regresses. | Marking is already per-owner (`managed_identity_mut(owner).mark_...`); folds into the same removal hop. Keep its test. | -| R6 | Future cold-load restore drops entries (a persisted row whose owner identity isn't applied yet). | The move introduces an ordering constraint the flat queue didn't have: any future restore must apply identities **before** fanning each persisted row out to its owner's queue. Documented here + in the storage doc-comment so whoever finishes the (currently blocked) restore doesn't reintroduce the drop. Not active today (nothing restores). | -| R7 | Identity removal now GC's its queue (dies with the `ManagedIdentity`). | Behavior change vs the flat wallet Vec (entries used to outlive owner residence). **Accepted** — it's orphan cleanup; a transient remove/re-add loses queued ops that the sweep re-enqueues on re-add. Noted, no code needed. | -| R8 | Identity removed between drain snapshot and removal → its keys aren't retained-off/cleared. | Net-identical to today: the op's target is gone and `apply` ignores the `cleared` delta anyway. Noted. | - -## 6. Change list (critical files) - -- `wallet/platform_wallet.rs` — remove the field + doc. -- `wallet/identity/state/managed_identity/mod.rs` — add the field + doc. -- `wallet/identity/state/managed_identity/identity_ops.rs` — init in `new` + `new_out_of_wallet`; **do not** touch `from_managed`. -- `wallet/identity/state/manager/accessors.rs` — add `managed_identities()` iterator (both buckets). -- `wallet/identity/network/contact_requests.rs` — 2 enqueues (`:1553`, `:1734`); `drain_pending_contact_crypto` (snapshot both buckets, per-owner removal); `pending_contact_crypto_count` (sum both buckets); `drain_auto_accepts`; `empty_info` test helper (`:3254`); the drain/count/auto-accept tests. -- `wallet/identity/network/contact_info.rs` — enqueue (`:385`). -- `wallet/identity/network/payments.rs` — send drain unchanged (`:575`); **re-seed** the drain tests (`:2757`, `:2811`) with real registered identities (out-of-wallet for the `identity_index==None` case). -- `manager/load.rs:114`, `manager/wallet_lifecycle.rs:249`, `wallet/apply.rs:420`, `wallet/platform_wallet_traits.rs:43,56` — drop the `PlatformWalletInfo` init sites. -- `rs-platform-wallet-storage/src/sqlite/schema/pending_contact_crypto.rs:100-106` — update the doc-comment that names `PlatformWalletInfo.pending_contact_crypto` as the restore target (now per-identity fan-out by `owner_identity_id`; see R6). -- `ffi/dashpay.rs` — unchanged; verify it still compiles. - -## 7. Explicitly out of scope - -- Dropping `owner_identity_id` from `PendingContactCrypto`. -- Deleting/altering the `pending_contact_crypto_{added,cleared}` changeset fields — they are - persisted by the SQLite backend (§2). NOT vestigial. -- Wiring the cold-load restore (blocked upstream); this change only leaves it a correct ordering note. -- Any Swift / FFI-signature change. - -## 8. Test / verification plan - -- **R1 (the one that matters):** an out-of-wallet identity holding an `AutoAccept` entry is counted - by `pending_contact_crypto_count` and processed by the drain — asserts both buckets are iterated. -- **R3:** enqueue lands on the owner identity's queue and no other identity's. -- **R4:** 2 resident identities each holding queue entries → aggregate count + drain == sum. -- **R2:** re-uses `retain_drained_by_snapshot`'s existing value-equality test shape, per-owner. -- **R5:** auto-accept verify-failure marks the right identity. -- Cold-load: a freshly-loaded identity has an empty queue; the sweep re-enqueues (behavior unchanged). -- Keep green (re-seeded where noted): `send_payment_runs_pending_contact_crypto_drain`, - `drain_completes_register_receiving_and_clears_queue`, - `drain_leaves_register_external_it_cannot_complete`, - `account_build_count_excludes_contact_info_decrypt`, the changeset merge/dedup tests. -- `cargo test -p platform-wallet -p platform-wallet-ffi`; `cargo clippy … --all-targets` clean; - `build_ios.sh --target sim` BUILD SUCCEEDED (FFI unchanged → Swift unaffected). diff --git a/docs/dashpay/QA_TESTCASES_SPEC.md b/docs/dashpay/QA_TESTCASES_SPEC.md deleted file mode 100644 index 55d0456ee12..00000000000 --- a/docs/dashpay/QA_TESTCASES_SPEC.md +++ /dev/null @@ -1,225 +0,0 @@ -# DashPay (DIP-15 / DIP-16) QA test-case expansion — SPEC - -**Status:** reviewed (4-lens multi-agent pass folded in) -**Target file:** `packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md` §4.10 (+ §5, §6, §1) -**Base:** branched off `feat/dashpay-m1-sync-correctness` (PR #3841, -"fix(platform-wallet)!: complete dashpay") — the DashPay views, `docs/dashpay/`, -and the features these rows describe live there, not yet in `v3.1-dev`. -**PR target:** `v3.1-dev`, to merge **after** #3841 lands (pure-docs diff; -rebased so it shows only the TEST_PLAN.md / spec changes). -**Renders in:** [`dashpay/qa-dashboard-site`](https://github.com/dashpay/qa-dashboard-site) -once the sibling seed task re-seeds the `dash-qa` contract from the updated plan. - ---- - -## 1. Problem - -`TEST_PLAN.md` §4.10 (DashPay) had **6 coarse rows** (`DP-01..06` + cross-ref -`MW-03`) at "feature exists" granularity, and their entry points were **stale**: -they cited `FriendsView` / `AddFriendView`, which #3841 replaced with a dedicated -DashPay tab (`DashPayTabView`, `AddContactView`, `ContactsView`, -`ContactRequestsView`, `ContactDetailView`, `DashPayProfileView`, -`IgnoredContactsView`, `SendDashPayPaymentSheet`). - -#3841 implements substantial **DIP-15** surface the catalog did not exercise -(per the branch's audit `docs/dashpay/DIP_CONFORMANCE_GAPS.md`): -`encryptedAccountLabel` send+receive, QR auto-accept (build + paste-to-add), -on-chain `contactInfo` publish, and the §12.6 incoming-payment backfill rescan. -**DIP-16** is the SPV sync layer underneath (covered by `CORE-07`; its -DashPay-specific facet is the single backfill row `DP-10`). - -## 2. Goal & non-goals - -**Goal:** correct the stale `DP-01..06` entry points and add rows for the -**user-observable, simulator-drivable** DIP-15/16 DashPay flows #3841 ships. - -**Non-goals (out of scope):** -- **DIP-15 crypto internals** (ECDH, 69-byte compact xpub, `accountReference` - masking, avatar hash/dHash) — already Rust known-answer tests; not app rows. -- **Gap / absence rows** (`🚫`/`➖`) for unimplemented features (multi-account - `Account≠0`, `acceptedAccounts` flood mitigation, invitations). Drivable only. -- **A DIP-16 section.** SPV sync is `CORE-07`; the DashPay facet is `DP-10`. -- **New table columns.** Keep the uniform 6-col `ID | Action | Layer | Tier | - Status | Entry point & test notes`; cite DIP §s inline. (The dashboard - normaliser reads no section field; a 7th column is dropped on seed.) -- **A `tags` column.** Tag assignment for the v5 contract is the seed tool's job - (not in these repos). Open question §7. - -## 3. Corrections to existing rows (`DP-01..06`) - -Entry points updated to the #3841 DashPay tab; FFI-symbol naming (matches the -existing §4.10 convention). Merged sub-flows folded in as notes: -- **DPNS-add path** → a note on `DP-01` (precedent: `ID-04`/`MW-01` list input - methods in one row; DPNS resolution itself is `DPNS-03`/`DPNS-07`). -- **Payment-channel-broken** state → a note on `DP-03` (precedent: `ID-12`/`DOC-07` - attach gating state to the action row). -- **Avatar** (url + Rust-computed hash/fingerprint) → a note on `DP-04`. -- `DP-06` **Reject → Ignore** rename (the branch made reject a reversible local mute). - -## 4. New rows (drivable DIP-15/16 flows) - -| ID | Tier | DIP-15 § | Behavior | -|---|---|---|---| -| DP-07 | Common | §8.5 | Attach `encryptedAccountLabel` on send; counterparty sees "Their account" (decrypted, incoming-row only). | -| DP-08 | Thorough | §8.13 | QR auto-accept: build "Add me" QR; add via pasted URI → auto-accepted without manual accept. Paste-drivable; camera = Manual variant. | -| DP-09 | Thorough | §10 | Publish encrypted on-chain `contactInfo`; ≥2-contact gate → `.published` / `.deferredUntilTwoContacts` / `.skippedWatchOnly`. | -| DP-10 | Manual | §8.7/§12.6 | Incoming-payment backfill rescan (no UI trigger; `reconcile_dashpay_rescan` rewinds SPV `synced_height`). Env-limited; the §12.6 payment-loss regression pin. | - -`DP-10` note: the branch's `DIP_CONFORMANCE_GAPS.md` §1.1 still marks this MISSING, -but that audit predates the implementing commit `18483e4232` -(`reconcile_dashpay_rescan`, wired in `manager/dashpay_sync.rs`, 4 unit tests) — -so Status=✅ is correct. - -## 5. Cross-cutting edits (applied) - -- **§6 index** — DashPay: `DP-01..06, MW-03` → `DP-01..10, MW-03`. -- **§5 by-tier** — Common `31→32`, Thorough `35→37`, Manual `1→2` (`CORE-08, DP-10`). -- **§5 by-layer (automatable; Manual EXCLUDED)** — Platform `~72→~75`; **Cross - unchanged** (DP-10 is Manual). -- **§1 worked example** — "list the manual tests" → `CORE-08, DP-10`. - -## 6. Final row set - -6 corrections (`DP-01..06`) **+ 4 new** (`DP-07` account label, `DP-08` QR -auto-accept, `DP-09` on-chain `contactInfo`, `DP-10` backfill rescan). - -## 7. Open questions - -1. **Tags** — does the seed tool assign v5 tags (e.g. `dip15`, `sync`) from - Domain/§6, or should the plan encode them? Needs the seed tool (not in repos). -2. **DP-07 a11y id** — the "Their account" block in `ContactDetailView` has no - `accessibilityIdentifier`, so DP-07 asserts on visible text. A 1-line app - change would make it cleanly automatable — a trivial follow-up, deliberately - kept out of this docs-only PR. - -## 8. Verification plan - -1. **Render check** — IDs match `^DP-\d+$`; tier/category present so the dashboard - matrix charts them (the normaliser only hard-requires `testId`). -2. **Drive each new row** with the `simulator-control` skill on a booted sim; two - on-device wallets where a counterparty is needed (`DP-07`/`DP-08`, cf. `MW-03`). - Verify against **persisted SwiftData state**, not UI alone (§1 pass criteria). - `DP-10` is Manual → skip-and-flag in automation. -3. **No code change** — pure TEST_PLAN.md edit. The seed task re-seeds `dash-qa`; - the dashboard renders. - -## 9. Review provenance - -Four independent review lenses (DIP domain-fit, scope/simplicity, automatability/ -entry-point accuracy, catalog conventions) ran against the draft. Key folds: -- Added `DP-09` (on-chain `contactInfo`) — the draft wrongly excluded it as - "local-only / no UI"; it is a drivable DIP-15 §10 publish. -- Merged the draft's separate DPNS / avatar / channel-broken rows into notes on - `DP-01` / `DP-04` / `DP-03` (catalog precedent; lean set). -- Dropped a 7th `DIP-15 §` column (normaliser ignores it; breaks the 6-col shape). -- Confirmed `DP-10`'s rescan is implemented + wired; corrected the `wallet.pass` - SF-symbol-vs-a11y-id confusion in `DP-07`. - -## 10. Runtime verification (simulator) - -Driven on a booted iOS simulator (iPhone 17) against a live devnet build with real -DashPay fixtures (wallet "SimB", 1 identity, 2 contacts, 5 requests), via the -`simulator-control` skill. Read-only structural pass — navigated to each row's -entry point and confirmed the cited screens/controls exist; **no broadcasts fired**. - -Confirmed live: -- `DP-01` — `AddContactView` mode picker + resolved-recipient preview + **Send Request**. -- `DP-03` — `ContactDetailView` `dashpay.detail.sendDash`. -- `DP-04` — `DashPayProfileView` **Edit** (→ editor) + avatar. -- `DP-05` — DashPay tab: `ContactsView` (search, contacts, segment, profile header). -- `DP-07` (send) — `dashpay.addContact.accountLabel` renders once a recipient resolves. -- `DP-08` — build: `dashpay.profile.qrURI` emits a real `dash:?du=…&dapk=…` URI + QR - image; add: `AddViaQRSheet` `dashpay.qr.uriField`. -- `DP-09` — the **Alias / Note / Hide** editor calls `saveContactInfo` → - `setDashPayContactInfo`; the in-app footer confirms the ≥2-contact encrypted-publish - gate. **Refined the row** accordingly — the original "distinct from a local note" - wording was wrong (the same editor caches locally *and* publishes on-chain). - -Code-confirmed but not rendered this pass (no fixture): `DP-02` / `DP-06` -(`dashpay.request.accept` / `.ignore` — need an *incoming* pending request); -`DP-07` receive-side "Their account" (only shows when a contact sent a label); -`DP-10` (no UI by design — automatic in DashPay sync). Live broadcast execution and -the two-wallet loops (`DP-07`/`DP-08`) are the next step, gated on credits + a -counterparty identity. - -A full code re-audit of every row (4 parallel passes) confirmed 8/10 rows + all the -§5/§6/§1 count edits accurate, and corrected 5 row-wording inaccuracies: DP-01 (open -button id `dashpay.addContact` vs the in-sheet mode toggle), DP-02/DP-05 -(`EstablishedContact` is a Rust/FFI handle, **not** a SwiftData model — the tab views -are backed by `PersistentDashpayContactRequest`), DP-03 (channel-broken is any -permanent channel failure, not only key rotation), and DP-08 (TTL is exactly 3600s). - -## 11. Implementation observations (for #3841 — surfaced during verification, NOT addressed here) - -These are defects/smells in the DashPay *implementation* found while auditing the -plan. They are out of scope for this docs PR; recorded for the #3841 author. - -1. **Stale doc-comment** — `ContactRequestsView.swift:5-8` says incoming rows carry - "**Accept / Reject**", but the button is **Ignore** (reject was replaced by the - reversible local mute). Same file `:35-37` carries an internal `§6.4` spec-gate - ref (rots; against the timeless-comment convention). -2. **Multi-wallet mis-attribution risk** — `DashPayProfileEditorView` falls back to - `walletManager.firstWallet` when `walletId` is nil (`IdentityDetailView.swift:1316`); - in a multi-wallet setup a profile update could submit under the wrong identity. - Already acknowledged in an in-code comment as needing tightening. -3. **Handle-leak smell** — `acceptContactRequest`'s returned `EstablishedContact` - (FFI handle wrapper) is discarded with `_ =` at both call sites - (`ContactRequestsView.swift:228`, `AddContactView.swift:487`); leaks per accept - unless the wrapper frees the handle in `deinit` (worth confirming a `deinit`). -4. **QR clock edge** — `build_auto_accept_qr` derives expiry from - `SystemTime::now()…unwrap_or(0)`; a pre-1970 / badly-skewed clock yields an - already-expired QR. Harmless on a real device. -5. **No collision handling in `AddViaQRSheet`** — pasting a URI from someone who - already sent *you* a request broadcasts a duplicate outgoing request rather than - offering "Accept instead" (`AddContactView` handles this; the QR path does not). - -By-design / cosmetic (no action expected): avatar hash does not change if the image -bytes are swapped behind the same URL; a corrupt/hostile incoming account label -unpads to garbage and is coerced to `None` (shows no "Their account" — relevant to -DP-07 negative testing); `setDashPayContactInfo` maps unknown future outcome bytes to -`.published` on the Swift side; a stale memo doc-comment in `SendDashPayPaymentSheet` -(DashPay payments always pass `memo: nil`); a dead `_ = bytes` local + a redundant -`?? nil` duplicated across four profile-cache reads. - -## 12. Live end-to-end run (freshly-built binary) - -Built `build_ios.sh --target sim` from `feat/dashpay-m1-sync-correctness` HEAD -(`47d9044b5a`), installed on two iOS simulators, and drove the flows on-chain -against devnet (two funded identities per side): **Eve** (SimB) ↔ **Alice / Bob / -Dolly(7A8E)** (SimA), each ~25–30B credits. Verified against SwiftData ground -truth (and on-chain for the payment). - -| Row | Result (fresh build) | Evidence | -|---|---|---| -| DP-01 send | ✅ | labeled contact request broadcast (sheet dismissed, no error); also the DP-02 reciprocal send | -| DP-02 accept | ✅ | 7A8E accepted Eve → reciprocal `7A8E→Eve` row created (established) | -| DP-03 payment | ✅ one direction | Eve→Alice **0.001 DASH** real L1 tx (input spent, change `74,899,477`, fee `226` duffs, txid `850433507c88…560e`) — **after starting Core SPV**. ⚠️ only the forward direction was driven; see the bidirectional gap below | -| DP-04 profile | ✅ | publicMessage updated on-chain → SwiftData (`QA fresh-build 16:10`) | -| DP-05 view | ✅ | contacts / requests / profile rendered throughout | -| DP-06 ignore | ✅ | registered a fresh identity (asset-lock funded, ChainLock proof) → sent Eve a request → Eve **ignored** it (→ ignored-senders) → **un-ignored** (reversed). Local-only mute | -| DP-07 label | ✅ fresh first-contact | Bob→**EveN** (fresh pair) labeled send; EveN accepted → decrypted "Their account" = the sent label. Confirms decrypt-on-accept end-to-end | -| DP-08 QR | ✅ fresh first-contact | Alice built `dash:?du=…&dapk=…`; **EveN** pasted + `sendContactRequestFromQR`; Alice **auto-accepted** (reciprocal, no manual Accept) — *after unlocking Alice's wallet* (signer-backed drain; see note) | -| DP-09 contactInfo | ✅ on-chain | log: `Published contactInfo document identity=Eve contact=Alice` — the `.published` outcome, not just local persist | -| DP-10 backfill rescan | ✅ mechanism (logs) | the §12.6 rescan fired live: `DashPay rescan: lowered SPV synced_height … floor=51112` → `dash_spv…filters: synced_height 51112 fell below committed_height 52175, restarting scan`. No UI trigger (Manual tier); the full restore-from-seed payment-recovery remains a device exercise | - -**10/10 flows verified live on the fresh build** — DP-01..09 driven on-chain -(SwiftData + chain; DP-07/DP-08 via a freshly-registered unconnected identity to -get clean first-contact pairs), DP-09's on-chain publish + DP-10's backfill-rescan -both confirmed in the Rust logs. - -**Gap (DP-03 bidirectional):** only the **forward** payment (Eve→Alice) was driven. -The reverse (Alice→Eve) is symmetric by design — once established, each party derives -the other's payment address from the exchanged xpubs — but it was **not** verified -live (SimA's app context had flipped to a separate testnet wallet set). DP-03 now -explicitly requires verifying **both** directions; the reverse remains to be driven. - -**Finding (DP-08):** the QR auto-accept *reciprocal* is signer-backed, so it only -fires once the recipient's wallet is **unlocked** (the "N contacts waiting to finish -setup → Unlock" drain). The request and auto-accept proof reach the recipient -immediately, but the established reciprocal lands after unlock — so "auto-accept" is -not fully hands-off. Worth surfacing in DIP-15 §8.13 expectations. - -Two plan corrections came out of the run: **DP-03** now records the Core-SPV -precondition (a DashPay payment is an L1 broadcast — fails "SPV Client not started" -if SPV is stopped); **DP-07** now states the label decrypts **on accept**, not on -ingest. diff --git a/docs/dashpay/QR_AUTO_ACCEPT_SPEC.md b/docs/dashpay/QR_AUTO_ACCEPT_SPEC.md deleted file mode 100644 index 23b84e11764..00000000000 --- a/docs/dashpay/QR_AUTO_ACCEPT_SPEC.md +++ /dev/null @@ -1,270 +0,0 @@ -# DashPay QR Auto-Accept (DIP-15) — Implementation Spec - -Decision (2026-06-24): build the DIP-15 `autoAcceptProof` QR flow, **faithful to the -DIP-15 wire formats** so we are a correct reference implementation. Research (incl. the -finding that no reference client implements this today, so it is iOS-first / convention- -setting) informed this spec. Invitations (DIP-13) are queued next. - -> **Status:** IMPLEMENTED (2026-06-24) across Rust + FFI + Swift; `build_ios.sh` green, -> platform-wallet 299 + ffi 117 tests green. REVIEWED (4-lens: DIP-fidelity / security / -> feasibility / scope) and revised — §10. The first draft's §4 was materially wrong -> (verify can't use `&Wallet` in the seedless drain; the drain lacks the identity signer; -> the sweep parser drops the proof) — all fixed. **Owner decisions:** TTL = 1h fixed; -> auto-accept = always automatic; whole feature in one pass; DIP-literal HD-derived owner -> key (scoped raw-key export). **On-device:** My-QR UI + DPNS-name guard verified; the full -> QR-generate→scan→auto-accept loop is pending a DPNS-named *local* identity (the available -> devnet wallets have on-chain names not cached in `PersistentIdentity.dpnsName`). Follow-up -> (P3): resolve the owner's DPNS name on-chain in `build_auto_accept_qr` when the local -> field is empty. - -## 1. Problem & goal - -DashPay contact establishment is two manual taps. DIP-15 defines an optional -`autoAcceptProof` so a party can pre-authorize automatic acceptance — the canonical use -case is a **merchant / in-person QR**: show a QR, the scanner sends a contact request that -the owner's client auto-accepts with no manual tap. The proof crypto exists and is -unit-tested (`auto_accept.rs`) but is **dormant** — nothing generates, verifies, or acts -on it. Goal: wire the full three-role flow, end to end (Rust + FFI + Swift + on-device). - -### Non-goals -- Not Invitations (DIP-13 `dashpay://invite` + AssetLock onboarding) — separate, queued next. -- No Android interop today (no reference client verifies the proof); iOS-first. We still - follow DIP-15 byte layouts so a future client can interop. -- No new on-chain artifact beyond the already-defined optional `autoAcceptProof` field. -- No `di=` identity-id URI fallback in v1 (DIP uses `du`; require a DPNS name — §9). -- No TTL picker, no opt-in toggle (always automatic) in v1 (§9). - -## 2. The DIP-15 model — three roles - -1. **Owner (QR shower, "Bob").** Derives an auto-accept key at `m/9'/5'/16'/expiry'`, - embeds the **private key + expiry** in a QR (`dash:?du=&dapk=`), - shows it. (`expiry = now + 1h`.) -2. **Scanner ("Carol").** Scans, resolves `du`→Bob's identity, decodes `dapk`→(private key, - expiry), derives her friendship `accountReference` to Bob, **signs `Carol.$ownerId ‖ - Bob.toUserId ‖ accountReference` with the handed key**, and sends a contactRequest to - Bob carrying that proof. -3. **Owner receives + auto-accepts.** Bob's client (at a signer-present drain) verifies the - proof against **his own** re-derived auto-accept **public** key and, if valid and - unexpired, **auto-accepts** (sends the reciprocal) with no manual tap. - -Why the scanner signs (not the owner): the signed message includes the scanner's -`$ownerId`, unknown at QR-create time — so the owner delegates signing via the (expiry- -bounded) private key. This per-sender binding means a leaked proof can't be replayed by a -*different* sender. - -## 3. DIP-15 wire formats — normative-for-us - -These are wire-faithful to DIP-15 (fidelity review: byte-for-byte match). Where DIP-15 is -silent, the value below is **normative for our implementation** — a future interop client -MUST match it or verification silently fails. - -**Auto-accept key blob** (`dapk` value), 38 bytes for ECDSA: - -| field | size | value | -|---|---|---| -| key type | 1 | `0x00` (ECDSA_SECP256K1) | -| timestamp/expiry (= derivation index) | 4 | u32, **big-endian** *(DIP-silent → normative)* | -| key size | 1 | `0x20` (32) | -| key | 32 | secp256k1 **private** key | - -**Proof blob** (`autoAcceptProof` field), 70 bytes for ECDSA, 38–102 range: - -| field | size | value | -|---|---|---| -| key type | 1 | `0x00` | -| key index (= expiry, same value as the blob) | 4 | u32, **big-endian** | -| signature size | 1 | `0x40` (64) | -| signature | 64 | compact ECDSA | - -**Signed message** *(DIP names the fields; hashing/encoding DIP-silent → normative)*: -`SHA256($ownerId(32) ‖ toUserId(32) ‖ accountReference(4, little-endian))`, where -`$ownerId` = the contactRequest **sender (scanner)**, `toUserId` = the QR **owner**, and -`accountReference` is the **raw masked u32** the contactRequest carries (`version<<28 | -masked_index`). Matches the existing `auto_accept.rs::build_message_hash`. **Security pin -(§6):** the verifier MUST bind `$ownerId` to the **consensus-authenticated document owner -id** (`doc.owner_id()`), never a self-reported field. - -**Derivation path**: `m / 9' / 5'(mainnet, else 1') / 16' / expiry'`, all hardened; `expiry` -≤ 2^31−1 (hardened-index bound, ~year 2038 — reject at encode time). Matches code. - -**URI**: `dash:?du=&dapk=` (contact-only). Matches the -DIP-15 example. No `di=` fallback in v1. - -## 4. Seedless integration (the corrected crux) - -Our wallets are `ExternalSignable` — no seed in Rust; key material is reachable only via -the Keychain resolver/provider. The background sweep is **signerless**. Both verify -(needs the owner's auto-accept key) and auto-accept (sends a signed state transition) need -key material, so **neither runs in the sweep** — they ride the deferred-crypto queue + -the signer-present drain. The first draft got the mechanics wrong; corrected: - -### 4.1 Sweep (signerless) — read the proof, enqueue, bounded -- **FIX (feasibility #2):** `parse_contact_request_doc` must read - `props.get("autoAcceptProof")` into the parsed `ContactRequest` (today it's hard-coded - `None`, so the proof is dropped before the queue). Mirror the outgoing reader. -- After `add_incoming_contact_request`, if the request carries an `autoAcceptProof` that - passes a cheap **structural pre-check** (length 38–102, key-type `0x00`), enqueue - `PendingContactCryptoOp::AutoAccept { sender_id }` (dedup key `(owner, sender, AutoAccept)`). -- **DoS bound (security #4):** cap queued `AutoAccept` ops per owner (constant, e.g. 64); - beyond the cap, skip enqueue (the request is still manually acceptable — nothing lost). - Log the drop (no silent cap). - -### 4.2 Drain (signer present) — needs BOTH signers -- **FIX (feasibility #3 / scope M1):** the drain needs the identity `Signer` - (to send the reciprocal) **and** the `ContactCryptoProvider`. Thread a `signer` into - `drain_pending_contact_crypto` and add a `signer_handle` to the drain FFI (matching the - send/accept FFIs). Existing arms ignore it (additive bound). **Note:** the drain FFI is - the same one `unlockWalletFromKeychain` calls (needs-unlock work) — that call site now - passes the Swift `KeychainSigner` too. -- Per `AutoAccept` entry, in order: - 1. **Local verify FIRST, before any network fetch** (security #4 — anti-DoS): build the - path `m/9'/coin'/16'/expiry'` (expiry from the proof header), derive the owner's - auto-accept **public** key via `provider.receiving_xpub(&path).public_key` (**FIX - feasibility #4** — verify needs only the pubkey; no `&Wallet`), then - `verify_auto_accept_proof_with_pubkey(pubkey, proof, sender_id = request.sender_id - (= doc.owner_id), recipient_id = self_identity, account_ref = request.account_reference)`. - 2. **Expiry check** against the **same** timestamp that keyed verification - (`now > expiry → reject`). - 3. If valid + unexpired → `accept_contact_request_with_external_signer(request, signer, - provider)` (sends the reciprocal; idempotent — adopts if already reciprocated). -- **Verdict mapping (security #3):** invalid signature / wrong params / expired / - out-of-range index (the `Err` from path derivation) ⇒ **permanent: clear the entry** - (the request falls back to a normal manual-acceptable pending request). Signer/network - unavailable ⇒ **transient: leave queued** for the next drain. Never `mark_channel_broken` - (there's no channel yet). - -Consequence: auto-accept completes at the owner's next signer-present moment (unlock or any -DashPay action), not instantly in the background. Consistent with the seedless model. - -## 5. Interface / data flow per layer - -### 5.1 Rust — `auto_accept.rs` (extend; keep existing tested fns) -- KEEP `derive_auto_accept_private_key(wallet, network, expiry)` (owner, QR-create). -- ADD `encode_auto_accept_key_blob(secret_key, expiry) -> Vec` / - `decode_auto_accept_key_blob(&[u8]) -> Result<(SecretKey, u32)>` (38-byte `dapk`). -- ADD `sign_auto_accept_proof(secret_key, scanner_id, owner_id, account_ref, expiry) -> Vec` - — scanner signs with the **handed** key. Message bytes = `scanner_id ‖ owner_id ‖ - account_ref(LE)` (the existing `build_message_hash`); a doc-comment ties the param names - to DIP roles (**scope M2** — the current `generate` models the owner as `sender_id`, - the opposite; don't invert at wiring). -- ADD `verify_auto_accept_proof_with_pubkey(pubkey, proof, scanner_id, owner_id, account_ref) -> bool` - — pure, no wallet (the drain's verify path). -- ADD `auto_accept_proof_expiry(proof) -> Option` and fold the expiry check into the - acceptance entry point — **do not** expose a public bare `verify` that returns `true` - for an expired proof (**security #2** foot-gun). Keep `verify_auto_accept_proof(wallet,…)` - for owner-side tests only. -- ADD a URI codec `encode_dashpay_contact_uri(username, key_blob)` / - `parse_dashpay_contact_uri(&str) -> Result<(username, key_blob)>` (pure, testable). -- Refactor `generate_auto_accept_proof` to `derive + sign` (test/convenience). -- Remove the stale `// TODO: Where and how we use these helpers?` and fix the docstring - that references a now-real `verify_auto_accept_proof_with_pubkey` (**scope N3**). - -### 5.2 Rust — changeset.rs + contact_requests.rs (flow) -- `PendingContactCryptoOp::AutoAccept { sender_id }` + `PendingContactCryptoKind::AutoAccept` - — 9 sites (feasibility #1 change-list): enum, kind, `kind()`, storage `KIND_LABELS`, - `kind_db_label`, the `kind_labels_match_enum` test, the drain's exhaustive `match`, and - `count_account_build_ops` (decide inclusion — **yes**, so the needs-unlock banner counts - pending auto-accepts; reword the banner copy, **scope S3**). -- `parse_contact_request_doc` reads `autoAcceptProof` (§4.1). -- `sync_contact_requests` enqueues `AutoAccept` (bounded) when a proof is present. -- `drain_pending_contact_crypto` gains the `signer` param + the `AutoAccept` arm (§4.2). -- Scanner send reuses `send_contact_request_with_external_signer(..., auto_accept_proof)` - (already threaded). **scope M3:** the scanner must derive its `accountReference` first - (in-signer, masked over the friendship xpub) and sign the proof over that **exact** value - before broadcast — test that the signed `accountReference` equals the document's. -- DPNS resolve: `IdentityWallet::resolve_name(&str) -> Option` (feasibility #5; - not `search_names`). - -### 5.3 FFI (rs-platform-wallet-ffi) -- `platform_wallet_build_auto_accept_qr(wallet, identity_id, out_uri…)` — owner: resolve - the wallet's DPNS name (error if none), `expiry = now + 3600`, derive the key, build the - `dash:?du=…&dapk=…` URI; return it. (Single Rust entry — no decisions in Swift.) `now` is - passed in from Swift (FFI can't read the clock deterministically) or read via a host hook. -- `platform_wallet_send_contact_request_from_qr(wallet, signer, core_signer, uri, out…)` — - scanner: parse URI → resolve `du` → decode `dapk` → (derive accountRef, sign proof) → send - the contactRequest with the proof. One call. -- `platform_wallet_drain_pending_contact_crypto` gains `signer_handle: *mut SignerHandle` - (the identity signer) alongside the existing `core_signer_handle` (§4.2). - -### 5.4 Swift (SwiftExampleApp) -- **My QR** (net-new, `DashPayProfileView`): a "Show my QR" affordance rendering the URI - from `build_auto_accept_qr` via the existing `generateQRCode` helper. -- **Scan** (net-new entry in the DashPay tab toolbar): present `QRScannerView`; add a new - parse branch + result type (`ScannedContact{username, keyBlob}`) to `QRPayloadParser` - (the existing `ScannedPayment` path doesn't fit a no-address URI — **scope N2**), route to - `platform_wallet_send_contact_request_from_qr`. -- **Drain call-site:** `unlockWalletFromKeychain` now passes the `KeychainSigner` to the - drain FFI (the added `signer_handle`). -- **Feedback (scope S4):** the auto-accepted contact lands in `ContactsView` via `@Query`; - add a light signal (the needs-unlock banner already counts the pending `AutoAccept`, so it - shows "1 contact waiting…" until the drain completes, then it appears as a contact). - -## 6. Security (4 must-fixes folded) - -1. **Consensus-authenticated sender binding (must-fix #1):** verify binds `$ownerId = - doc.owner_id()`. A malicious holder of a leaked QR key *can* sign a proof naming any - sender, but cannot broadcast a contactRequest *as* a victim — platform consensus - requires the doc to be signed by the owner's identity key. The verify gate MUST use the - document owner id, never a proof-internal/client value. -2. **No expired-but-valid foot-gun (must-fix #2):** the only acceptance entry checks expiry - against the same timestamp that keyed verification; no public bare `verify` returns - `true` for an expired proof. -3. **Drain verdict mapping (must-fix #3):** invalid/expired/bad-index → permanent-clear; - signer/network → transient-leave (§4.2). Prevents forever-churn. -4. **Queue bound + verify-before-fetch (must-fix #4):** cap `AutoAccept` per owner; run the - local ECDSA verify + expiry before any `Identity::fetch`, so a spam-N-identities attacker - can't turn the owner's unlock into O(N) network round-trips. -- **Private key in QR:** intrinsic to DIP-15 (the scanner must sign; the owner can't - pre-sign without the scanner's id). Scoped (only auto-accept, not payments/identity), - expiry-bounded (**1h**), blast radius = unwanted contact spam (removable via ignore). - Acceptable documented trade-off, tightened by the short TTL (no off-switch since - auto-accept is always-on, so the short TTL is the mitigation). -- **Replay:** signed message binds `(sender, owner, accountReference)`; the doc's unique - index is `($ownerId, toUserId, accountReference)` — no cross-sender replay, no on-platform - dup. Cross-network separated by coin-type in the path. - -## 7. Failure modes -- **Signer absent when a proof arrives:** enqueued (bounded), completes on next drain; - surfaced by the needs-unlock banner. -- **Expired / invalid / forged proof:** verify-gate rejects, entry cleared (permanent); - request remains manually acceptable. -- **`du` resolves to wrong/missing identity:** scanner send fails loudly; no contact. -- **Owner has no DPNS name:** `build_auto_accept_qr` errors at QR-create (v1 requires `du`). -- **Queue flood:** bounded per owner; junk cleared by local verify before any fetch. -- **Expiry index overflow (> 2^31−1):** rejected at encode (and verify path-derivation errors → permanent-clear). - -## 8. Test plan -- **Rust unit (auto_accept.rs):** key-blob round-trip; URI round-trip; **cross-actor** sign - (loose key, scanner) → verify-with-pubkey (owner's re-derived pubkey) succeeds; wrong - sender/owner/accountRef fails; expiry extraction + now ≤/≥ expiry; truncated/oversize/bad - key-type rejected; structural pre-check. -- **Rust flow (contact_requests.rs):** parser reads `autoAcceptProof`; ingest-with-proof → - `AutoAccept` enqueued (and bounded — Nth+1 dropped); drain valid+unexpired → reciprocal - sent + cleared; expired → cleared, not accepted; invalid → cleared; signerless/transient → - stays queued; **signed `accountReference` == document's** (scope M3). -- **FFI:** null/oversize/bad-URI input validation; build-QR → parse round-trip; drain with - the new signer handle. -- **Swift build:** `build_ios.sh` green. -- **On-device (two sims):** A "Show my QR" → B scans → sends; A unlock/drain → contact - auto-accepts (established, no tap on A); expired-QR path rejected. - -## 9. Decisions (resolved 2026-06-24) -1. **TTL = 1 hour, fixed** (named constant `AUTO_ACCEPT_TTL_SECS = 3600`). DIP-15 is silent - on the value (only mandates the timestamp *is* the expiry); 1h is the safe default given - auto-accept is always-on (no off-switch). No picker in v1. -2. **Auto-accept = always automatic.** No opt-in toggle. Valid + unexpired proofs - auto-accept in the drain. -3. **Scope = whole feature in one pass** (Rust + FFI + Swift + on-device), committed in - logical layers on the branch. -4. **`du`-only** (no `di=` fallback); require a DPNS name to build a QR. - -## 10. Review resolutions (4-lens, 2026-06-24) -- **DIP-fidelity:** wire-faithful, no byte fixes; pinned BE + SHA256/LE as normative (§3). -- **Security:** 4 must-fixes folded (§6); private-key-in-QR accepted as DIP-intrinsic, - mitigated by the 1h TTL. -- **Feasibility (§4 rewrite):** verify via `provider.receiving_xpub(path).public_key` (no - `&Wallet`); drain gains the identity signer + FFI `signer_handle`; the sweep parser must - read `autoAcceptProof`; use `resolve_name`. Queue variant change-list = 9 sites. -- **Scope:** `du`-only + fixed TTL (cut `di=`/picker); cross-actor + `accountReference` - ordering tests; banner counts `AutoAccept`; My-QR + Scan are net-new UI; clean stale - `auto_accept.rs` docstrings. diff --git a/docs/dashpay/SIGNER_SEED_ELIMINATION_SPEC.md b/docs/dashpay/SIGNER_SEED_ELIMINATION_SPEC.md deleted file mode 100644 index a0594bd68d6..00000000000 --- a/docs/dashpay/SIGNER_SEED_ELIMINATION_SPEC.md +++ /dev/null @@ -1,666 +0,0 @@ -# DashPay Signer-Based Seed Elimination — Spec - -Status: DRAFT v3 (revised after a 3-agent deep design review: seedless -background-sync architecture, signer/host-primitive model, security & -failure-mode audit) -Branch: `feat/dashpay-m1-sync-correctness` (PR #3841) -Cross-repo: required key-wallet method (`extended_public_key`) has LANDED; -pinned `rust-dashcore` rev already bumped. - -## 1. Problem - -PR #3639 ("external signable wallets", v3.1-dev) set this codebase's -posture: a registered/restored wallet holds **no resident seed** -(`WalletType::ExternalSignable`). Private-key work is done by passing a -**Keychain-backed `Signer`** per operation; the seed lives only in the iOS -Keychain. - -The DashPay paths added in PR #3841 did not follow that model — they reach -for the resident seed (`send_payment` passes the `Wallet` to `build_signed`; -`derive_contact_xpub` calls `wallet.derive_extended_public_key`; contact -encrypt/decrypt + `accountReference` + contactInfo derive raw secrets off -the `Wallet`). To make them work, `manager/attach_seed.rs::attach_wallet_seed` -re-derives a signing `Wallet` from the Keychain seed and grafts it onto the -loaded wallet via `std::mem::swap`. **That defeats the external-signable -posture** (the seed becomes resident for the whole session) and is a -workaround. - -The resolved **import-wallet bug** was the same disease for identity keys -(Swift re-derived identity scalars from the mnemonic during discovery); -fixed by the carry-scalar change (later reworked into the derive-sign-destroy resolver — see `IDENTITY_KEY_SCALAR_ELIMINATION_SPEC.md`). - -### Goal - -Every DashPay private-key operation runs through a Keychain-backed host -primitive; the wallet seed is **never made persistently resident**; -`attach_wallet_seed` (+ `unlockWalletFromKeychain`'s re-attach + the FFI -export + the dual-gate/`mem::swap`) is **deleted** — but only after the -background sync sweep is seedless-safe (§4.9 ordering constraint). - -### Honest scope of the security win (corrected after the security audit) - -This does **not** make the seed "never resident." Verified facts, to be -stated plainly so the win is not over-credited: - -- **The full BIP-39 64-byte seed + master xprv are reconstructed in one - contiguous buffer per operation** (`MnemonicResolverCoreSigner::resolve_derived_xprv` - — `seed: Zeroizing<[u8;64]>`, master xprv on the next lines; sibling - `sign_with_mnemonic_resolver.rs`). The whole wallet is derivable from that - buffer for the duration of the op. -- **The read is unlock-gated, not biometric-gated.** The Keychain mnemonic is - `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` with **no `LAContext` / - `SecAccessControl`** on the read path (`WalletStorage.swift`). The - `.biometryCurrentSet` stash exists but is **unused**. So "user present" - really means "device unlocked" — any in-process code can drive the resolver - while unlocked. -- **The wipe is best-effort.** Byte buffers use `Zeroizing` (volatile + fence, - runs on unwind). The two `ExtendedPrivKey` scalars use secp256k1's - `non_secure_erase` (fills `[1u8;32]`, self-disclaimed as non-secure; - `ExtendedPrivKey` has no `Drop`/`Zeroize` upstream). There is an **error- - /unwind-path residue gap** (§4.2 hardening). - -The real, defensible benefit is a **smaller in-memory time-window** for the -root secret — per-operation-and-wiped vs `attach_wallet_seed`'s session-long -resident `Wallet` — plus consistency with the #3639 posture. It is a modest, -honest improvement, not "the seed is never in RAM." (The dashj / -dash-shared-core reference clients hold the decrypted seed for the whole -session — see §8 — so there is no off-the-shelf signer-based DashPay to copy.) - -### Non-goals - -- Changing `downgrade_to_external_signable` (`wallet_lifecycle.rs:251`) — it - is what makes the seed absent; it stays. -- Re-introducing an in-memory-seed wallet (the dashj model). -- Wiring QR-based auto-accept — tracked in the backlog (dashpay/platform#4020) (helpers KEPT, not - deleted; §2 note). - -### Q2 scope, status & completion criteria (2026-06-23) - -**Q2** (the PR reviewer ask) = *remove the assign-seed workaround* = delete -`attach_wallet_seed` (this §; §4.9). Live status: the backlog issue dashpay/platform#4020 (authoritative tracker). - -**Done + verified** (branch `feat/dashpay-m1-sync-correctness`; platform-wallet -292/292; glue builds host + `aarch64-apple-ios-sim`): §2 inventory sites **#1–#6** -— the entire seedless contact-request flow (send/accept/drain/always-enqueue -sweep), the `ContactCryptoProvider` seam + signer host primitives (ECDH, -accountReference, contactInfo seal/open, wrong-seed check), the deferred-crypto -queue + SQLite persistence, and C3 (resident ECDH path deleted). Discovery is -NOT a rewrite target — the **carry-scalar fix is kept** (§1). - -**Remaining for Q2 (do in this order). Folds in the 4-lens plan review -(feasibility / security / scope-dedup / adversarial), which corrected two -over-optimistic items in an earlier draft of this banner — see the MUST-FIX -notes.** - -1. **#7 contactInfo (the load-bearing item).** - - Add `contact_info_seal` AND **`contact_info_open`** to `ContactCryptoProvider` - (+ glue impl over the signer primitives + `SeedCryptoProvider` test impl). - **MUST-FIX (review):** publish is NOT seal-only — it must DECRYPT existing - owned docs to decide update-vs-create (the doc↔contact binding lives inside - `encToUserId` ciphertext; `contact_info.rs:435-447`), so it needs `open`. - - Refactor the shared helper `fetch_decrypted_contact_infos` (resident-hardcoded - at `:206`/`:450`, used by BOTH publish and the signerless sweep) into a public - high-water scan (no keys) + a provider-`open` decrypt step. Thread `crypto` - into `set_contact_info_with_external_signer` (publish) and the FFI - (`platform_wallet_set_dashpay_contact_info_with_signer` gains a - `core_signer_handle` — ABI break, like send/accept). - - **MUST-FIX (security): root-path provenance.** Build the contactInfo root - path in **Rust** via `identity_auth_derivation_path_for_type(net, ECDSA, - identity_index, root_key_id)` (never Swift); pin the parity test against that - REAL path, not `test_path()` — a wrong root silently writes undecryptable - contactInfo with no on-chain oracle. - - **MUST-FIX (security): high-water.** Publish is signer-present, so derive the - high-water `derivationIndex` from a FRESH full decrypt, or refuse to publish — - NEVER fall back to `0`/stale (collides the unique `($ownerId, rootIndex, - derivationIndex)` index / reuses a key). - - Implement the `ContactInfoDecrypt` **drain op** (currently a no-op stub, - `contact_requests.rs:1440`): re-fetch owned docs + `contact_info_open` via the - provider + re-run `validate_contact_request`-equivalent. Re-fetch = **testnet - validated**. `derive_contact_info_keys` (resident twin) is deleted ONLY after - both publish AND this sweep/drain path no longer call it. - - **MUST-FIX (security): confused-deputy.** The drain (and opportunistic drains) - must re-validate the queue entry's `owner_identity_id` is owned by THIS wallet - before decrypting/registering. - -2. **Discovery/loading — NO library change (corrected; was wrong in the earlier - draft).** The FFI already routes external-signable wallets through - `discover_from_master` / `load_identity_by_index_from_master` (via - `resolve_master_from_resolver`), and the `ResidentWallet` variants are the - **live path for genuine resident-key wallet TYPES** (`WalletType::Mnemonic`/ - `Seed`, e.g. raw-seed imports with no persisted mnemonic) — NOT dead duplicates. - So **keep** them; do **NOT** attempt the deep `verified_scalar`-drop rewrite - (that's the env-blocked architectural change, and unnecessary — carry-scalar is - kept). The only Q2 work here: (a) confirm every discovery/loading caller passes - a non-null resolver after the deletion makes external-signable the universal - posture (the FFI already errors on null — `identity_discovery.rs:194`); (b) the - test-helper rework in step 3. NOTE: the §2 "exhaustive" table is exhaustive over - *DashPay-contact* paths; the discovery resident derive `derive_identity_auth_keypair` - (`identity_handle.rs:191`) is a resident-key-TYPE path that legitimately stays. - -3. **Test-helper rework + Swift.** ✓ DONE. The test helpers were made seedless - earlier (`c42d2e413e`). Swift (`70aaf32f9f`): `unlockWalletFromKeychain(_:)` - now verifies-binds + drains-on-unlock (no seed graft); send/accept/contactInfo - thread the resolver `core_signer_handle`. Verified by regenerating the - xcframework header (`build_ios.sh --target sim`) + an arm64-sim SwiftExampleApp - build (BUILD SUCCEEDED). - -4. **Delete** `attach_wallet_seed` + FFI export + dual-gate/`mem::swap` + the dead - `dash_sdk_dashpay_*` rs-sdk-ffi surface (4 fns, zero Swift callers — confirmed) - + the legacy `KeychainSigner.sign(...)->Data?` nil-swallow. - - **MUST-FIX (security): atomic wrong-seed wiring.** ✓ DONE (Rust, `fe3ab74e19`): - `attach_wallet_seed` (lib + FFI + dual gate) deleted and `verify_seed_binds` - (`PlatformWallet` method + `platform_wallet_verify_seed_binds_to_wallet` FFI) - landed in the same commit — signer-derived BIP44-0 xpub vs the persisted one, - mismatch → `SeedMismatch`. The comparison lives in `verify_seed_binds`, which - derives the xpub through the `ContactCryptoProvider::receiving_xpub` seam (a - generic derive-at-path) — no redundant trait method. (The signer's - `verify_binds_to_xpub` primitive was NOT used and was deleted post-review as - dead code; the live path reimplements the equality in `verify_seed_binds`.) - The dead `dash_sdk_dashpay_*` surface was removed earlier (`db18688545`). - ✓ Swift wiring DONE (`70aaf32f9f`): the verify FFI is called at unlock and the - `KeychainSigner.sign(...)->Data?` nil-swallow is deleted (see step 3 Swift). - - **SHOULD-FIX (security): §4.2 sibling-FFI leak.** ✓ DONE (`feb266fd1b`): the - `WipingXprv` RAII guard now wraps `master`/`derived` in - `rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`, scrubbing on the - error/unwind paths the Ok-only `non_secure_erase` missed. - -**Done when:** -- ✓ `git grep attach_wallet_seed` empty (outside docs/regenerated headers). -- ✓ `cargo test -p platform-wallet` green (292) + glue (`platform-wallet-ffi`) green. -- ✓ `build_ios.sh --target sim` regenerates the header (verify FFI present, attach - gone, send/accept/contactInfo carry `core_signer_handle`) + SwiftExampleApp - builds clean (arm64 sim, BUILD SUCCEEDED). -- **On-device acceptance — VALIDATED on devnet `paloma` (2026-06-23)** via idb UI - automation across **two simulators** (sim A = funded `Test_devnet`, 9 DASH, 3 - identities; sim B = freshly created `SimB` wallet + new identity "Eve"). Every - signing op below ran through the Keychain resolver with **no resident seed**: - - ✓ App builds, installs, launches, runs full (SDK init, network switch, wallet - list/detail, DashPay sync) on the new seedless binary — no crash. - - ✓ **Seedless Core payment + IS-lock** — sim A sent 1 DASH to sim B's address; - tx signed via the resolver, broadcast, **InstantLock validated** (this is the - real wrong-seed-would-fail path: the seedless wallet signed a real Core tx). - - ✓ **Seedless identity registration** — sim B: asset lock → InstantSend proof → - ChainLock proof → Platform registration, all via the resolver ("Identity - created" `BfWHEg…`). - - ✓ **Seedless DPNS name registration** (`eveqaseed1ess2026`) + **profile publish** - ("Eve") — both Platform writes via the resolver. - - ✓ **Seedless contactInfo publish** — `setDashPayContactInfo` - (`core_signer_handle`) published an encrypted contactInfo doc, **create** - (`updated=false`) **and update** (`updated=true`). - - ✓ **Seedless contact request SEND** (Alice→Eve) — `send_contact_request_with_signer` - (`core_signer_handle`): registered the DashpayReceivingFunds account. - - ✓ **Seedless contact request ACCEPT** (Eve→Alice) — - `accept_contact_request_with_signer` (`core_signer_handle`): reciprocal request - + registered DashpayReceivingFunds **and** DashpayExternalAccount (the ECDH + - accountReference path). Contact established cross-device (sim A shows Eve as - contact #3). - - ⏳ NOT exercised on-chain (covered elsewhere): (a) **wrong-seed rejected loud** - via `verify_seed_binds` at unlock — gated behind the `loadFromPersistor` - restorable path; cleanly forced only by a destructive wipe+reimport. Covered by - the high-fidelity unit test (real `key_wallet` wallet, same BIP44-0 path, - accept/reject) — and the live Core/Platform signing above proves the resolver - derives correct keys. (b) **cross-device contactInfo decrypt** (same owner on a - 2nd device) — publish validated; decrypt-drain unit-tested. - - **Unrelated finding (not a seed defect):** the DashPay contact-profile chunk fetch - fails with a DAPI error `missing order by for range error: query must have an - orderBy field for each range element` ("Failed to fetch a contact-profile chunk; - will retry next sweep"). Contact establishment still succeeds; contact *profiles* - may not render. Track + fix separately. - -**Out of Q2 scope** (tracked in the backlog (dashpay/platform#4020)): §6b queue restore (upstream -`ClientStartState::wallets` — note the security review's caveat that until restore -works, a contact discovered-then-app-killed-before-unlock never finishes setup); -the §4.8 present-but-zero-keys import caveat; QR auto-accept wiring. - -## 2. Inventory of seed-dependent paths (revised; exhaustive over **DashPay-contact** paths) - -Verified by grepping every `derive_extended_p*_key` / `build_signed(wallet` / -`.has_seed()` reader, and by tracing reachability from the background sweep. -NOTE (plan review): this table enumerates the DashPay-contact seed paths (#1–#7). -The **identity-discovery** resident derive `derive_identity_auth_keypair` -(`identity_handle.rs:191`) is a separate resident-key-TYPE path — it legitimately -stays for `WalletType::Mnemonic`/`Seed` wallets and is NOT a Q2 deletion target -(external-signable wallets already route discovery through the resolver). See the -Q2 banner step 2. - -`bg?` = reachable from the **signerless** recurring sweep -(`DashPaySyncManager` → `dashpay_sync` → `build_contact_accounts` / -`sync_contact_infos`). The sweep FFI takes **no signer handle** and runs with -no user present — so any `bg?`+secret op is a deferral problem (§4.6). - -| # | Call site | Capability | bg? | Phase | -|---|---|---|---|---| -| 1 | `send_payment` (`payments.rs`, build site) | ECDSA sign at path | no | **1 — DONE** | -| 2 | `derive_contact_xpub` (`dip14.rs:98`), caller = send-contact-request flow (signer present) | xpub at hardened path | no | **2** (bundled with #4 — same function) | -| 2b | `register_contact_account` → `wallet.derive_extended_public_key` (`contacts.rs:186`) | xpub at hardened path | **yes** | **2** (steady-state no-op — see note) | -| 3 | contact-request / profile / contactInfo **doc signing** | `Signer` | — | already done | -| 4 | contact-request xpub **encrypt** — `derive_encryption_private_key` (`identity_handle.rs:476`) → `EcdhProvider::SdkSide` (`sdk_writer.rs:240`) | ECDH | no | **2** | -| 5 | contact xpub **decrypt** — `register_external_contact_account` (`contacts.rs:472/510/514`) | ECDH | **yes** | **2 — DEFERRED via queue** | -| 6 | `accountReference` (`contact_requests.rs:204`; `dip14.rs:262`) | HMAC keyed by the **same** ECDH key | partial | **2** | -| 7 | contactInfo AES keys — `derive_contact_info_keys` (`contact_info.rs:80`) via sync (read) + publish (write) | raw hardened-child bytes as AES-256 key | **yes (read)** | **2 — DEFERRED via queue** | - -Reclassification vs v2 (from the review): - -- **Sites 2 and 2b moved Phase 1 → Phase 2.** Both live in functions that - *also* perform Phase-2 ECDH (#4 in `send_contact_request_with_external_signer`; - #5 in the `register_external` path that the sweep drives). Converting their - xpub piecemeal in Phase 1 would double-touch the same functions. Bundle each - xpub conversion with the ECDH conversion of its host function (one - wallet-HD closure threading per function). Phase 1 stays exactly the - already-shipped, green slice (#1 + the `extended_public_key` foundation + - dead-API deletion). -- **2b is a steady-state no-op.** `register_contact_account` has an early-exit - (`contacts.rs:153–174`): once the receiving account exists it returns before - the derive at `:186`. The account **is persisted** — - `AccountRegistrationEntry.account_xpub: ExtendedPubKey` (`changeset.rs:967`) - bincode round-trips (`persistence.rs:2387`/`2869`) and restores via - `Account::from_xpub` into a watch-only `new_external_signable` wallet - (`persistence.rs:2874/2889`), with the address pool + `used` flags - (`AccountAddressPoolEntry`). Gap-limit refill is pure public `ckd_pub` - (`KeySource::Public`, key-wallet `managed_account_trait.rs:295`). So the - derive at `:186` fires **only** on a contact's first-ever registration in a - session where it was never persisted — the deferral edge case (§4.6). -- **Only #5 (ECDH decrypt) and #7-read (contactInfo decrypt) are genuine - background blockers.** Everything else the sweep does (request ingest, - profile sync, address matching, gap-limit refill, payment reconcile) is - pure public derivation or no derivation at all. - -Notes (unchanged from v2): -- **#6 is not a separate key** — `calculate_account_reference` is HMAC-keyed by - the same ECDH scalar as #4/#5; the ECDH handling covers it. -- **auto-accept (`auto_accept.rs:80`) is KEPT** — real but unwired DIP-15 - feature; converts cleanly to a sign path when wired. Tracked in the backlog (dashpay/platform#4020). -- **Dead read APIs** `contact_xpub` / `contact_payment_addresses` had zero - callers → **DELETED in Phase 1**. - -`attach_wallet_seed` consumers to remove (Phase 2, §4.9): FFI -`platform_wallet_manager_attach_wallet_seed_from_mnemonic` (`manager.rs:430`); -Swift `unlockWalletFromKeychain` (`PlatformWalletManager.swift:468`); test -helpers (`payments.rs`). - -## 3. Capabilities the Keychain signer must expose - -Two distinct, correctly-separate signer notions exist and stay separate -across the FFI seam (§4.4): - -- **Doc-signer** — `dpp::identity::signer::Signer` - (state-transition signing). Impl = `VTableSigner`; FFI handle = - `SignerHandle`/`VTableSigner` (seed never enters Rust). Already wired. -- **Wallet-HD signer** — `key_wallet::signer::Signer` (ECDSA-at-path, - `public_key`, `extended_public_key`). Impl = `MnemonicResolverCoreSigner`; - FFI handle = `MnemonicResolverHandle` (mnemonic transiently enters Rust; - all crypto runs in FFI-crate Rust and wipes). - -Wallet-HD capabilities: - -1. **ECDSA sign at path** — EXISTS (`sign_ecdsa`). -2. **public_key at path** — EXISTS. -3. **extended_public_key at path** — DONE (key-wallet method + host primitive). -4. **ECDH** `(path, peer_pubkey) -> shared_secret` — NEW host primitive (§4.5). -5. **contactInfo seal/open** — NEW host primitive (§4.5). - -Capabilities 4–5 are added as **inherent methods on `MnemonicResolverCoreSigner`** -(in `rs-sdk-ffi`, where it already lives) and consumed by platform-wallet via -**closures** (the existing `EcdhProvider::ClientSide` seam) — no new trait, no -new crate (§4.4). - -## 4. Design - -### Phase 1 — sign + xpub (low-risk, SHIPPED green) - -#### 4.1 key-wallet change — LANDED - -`key_wallet::signer::Signer::extended_public_key` added as a **provided -default that errors** (not a breaking required method); `InMemorySigner` test -impls override it; pinned rev bumped. `TransactionSigner`/`build_signed` -unchanged. - -#### 4.2 host primitive: extended_public_key (FFI + Swift) — DONE + 1 hardening - -`MnemonicResolverCoreSigner::extended_public_key` reuses the shared -`resolve_derived_xprv` helper, computes `ExtendedPubKey::from_priv`, and wipes -both scalars. Interop-guard test pins signer-xpub == `Wallet::derive_extended_public_key`. - -**Phase-1 hardening (from the security audit) — TODO before merge:** wipe the -`master` scalar on the **error/unwind path** of `resolve_derived_xprv`. Today -the explicit `non_secure_erase` runs only in the `Ok` arms of `derive_priv` -and `extended_public_key`; if `master.derive_priv(path)` returns `Err`, or a -panic unwinds between materialization and the wipe, the `master` scalar leaks -(no `Drop`/`Zeroize`). Same gap in `sign_with_mnemonic_resolver.rs`. Preferred -fix: a small RAII wipe-guard around the two `ExtendedPrivKey`s (so all exit -paths wipe), rather than more hand-placed calls — there will be **five** such -sites once §4.5 lands. - -**Path provenance (security requirement).** Paths are built in Rust -(`AccountType::…derivation_path()`, `identity_auth_derivation_path_for_type`, -the `dip14`/`contact_info` path builders) and passed **opaquely** through the -FFI; Swift never assembles a path. - -#### 4.3 call-site conversions (Phase 1) — DONE - -- `send_payment` takes `` and signs via - `build_signed(signer, …)`; FFI `platform_wallet_send_dashpay_payment` takes - a `MnemonicResolverHandle`; Swift threads the resolver under - `withExtendedLifetime`. -- Dead `contact_xpub` / `contact_payment_addresses` deleted. -- Sites 2 and 2b are **not** converted here — moved to Phase 2 (§2). - -### Phase 2 — raw-secret paths + seedless sweep + delete the workaround - -#### 4.4 Signer & host-primitive model (no duplicate logic) - -**Decision: two FFI handles; raw-secret ops as inherent methods on the -existing wallet signer, consumed via closures — NO new trait, NO new crate.** - -- Keep `VTableSigner` (doc-signer) and `MnemonicResolverHandle` (wallet-HD) as - **two** FFI handles. Merging them would either regress the doc-signer (seed - currently never enters Rust) or force DIP-15 crypto into Swift — both - rejected. -- `MnemonicResolverCoreSigner` (in `rs-sdk-ffi`) already **is** the wallet-HD - binding (impls `key_wallet::signer::Signer`) and is constructed only by the - `rs-platform-wallet-ffi` glue crate. `rs-sdk-ffi` and `platform-wallet` do not - depend on each other, so a shared `WalletKeyProvider` *trait* would need a new - crate or a layering inversion (the external `key-wallet` is ruled out — keep - the cross-repo PR to one method, and ECDH must not become a `Signer` method). - Avoid all of that: add the raw-secret capabilities as **inherent methods** on - `MnemonicResolverCoreSigner` (it gains a `platform-encryption` dep — a leaf - crypto crate, no cycle), and let platform-wallet consume them via **closures** - — the `EcdhProvider::ClientSide { get_shared_secret }` seam it already uses, - plus a closure param on the decrypt/drain path. The glue crate wires the - closures from the signer's methods (it already owns the signer's construction - + lifetime). *(An earlier draft proposed a `WalletKeyProvider: Signer` - extension trait; dropped — no shared home given the crate graph, and the - closure seam already exists.)* - -Inherent methods on `MnemonicResolverCoreSigner` (sync — derivation is -CPU-bound + the resolver call is synchronous; the consuming `ClientSide` closure -wraps each in a future at the FFI seam): - -```text -ecdh_shared_secret(path, peer_pubkey) -> Zeroizing<[u8;32]> // #4/#5 DONE -ecdh_shared_secret_and_account_reference(path, peer, compact_xpub, account_index, version) - -> (Zeroizing<[u8;32]>, u32) // #6 -unmask_account_reference(path, prior_reference, compact_xpub) -> (u32, u32) // #6 -contact_info_seal(root_path, derivation_index, contact_id, plaintext, iv) -> ContactInfoSealed // #7 -contact_info_open(root_path, derivation_index, enc_to_user_id, blob) -> ContactInfoOpened // #7 -``` - -- Document-ops conversion: send/accept contact-request already thread a - `doc_signer` for the state transition; for the xpub/ECDH they additionally - receive the wallet-HD closure(s) the glue crate builds from the signer. - `send_payment` keeps only its `key_wallet::Signer`. Swift passes the two - handles it already holds — **no new Swift class, no new Swift crypto**. -- **Delete the dead `dash_sdk_dashpay_*` ClientSide FFI surface** - (`rs-sdk-ffi/src/dashpay/contact_request.rs`: the two entry points, - params/results, `DashSDKEcdhMode`, the four `*_with_{shared_secret,private_key}` - helpers) and regenerate the cbindgen header. Zero non-Rust callers; it is a - divergence-prone parallel orchestration of the same `rs-sdk` core, and its - `SdkSide` raw-scalar ABI contradicts the new posture. The `rs-sdk` - contact-request core + `EcdhProvider` stay (single source). - -**No-duplicate-logic trace:** every host method body is "derive scalar at a -Rust-built path (the shared `resolve_derived_xprv`, scrubbed by the `WipingXprv` -guard on every exit path) → call the existing `platform_encryption` / `dip14` -fn → return the result." Zero new crypto. Single sources stay: DIP-15 ECDH/AES → -`platform-encryption`; accountReference HMAC + masking → `dip14`; contact-request -orchestration → `rs-sdk platform::dashpay::contact_request`; contactInfo wire -codec → `crypto/contact_info.rs` (plaintext-only, runs outside the primitive). - -#### 4.5 raw-secret host primitives (option iii) + EcdhProvider collapse - -For #4–#7 the host primitive derives the key **and runs the crypto in -FFI-crate Rust**, returning only the result; the raw scalar never reaches -`rs-platform-wallet` or Swift. - -- **ECDH (#4/#5):** switch `sdk_writer.rs:240` from - `EcdhProvider::SdkSide { get_private_key }` to - `ClientSide { get_shared_secret }` backed by a closure calling - `MnemonicResolverCoreSigner::ecdh_shared_secret` (DONE; parity-pinned). - For all DashPay paths the model **collapses to `ClientSide` only**; delete - `derive_encryption_private_key` (`identity_handle.rs:476`) and - `SendContactRequestParams.ecdh_private_key` — the two places that - materialize the raw scalar in `rs-platform-wallet`. Shared secret MUST be - byte-identical to `platform_encryption::derive_shared_key_ecdh` - (`SHA256((0x02|y_parity)‖x)`); peer pubkey validated on-curve before ECDH. -- **accountReference (#6):** folded into `ecdh_shared_secret_and_account_reference` - / `unmask_account_reference` so the encryption scalar is used for both ECDH - and the `dip14` HMAC in one derivation and never returns raw. -- **contactInfo (#7):** `contact_info_seal` / `contact_info_open` derive the - two hardened-child AES keys and run encrypt/decrypt via `platform_encryption`, - returning only ciphertext/plaintext. The DIP-15 wire codec stays in - `crypto/contact_info.rs` (no key material). - -**Interop parity (required):** host-path accountReference, contactInfo blob, -and ECDH secret MUST equal the in-process results for the same seed+path — -each pinned by a test (DIP-15 interop vs dashj/dash-shared-core). - -#### 4.6 Seedless background-sync & the deferred-crypto queue (NEW — the core) - -The recurring sweep has no signer and no user present (verified: -`DashPaySyncManager` holds no signer; FFI `platform_wallet_sync_contact_requests` -/ `…_dashpay_sync_start` / `…_sync_now` take none). Design rule: every sweep -op is **public-derivable** or **deferred** — never resident-seed. - -**Persisted pending-crypto queue.** Add `pending_contact_crypto: -Vec` to `PlatformWalletChangeSet`, keyed per -`(owner_identity_id, contact_id)` with an op discriminant: - -``` -PendingContactCrypto { owner_identity_id, contact_id, - op: RegisterReceiving // our friendship xpub (2b, first-time only) - | RegisterExternal { encrypted_public_key, our_decryption_key_index, - contact_encryption_key_index } // #5 ECDH decrypt - | ContactInfoDecrypt, // #7 (idempotent re-fetch+decrypt) - enqueued_at_ms } -``` - -- Stores **only ciphertext + public key indices** (safe to persist). Rides the - existing `persister.store(...)` changeset pipeline (no side channel); - restored through `build_wallet_start_state`; a missing/old column restores as - an empty queue (skip-and-continue convention). -- **Persisted, not in-memory**, because restore-from-Keychain is exactly when - it's needed and the app may be killed between background discovery and the - next foreground unlock. - -**Enqueue (background, seedless).** In `build_contact_accounts` and -`sync_contact_infos`, when key material is unavailable (the `Unavailable` -classification, §4.7), **enqueue** instead of the current silent skip-and-log -/ retry-forever / channel-kill. Idempotent per `(owner, contact, op-kind)`. - -**Drain (foreground, signer present)** — no new signer plumbing into the -background manager: - -1. **On-unlock (primary):** a **new FFI** `platform_wallet_drain_pending_contact_crypto(wallet, core_signer)` - wired into the same Swift code path that previously called - `unlockWalletFromKeychain` — so deleting the re-attach and adding the drain - are one change. It runs each entry through the §4.5 host primitives, then - the public registration, and clears the entry. -2. **Opportunistic:** `send_payment` / `send_contact_request_with_external_signer` - / `accept_…` / `set_contact_info_with_external_signer` each drain queue - entries **for the identity they operate on** before their own work — so the - first user action on a contact resolves its deferred crypto (e.g. tapping - "Pay" on an inbound-only contact builds its `DashpayExternalAccount`). - -Drain is idempotent (each op re-checks its early-exit guard). While queued, the -sweep still does all PUBLIC work for the contact (ingest, profile, address -match, reconcile); the contact is visible but "needs unlock to finish setup." - -#### 4.7 Error classification: Transient / Unavailable / Permanent (must-fix) - -The live bug behind "is deferral safe": `build_contact_accounts` today maps an -ECDH/derive failure to `Permanent` → `mark_contact_channel_broken` -(irreversible, `warn!`-only). A **locked Keychain is transient** but would -permanently disable payments to a contact. Fix before any Phase-2 coding: - -- Introduce a **third** arm, `Unavailable` (signer/key material absent), in - `RegisterExternalError` (and the contactInfo path), **distinct** from - `Transient` (retry soon) and `Permanent` (malformed data → mark broken). -- `Unavailable` → **enqueue (§4.6) and pause this contact's build; never kill, - never churn-retry every 15s.** Only genuinely malformed inputs (bad - ciphertext, off-curve pubkey) are `Permanent`. -- **Fix the `is_seedless` gate** (`contact_requests.rs:904–921`): it currently - keys on `identity_index.is_none()`, which a seedless-but-indexed identity - passes — falling through to the channel-kill. Gate on "can I derive **right - now**?" (key material available), not "is this a wallet-owned identity?". -- Add a **needs-rebuild / needs-unlock marker** surfaced to the UI for - deferred contacts. - -#### 4.8 wrong-seed safety check (replaces the deleted dual gate) - -`attach_wallet_seed`'s dual id/xpub gate also verified the Keychain mnemonic -binds to the loaded wallet. Replace it with a **one-time xpub self-check** at -signer construction / first use: derive BIP44 account-0 xpub from the resolved -mnemonic and compare to the wallet's persisted account-0 xpub; mismatch fails -loud. **Caveat (security audit):** this catches a *wrong* seed but **not** a -*present-but-zero-keys* import (the open imported-identity bug, §7 -prerequisite). - -#### 4.9 delete the workaround — ORDERING CONSTRAINT - -Remove `attach_wallet_seed`, the FFI export, `unlockWalletFromKeychain`'s -re-attach (replaced by the §4.6 drain), the dual gate + `mem::swap`; rework -the test helpers to inject a test `Signer` / pre-seed the queue. Also delete -or make-throwing the legacy `KeychainSigner.sign(identityPublicKey:data:) --> Data?` swallow (returns reasonless `nil` on any failure). - -**Do NOT execute this deletion until the sweep is seedless-safe** (§4.6 queue -+ §4.7 classification landed and tested). Until then the resident seed is what -keeps the signerless sweep working; removing it first turns every background -tick on an unbuilt contact into an irreversible channel-kill. - -## 5. Failure modes - -- **Signer unavailable mid-sweep (ECDH/xpub build):** classified `Unavailable` - → enqueued + paused, **not** channel-killed, **not** retried every 15s - (§4.7). Resolves on next drain. -- **Keychain locked while the tokio sweep ticks:** the loop has no - scenePhase/BG gating; it MUST hit the `Unavailable`+enqueue path, never the - kill path. -- **Pending queue never drains:** mitigated by the on-unlock drain wired to the - old re-attach trigger + opportunistic drain on any signer-present action + - a UI "needs unlock" marker. Pinned by an on-device test. -- **Poison entry (permanently malformed ciphertext):** `Permanent` → mark - broken + clear entry (no requeue). Transient → keep for next drain. -- **Partial registration** (persist-ok / in-memory-insert-fail): unchanged - (store-before-insert; relaunch rebuilds) — but the rebuild must classify a - locked Keychain as `Unavailable`, not escalate. -- **Restore-from-Keychain / app-reinstall → zero signing keys:** the - imported-identity bug — **RESOLVED** by carry-scalar (the materialization path no - longer re-derives from a not-yet-stored mnemonic). Phase 2 only confirms imported - wallets reach the resolver for xpub/ECDH (mnemonic stored by `createWallet`). -- **Wrong/mis-mapped mnemonic:** §4.8 xpub self-check, fails loud. -- **Read-path:** converted readers propagate signer errors; never return - empty/zero/stale addresses. - -## 6. Test plan - -- **key-wallet:** `extended_public_key` on `InMemorySigner` matches - `derive_extended_public_key` (DONE). -- **platform-wallet:** test `Signer` replaces `attach_wallet_seed`; - signer-based tests for `send_payment` (DONE: interop-guard) + contact-request - create/accept (ECDH), contactInfo round-trip; **interop parity** tests - (accountReference, contactInfo, ECDH byte-equal to in-process). -- **Seedless sweep:** watch-only wallet + persisted xpubs → sweep does all - PUBLIC ops with no signer; `register_contact_account` early-exit no-op once - persisted. -- **Deferral queue:** background-discover inbound contact → `Unavailable` → - enqueue (not kill); drain on unlock → contact payable. A `Permanent` error - clears the entry AND sets `payment_channel_broken`. A locked-Keychain - (transient) does **not** mark broken. -- **Per-primitive no-residue:** each inherent host-primitive method wipes scalars - on Ok **and error/unwind** paths. -- **FFI:** input-validation (null/oversize/bad-path) incl. contactInfo depth-6 - paths (hardened 65536/65537). -- **Acceptance grep:** `git grep attach_wallet_seed` empty; no surviving - `derive_extended_private_key` / `build_signed(wallet` on DashPay paths. -- **On-device:** clean wipe → import → discover → send/accept contact request, - send payment, publish profile + contactInfo, **background-discover an - inbound contact then unlock → it becomes payable** — all with **no** - re-attach. - -## 7. Rollout order (revised) - -1. **Phase 1 (SHIPPED, green):** key-wallet method → host `extended_public_key` - primitive → `send_payment` conversion → delete dead read APIs. **Remaining - before merge:** the §4.2 error-path wipe hardening + iOS `build_ios.sh` - verification. -2. **Phase 2 prerequisites (design + fix BEFORE feature code):** - - §4.7 three-state error classification + `is_seedless` gate fix. - - §4.6 persisted pending-crypto queue + drain FFI. - - ~~Resolve the imported-identity zero-signing-keys bug~~ **RESOLVED** by the - carry-scalar change (commit `c567981c46`; on-device 23/23 signable, - regression tests green). No longer a blocker. Phase 2 need only confirm an - imported wallet reaches the resolver for xpub/ECDH (its mnemonic is stored in - the Keychain by `createWallet`, so the resolver-backed signer works for it). -3. **Phase 2 feature code:** inherent host primitives on - `MnemonicResolverCoreSigner` (ECDH + accountReference + contactInfo) → - `EcdhProvider` collapse to `ClientSide` → - convert #2/#2b/#4–#7 (each xpub bundled with its function's ECDH) → delete - the dead `dash_sdk_dashpay_*` surface → §4.8 self-check. -4. **Only then §4.9:** delete `attach_wallet_seed` + re-attach + legacy - `KeychainSigner.sign(...)->Data?`. -5. Build + clippy + tests + on-device acceptance after each phase. - -Cross-repo: the key-wallet edit (already landed) needed Claude Code run from -`/Users/ivanshumkov/Projects/dashpay/` (sibling-repo writes). FFI/Swift work -goes through the **swift-rust-ffi-engineer** agent. - -## 8. Alternatives rejected - -- **In-memory seed (dashj / dash-shared-core).** Reference clients hold the - decrypted seed in-session — no signer-based DashPay to copy. Rejected: - abandons the #3639 posture. -- **Phase-1-only (xpub/sign):** does not delete `attach_wallet_seed`. Adopted - *as Phase 1*, not the end state. -- **Keep the workaround:** rejected by product decision. -- **Unified single signer handle (doc + wallet + ECDH):** rejected — regresses - the doc-signer (seed never in Rust today) or pushes DIP-15 crypto into Swift. - Add the wallet-side raw-secret ops as inherent methods on the existing - signer, consumed via closures, instead (§4.4). -- **§4.5 option (i) (return raw scalar):** rejected for option (iii) — exposes - the ECDH key raw, defeating the hashing; the carry-scalar precedent - (write-once Rust→Swift) does not sanction the read-many reverse flow. -- **Skip-and-log deferral (current code):** rejected — silently strands - contacts and (worse) the `Permanent` path irreversibly kills channels. - Replaced by the §4.6 persisted queue + §4.7 classification. - -## 9. Security must-fixes from the multi-agent review (priority order) - -1. **[CRITICAL]** Three-state classification (`Unavailable` ≠ `Permanent`); - never auto-`mark_contact_channel_broken` on unavailable key material (§4.7). -2. **[CRITICAL]** Give the sweep a seedless-safe path: enqueue+defer, never - derive-or-die; do not delete `attach_wallet_seed` until this lands (§4.6, - §4.9 ordering). -3. **[CRITICAL]** Fix the `is_seedless` gate predicate — key-availability, not - `identity_index.is_none()` (§4.7). -4. **[RESOLVED]** Imported-identity zero-signing-keys bug — fixed by carry-scalar - (commit `c567981c46`), on-device-verified, regression tests green. No longer a - Phase-2 blocker; only confirm imported wallets reach the resolver for xpub/ECDH. - (Note: §4.8's xpub self-check still does not cover a present-but-zero-keys import, - but the carry-scalar fix means imports now materialize keys, so this is moot.) -5. **[HIGH]** Tighten §1 honest-scope wording (done) + fix the - `resolve_derived_xprv` error-/unwind-path scalar leak; prefer one RAII - wipe-guard over five hand-placed `non_secure_erase` calls (§4.2). -6. **[MEDIUM]** Delete / make-throwing the legacy `KeychainSigner.sign(...)->Data?` - nil-swallow (§4.9). -7. **[MEDIUM]** UI marker for contacts pending an unlock-drain (§4.6/§4.7). - -## 10. Resolved review questions - -- **key-wallet method:** provided-default-that-errors — §4.1. -- **raw-secret:** option (iii) via inherent host primitives on the wallet - signer, consumed by closures — §4.4/§4.5. -- **signer surface:** two FFI handles; raw-secret ops as inherent methods + - `EcdhProvider::ClientSide` closures (no new trait/crate) — §4.4. -- **dead `dash_sdk_dashpay_*` surface:** delete — §4.4. -- **read-API ripple:** dead → deleted — §2. -- **dual-gate deletion:** safe for grafting; wrong-seed detection preserved via - the §4.8 self-check (but not zero-keys — §7). -- **ECDH placement:** FFI-layer host primitive, not a key-wallet trait method — - §3/§4.5. -- **"defer site 2b":** safe **only** with §4.6 queue + §4.7 classification + - §4.9 ordering. Without them it is silent, irreversible channel corruption. -- **pre-check:** Swift uses `platform_wallet_send_contact_request_with_signer`; - the rs-sdk-ffi ClientSide surface is the reference template for the ECDH - switch and is deleted afterward. diff --git a/docs/dashpay/SPEC.md b/docs/dashpay/SPEC.md deleted file mode 100644 index 6306eec5f1f..00000000000 --- a/docs/dashpay/SPEC.md +++ /dev/null @@ -1,1362 +0,0 @@ -# DashPay — Implementation Spec & Gap Analysis - -> **Purpose.** A single working spec for getting the **full DashPay flow** — sync, -> create/update profile, send contact request, approve/reject contact requests, -> send money to a contact — *done and tested* in the **platform wallet** -> (`rs-platform-wallet` + FFI) and surfaced as a **nice UI** in the -> **SwiftExampleApp**. -> -> **Status (2026-06-10).** DashPay is **already ~80% implemented end-to-end.** This -> document maps the protocol, inventories what exists, isolates the gaps & bugs, -> and lays out the remaining work + test plan. It is *not* a greenfield design — -> it is a finish-and-polish plan. -> -> **Status update (2026-06-18) — the finish-and-polish work is essentially done -> on `feat/dashpay-m1-sync-correctness` (PR #3841).** Resolution of the Part-0 gap -> table: **G1, G2, G12, G13, G14, G15** (the P0 sync/wire/key-purpose blockers) and -> **G3, G6, G7, G8, G9, G10** — all **DONE** (M1–M4). **G5** reworked and shipped as -> a per-sender, reversible, **local-only Ignore** (Spec 2) across every layer incl. -> the SQLite persister; cross-device sync deferred to a future encrypted `profile` -> field (contract track — the `contactInfo` route was rejected for the R1 leak). -> **G11**: the `network/` layer now has unit coverage; the live cross-client e2e -> ride PR #3549 and stay blocked on devnet funding. **G4** (watch-only ECDH) is -> **deferred** with an amended design (needs xpub hooks, not just an ECDH hook). -> Three follow-on specs were written and **implemented** this pass: -> **`SYNC_CORRECTNESS_SPEC.md`** (Spec 0 — paginated/high-water sync + contact-profile -> cache + durable persistence), **`CONTACTINFO_FORMAT_SPEC.md`** (Spec 1 — privateData -> CBOR→DIP-15 varint), and Spec 2 (Ignore). Also resolved: `accountReference` -> byte-order (**keep ours** — recipient-ignored one-time pad, no interop break) and -> the friendship-path `account'` hardcode (fixed upstream in **rust-dashcore#813**, -> pulled in via the dashcore bump **PR #3936**). Remaining is all blocked on external -> resources (devnet funding for e2e/UAT; contract governance for cross-device ignore -> + DoS filter; an upstream rust-dashcore change for multi-account). See -> the DashPay backlog issue [#4020](https://github.com/dashpay/platform/issues/4020) for the authoritative item-by-item status. -> -> **How to read.** Part 0 is the TL;DR. Parts 1–2 are reference (protocol + -> architecture). Part 3 is the current-state inventory. Part 4 is the prioritized -> gap/bug list. Part 5 is the work plan. Part 6 is the Swift UI design. Part 7 is -> the test plan. The durable evidence base ships alongside this spec: -> [`INTEROP_DESK_CHECK.md`](./INTEROP_DESK_CHECK.md) (cross-client wire-format -> verdicts + testnet census) and [`CONTACTINFO_FORMAT_SPEC.md`](./CONTACTINFO_FORMAT_SPEC.md) -> (Appendix A). The transient working-research files that once backed the -> remaining citations were trimmed from the tree; they remain in this branch's -> git history (`docs/dashpay/research/`, up to the trim commit). - ---- - -## Part 0 — Executive summary - -### What works today (end-to-end, real broadcast) - -- **Profile**: create / update / fetch / sync — `rs-platform-wallet` - (`network/profile.rs`), FFI, and Swift `DashPayProfileEditorView` are all wired - and broadcast real document state transitions. -- **Send contact request**: builds the `contactRequest` doc, ECDH + AES-256-CBC - encrypts the receiving xpub (via `dash-sdk` → `platform-encryption`), signs, - broadcasts. Wrapped in Swift (`AddFriendView`). ⚠ **Correction (2026-06-10): - "works" was overstated — the G2 entropy bug meant every broadcast through this - path was rejected by consensus until the M1 task-4 fix. The code path existed; - it did not function. (Exactly what G11's zero-network-tests predicted.)** -- **Accept contact request**: sends the reciprocal request, decrypts the - contact's xpub, registers a watch-only sending account. Wired in Swift - (`ContactRequestRow` Accept). -- **Send money to a contact**: derives the next contact address, builds + signs + - broadcasts an L1 tx, records the payment. Wired in Swift - (`SendDashPayPaymentSheet`). -- **DIP-14 / DIP-15 derivation**: 256-bit non-hardened child derivation, the - `m/9'/5'/15'/0'//` friendship path, the two account - types (`DashpayReceivingFunds`, `DashpayExternalAccount`), gap limit 20 — all - implemented and test-vector-pinned in `rust-dashcore/key-wallet`. -- **Persistence**: contacts / profiles / payments round-trip through the - changeset → SQLite pipeline. SwiftData mirror models exist. -- **Swift FFI coverage**: all ~14 DashPay/DPNS FFI functions are already wrapped - on `ManagedPlatformWallet`. - -### The gaps that block a *complete, correct* flow (detail in Part 4) - -| # | Gap | Severity | Layer | -|---|-----|----------|-------| -| G1 | **Sync never builds sending accounts** — a contact who accepts you *while you're offline* has no spendable account after sync; `send_payment` fails until `register_external_contact_account` is manually called | **P0** | `rs-platform-wallet` | -| G2 | **`send_contact_request` entropy mismatch** — document-ID entropy diverges from the broadcast entropy (`rs-sdk` admits the "simplification"); severity needs verification against `PutDocument` | **P0** | `rs-sdk` | -| G3 | **`accountReference` hardcoded to 0** — DIP-15 masking unused; the unique index `(ownerId,toUserId,accountReference)` makes key rotation / re-send impossible | **P1** | `rs-platform-wallet` | -| G4 | **Watch-only wallets can't send/accept** — ECDH is derived from the in-process seed; only `EcdhProvider::SdkSide` is used, the `ClientSide` push-across-FFI path is unbuilt | **P1** | wallet + FFI | -| G5 | **Reject is local-only** — no tombstone at all (recurring sync would resurrect rejects — stage-1 fix in **M1**) and no `contactInfo` `displayHidden` doc (cross-device — stage 2 in **M3**) | **P1** | wallet + SDK | -| G6 | **Wrong fallback contract ID** — `rs-sdk` `#[cfg(not(feature="dashpay-contract"))]` path hardcodes the **DPNS** id (dead code in default builds, latent bug) | **P2** | `rs-sdk` | -| G7 | **Dead code**: `calculate_account_reference`, `validate_contact_request`, auto-accept proof gen/verify — implemented + tested but never called by live paths | **P2** | `rs-platform-wallet` | -| G8 | **Local sent-request placeholder** — stores `vec![0u8;96]` for `encrypted_public_key` instead of the real ciphertext | **P2** | `rs-platform-wallet` | -| G9 | **No contract cache** — the bundled system contract is re-loaded on every op | **P2** | `rs-platform-wallet` | -| G10 | **No `contactInfo` support** — alias/note/hidden private metadata never syncs across devices | **P2** | wallet + SDK | -| G11 | **Network layer is untested.** Primitives/state/persistence are well covered, but the *whole* `network/` layer (send/sync/accept/pay/profile-broadcast) has **0 tests**; no full send→sync→accept→pay integration test; Swift has **0** DashPay tests | **P0** | both | -| G12 | **DashPay sync is not in the recurring sync loop.** The background `IdentitySyncManager` syncs **token balances only**; `dashpay_sync()` (contact requests + profiles) runs **only on-demand via FFI** — it must be folded into the recurring loop alongside the other syncs | **P0** | `rs-platform-wallet` | -| G13 | **Sync never reconciles own sent requests** — after restore-from-seed or on a second device an established contact renders as a mere incoming request; Accept re-broadcasts a duplicate reciprocal and is **rejected forever** by the unique index | **P1** | `rs-platform-wallet` | -| G14 | **Wrong encrypted-xpub wire format** (desk-check 2026-06-10, `INTEROP_DESK_CHECK.md`): we encrypt the 107-byte DIP-14 `ExtendedPubKey::encode()` instead of DIP-15's **69-byte compact** (`fingerprint‖chaincode‖pubkey`) used by iOS+Android → our send fails its own 96-byte check; our receive can't parse mobile payloads | **P0** | `platform-encryption` + `rs-sdk` + wallet | -| G15 | **Key-purpose convention mismatch**: mobile clients use key 0 (AUTHENTICATION) for both key indices; our send/validation require ENCRYPTION/DECRYPTION-purpose keys → cross-client requests blocked both directions. Verify against a real testnet mobile contactRequest, then align | **P1** | wallet + `rs-sdk` | - -### UI verdict - -The Swift DashPay UI exists but is **buried** (Identities → IdentityDetail → -`Section("DashPay")`) and **utilitarian**. The plan (Part 6) **promotes it to a -first-class `DashPay` tab**, renders the missing outgoing-requests section, moves -lists onto reactive `@Query`, and polishes styling (AsyncImage avatars, empty -states, toasts). No new happy-path FFI is required. - -### Recommended sequencing - -**Milestone 1 (correctness)**: G11-seam, G12, G1+G13 (+G5 tombstone), G2, interop -desk-check, G11-Rust. → the offline-accept→pay path works, is integration-tested, -and the background sync is wired. -**Milestone 2 (UI)**: first-class DashPay tab + polish + Swift tests (Part 6/7). -**Milestone 3 (spec-completeness)**: G3, G5, G10 (accountReference + contactInfo -for rotation/hide/alias sync). -**Milestone 4 (hardening)**: G4 (watch-only ECDH), G6–G9 cleanup. -**Milestone 5 (invitations)**: asset-lock voucher + claim + auto-accept wiring -(new scope 2026-06-10; design pass first). - ---- - -## Part 1 — What DashPay is, and the layered stack - -**DashPay** (DIP-0015) is a Dash Platform application that creates *bidirectional -direct settlement payment channels* between two Dash **identities**. User-facing -model: - -- **Username** → a **DPNS** name (DIP-0012) resolving to an identity. DashPay - itself never stores usernames; it references identities by their 32-byte id. -- **Identity** (DIP-0011) → the cryptographic actor; holds keys + credit balance; - signs all state transitions. -- **Profile** → public presentation (`displayName`, `publicMessage`, avatar). -- **Contact / friend** → an identity you have exchanged `contactRequest` - documents with **in both directions**. -- **Pay a contact** → decrypt the xpub from *their* contactRequest addressed to - you, derive the next L1 address, and pay it with an ordinary Dash transaction. - DashPay is the *key-sharing / coordination* layer; value transfer is plain L1. - -### The implementation stack (bottom → top) - -``` -┌──────────────────────────────────────────────────────────────────────────┐ -│ SwiftExampleApp Views: FriendsView, IdentityDetailView (profile), │ -│ (packages/swift-sdk/ SendDashPayPaymentSheet, AddFriendView [Part 6] │ -│ SwiftExampleApp) State: PlatformWalletManager, AppState, SwiftData │ -├──────────────────────────────────────────────────────────────────────────┤ -│ swift-sdk wrappers ManagedPlatformWallet.{sync,send,accept,reject,pay, │ -│ (Sources/SwiftDashSDK) profile…}, ContactRequest, EstablishedContact, │ -│ DashPayProfile, KeychainSigner (thin, marshal-only)│ -├──────────────────────────────────────────────────────────────────────────┤ -│ rs-platform-wallet-ffi C ABI: platform_wallet_{sync_contact_requests, │ -│ send_contact_request_with_signer, accept…, reject…, │ -│ send_dashpay_payment, *_dashpay_profile_with_signer} │ -├──────────────────────────────────────────────────────────────────────────┤ -│ rs-platform-wallet IdentityWallet (network façade): contact_requests.rs,│ -│ (the brains) contacts.rs, payments.rs, profile.rs, dashpay_sync.rs│ -│ ManagedIdentity state; crypto/{dip14,validation,…} │ -├───────────────────────────────┬──────────────────────────────────────────┤ -│ rs-sdk (dash-sdk) │ platform-encryption │ -│ dashpay/contact_request.rs: │ derive_shared_key_ecdh (libsecp256k1 ECDH),│ -│ create/send_contact_request, │ encrypt/decrypt_extended_public_key │ -│ EcdhProvider, queries │ (AES-256-CBC + PKCS7), account-label crypto │ -├───────────────────────────────┴──────────────────────────────────────────┤ -│ rust-dashcore/key-wallet DIP-9 paths, DIP-14 256-bit CKD, AccountType │ -│ (HD wallet primitives) ::Dashpay{ReceivingFunds,ExternalAccount}, │ -│ managed accounts, gap limit 20, tx checking │ -├──────────────────────────────────────────────────────────────────────────┤ -│ dashpay-contract v1 schema: profile / contactRequest / │ -│ contactInfo; id Bwr4WHCP…NS1C7 │ -└──────────────────────────────────────────────────────────────────────────┘ -``` - -Key architectural facts: -- **ECDH + AES live in `platform-encryption`**, *not* in `key-wallet` - (rust-dashcore has **zero** ECDH code). The wallet calls `dash-sdk`'s - `send_contact_request`, which calls `platform-encryption` internally; the - receive/decrypt path calls `platform-encryption` directly. -- **`key-wallet` only consumes an already-decrypted friend xpub** - (`wallet_add_dashpay_external_account_with_xpub_bytes`). -- **The DashPay system contract is bundled** (`load_system_data_contract`), no - network fetch needed. -- **Swift never orchestrates** — every multi-step DashPay op is *one* - `platform-wallet` FFI call (per `swift-sdk/CLAUDE.md`). - ---- - -## Part 2 — Protocol reference (authoritative numbers) - -Condensed from the DIPs themselves (DIP-9/11/13/14/15, -cross-checked against the deployed v1 contract). **Where DIP prose and the v1 -schema disagree, the schema wins.** - -### 2.1 Friendship lifecycle - -- A `contactRequest{ $ownerId: sender, toUserId: recipient }` is **one-directional**. -- **Established contact (DIP-15: "friendship") = both directions exist** (A→B *and* - B→A). One request = "pending"; the reciprocal = "accept". -- **To pay X**, read **X's** request addressed to you (`$ownerId==X`, - `toUserId==you`), decrypt its `encryptedPublicKey`, derive addresses. -- Contact requests are **immutable & non-deletable** (`documentsMutable:false`, - `canBeDeleted:false`). Key rotation = a **new** request with a bumped - `accountReference` version. - -### 2.2 Friendship derivation path (DIP-15 + DIP-14) - -``` -m / 9' / 5' / 15' / 0' / / / index - └─────── hardened ──────┘ └── non-hardened 256-bit (DIP-14) ──┘ └ non-hardened u32 -``` - -- `9'`=feature purpose, `5'`=Dash (`1'` testnet), `15'`=DashPay, `0'`=account. -- The two 256-bit levels are the raw 32-byte identity ids (owner first). **Must** - stay non-hardened (so a watch-only xpub at `…/0'` covers all contacts) and full - 256-bit (truncating to 31 bits is a DIP-14 security violation). -- Auto-accept proof keys use a **separate** path `m/9'/5'/16'/'`. - -### 2.3 ECDH shared secret - -libsecp256k1 ECDH (**not** raw X-coord): `sharedKey = SHA256( ((y[31]&1)|2) || x )` -of the shared point `d_self · Q_other`. The participating identity keys are -selected by `senderKeyIndex` / `recipientKeyIndex` (identity public-key `id`s, -encryption/decryption purpose). Both parties derive the identical 32-byte key → -the AES-256 key. - -### 2.4 Encryption layout - -- Plaintext = **compact** xpub `parentFingerprint(4) || chainCode(32) || - pubKey(33)` = **69 bytes** (not the 78-byte BIP32 xpub). *(Implementation note — - corrected by the 2026-06-10 desk-check: our stack actually fed - `ExtendedPubKey::encode()` in, which for the DashPay path is the **107-byte** - DIP-14 form → 128-byte ciphertext → our own send path failed. See **G14**; - reference clients confirm the 69-byte compact form.)* -- `encryptedPublicKey` = `IV(16) || AES-256-CBC-PKCS7(80)` = **exactly 96 bytes**. -- `encryptedAccountLabel` = `IV(16) || ciphertext(32–64)` = **48–80 bytes**. -- `contactInfo.privateData` uses **BIP32-derived** symmetric keys (self-encrypt), - not ECDH; `encToUserId` uses AES-ECB. - -### 2.5 `accountReference` - -``` -ASK = HMAC-SHA256(senderSecretKey, extendedPublicKey) -AccountRef = (Version << 28) | (ASK[28 msb] XOR (Account & 0x0FFFFFFF)) -``` -Top 4 bits = version (rotation signal), low 28 bits = account number masked by a -PRF of the xpub. Uniqueness not required. The recipient un-masks the account and -reads the version. - -### 2.6 DashPay v1 contract document types - -Contract id **`Bwr4WHCPz5rFVAD87RqTs3izo4zpzwsEdKPWUT1NS1C7`** -(hex `a2a1…71bc`), owner all-zero. Full field/index tables in the deployed -schema, `packages/dashpay-contract/schema/v1/dashpay.schema.json`. Summary: - -- **`profile`**: `avatarUrl`(uri,≤2048), `avatarHash`(32B), `avatarFingerprint`(8B), - `publicMessage`(1–140), `displayName`(1–25). Avatar trio is `dependentRequired`. - Unique index `$ownerId`; non-unique `$ownerId+$updatedAt`. Mutable. -- **`contactRequest`**: `toUserId`(32B id), `encryptedPublicKey`(**exactly 96B**), - `senderKeyIndex`, `recipientKeyIndex`, `accountReference`, optional - `encryptedAccountLabel`(48–80B), optional `autoAcceptProof`(38–102B, unencrypted). - Required system fields incl. `$createdAtCoreBlockHeight`. Unique index - `$ownerId+toUserId+accountReference`; timelines `toUserId+$createdAt` (received) - and `$ownerId+$createdAt` (sent). Immutable. -- **`contactInfo`**: `encToUserId`(32B), `rootEncryptionKeyIndex`, - `derivationEncryptionKeyIndex`, `privateData`(48–2048B encrypted; **DIP-15 - varint** `version`/`aliasName`/`note`/`displayHidden`/`acceptedAccounts` — - contract enforces length only, see `CONTACTINFO_FORMAT_SPEC.md`). Unique index - `$ownerId+root+derivation`. Privacy rule: don't publish until ≥2 established - contacts. - ---- - -## Part 3 — Current implementation state (inventory) - -Master status matrix. **Legend:** ✅ implemented · 🟡 partial/caveated · ❌ missing. -Citations are abbreviated (file:line against this branch). - -### 3.1 rust-dashcore `key-wallet` (HD primitives) - -| Capability | Status | Evidence | -|---|---|---| -| DIP-9 DashPay root `m/9'/5'/15'` (`/1'` testnet) | ✅ | `key-wallet/src/dip9.rs:167-198` | -| DIP-14 256-bit non-hardened CKD (priv+pub) | ✅ | `bip32.rs:575-598,1533-1589,1817+`; vectors `:2521-2594` | -| `AccountType::DashpayReceivingFunds` / `DashpayExternalAccount` | ✅ | `account/account_type.rs:76-95,469-514` | -| Managed accounts, single pool, **gap limit 20** | ✅ | `managed_account_type.rs:97-118,706-749` | -| Tx checking routes contact funds | ✅ | `transaction_checking/account_checker.rs:501-518` | -| FFI: add receiving / add external(xpub) / get | ✅ | `key-wallet-ffi/src/wallet.rs:397,451`, `managed_account.rs:436,497` | -| ECDH / shared secret / xpub encryption | ❌ (by design — lives in `platform-encryption`) | repo-wide grep: none | -| Auto-create DashPay accounts at wallet init | ❌ (per-contact, after the fact) | `wallet/initialization.rs` | -| Match result carries identity ids | 🟡 (only `account_index`; reverse-lookup needed) | `account_checker.rs:144-153` | - -### 3.2 `platform-encryption` + `rs-sdk` (crypto + send flow) - -| Capability | Status | Evidence | -|---|---|---| -| `derive_shared_key_ecdh` (libsecp256k1) | ✅ | `rs-platform-encryption/src/lib.rs:24-34` | -| `encrypt/decrypt_extended_public_key` (AES-256-CBC, IV-prepend, 96B) | ✅ | `lib.rs:97-128` | -| `encrypt/decrypt_account_label` (48–80B) | ✅ | `lib.rs:139-171` | -| `Sdk::create_contact_request` / `send_contact_request` | ✅ | `rs-sdk/src/platform/dashpay/contact_request.rs:164,378` | -| `EcdhProvider::{ClientSide, SdkSide}` | ✅ (both defined; only SdkSide used upstream) | `contact_request.rs:31-54` | -| Queries: sent / received / all contact requests | ✅ | `contact_request_queries.rs:33,76` | -| SDK helpers for `profile` / `contactInfo` | ❌ (done via generic `Document`+`PutDocument`) | — | -| **Bug: send entropy ≠ doc-id entropy** | 🟡 **G2** | `contact_request.rs:431-435` (code comment admits it) | -| **Bug: fallback contract id = DPNS id** | 🟡 **G6** (dead in default build) | `dashpay/mod.rs:33` | - -### 3.3 `rs-platform-wallet` (+ FFI, storage) — the brains - -| Flow | Status | Evidence | -|---|---|---| -| Identity ↔ wallet (managed identities) | ✅ | `state/managed_identity/mod.rs:37`, `network/identity_handle.rs:256` | -| Profile fetch / sync | ✅ | `network/profile.rs:64,145` | -| Profile create / update (external signer) | ✅ | `network/profile.rs:240,395` | -| `dashpay_sync` aggregator | ✅ | `network/dashpay_sync.rs:16` | -| Sync received contact requests | 🟡 **G1** (ingest guard drops reciprocals; no xpub-decrypt / no external account built) | `network/contact_requests.rs:322,367-372` | -| Sync own sent requests (restore/multi-device reconcile) | ❌ **G13** | sync calls `fetch_received_contact_requests` only | -| Send contact request (seed-in-process) | ✅ / 🟡 **G4** | `network/contact_requests.rs:91` | -| Accept (reciprocal send + register external account) | ✅ | `network/contact_requests.rs:466` | -| Reject | 🟡 **G5** (local-only) | `network/contact_requests.rs:678` | -| Auto-establish on reciprocal match | ✅ | `state/managed_identity/contact_requests.rs` | -| Register receiving / external account | ✅ (UAT 2026-06-12: now also **persisted** — registrations were in-memory only, so accounts vanished on relaunch and restored friendship UTXOs were dropped `dropped_no_account`) | `network/contacts.rs` | -| Send money to contact | ✅ | `network/payments.rs:93` | -| Record incoming payment | ✅ (UAT 2026-06-12: the old `try_record_incoming_payment` had **zero callers** — receiver history was always empty. Replaced by live recording in the wallet-event adapter + an idempotent `reconcile_incoming_payments` step in the recurring sync) | `network/payments.rs`, `changeset/core_bridge.rs` | -| Crypto: DIP-14 xpub / payment addrs | ✅ | `crypto/dip14.rs` | -| Crypto: `accountReference` | 🟡 **G3/G7** (correct but unused; send hardcodes 0) | `crypto/dip14.rs:147` | -| Crypto: auto-accept proof | 🟡 **G7** (dead code, `// TODO` at `auto_accept.rs:39`) | `crypto/auto_accept.rs` | -| Pre-send validation | 🟡 **G7** (never called) | `crypto/validation.rs:76` | -| Persistence round-trip | ✅ | `wallet/apply.rs`, storage `schema/{contacts,dashpay}.rs` | -| Local placeholder `encrypted_public_key` | 🟡 **G8** (`vec![0u8;96]`) | `network/contact_requests.rs:283` | -| Contract cache | 🟡 **G9** (re-load per call) | `network/profile.rs:83` | -| FFI surface (sync/send/accept/reject/pay/profile) | ✅ | `ffi/src/{dashpay,dashpay_profile,contact_request,established_contact,contact}.rs` | - -> **No `todo!()`/`unimplemented!()`/`unreachable!()` anywhere in DashPay paths** — -> all gaps are caveats, dead helpers, or local-only fallbacks, not panics. - -### 3.4 SwiftExampleApp + swift-sdk - -| Capability | Status | Evidence | -|---|---|---| -| All ~14 DashPay/DPNS FFI functions wrapped | ✅ | `Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift:1452-1779` | -| Wrapper objects: `ContactRequest`, `EstablishedContact`, `DashPayProfile` | ✅ | same dir | -| SwiftData mirrors: `PersistentDashpayProfile`, `PersistentDashpayContactRequest` | ✅ | `Persistence/Models/` | -| Contacts list + incoming requests + accept/reject | ✅ (utilitarian) | `Views/FriendsView.swift` | -| Add friend by DPNS name / identity id | ✅ | `FriendsView.swift` (`AddFriendView`) | -| Send money to contact sheet | ✅ (most polished) | `FriendsView.swift` (`SendDashPayPaymentSheet`) | -| Profile view / editor (DIP-15 avatar hashing) | ✅ | `Views/IdentityDetailView.swift:332,1169` | -| First-class DashPay tab | ❌ **(Part 6)** buried under Identities | `ContentView.swift` | -| Outgoing requests rendered | ❌ (loaded, not shown) | `FriendsView.swift` | -| Lists driven by reactive `@Query` | ❌ (reads live Rust snapshot) | `FriendsView.swift` | -| DashPay tests (unit / XCUITest) | ❌ **G11** | `SwiftTests/`, `SwiftExampleAppUITests/` | - ---- - -## Part 4 — Gap analysis & bugs (prioritized) - -### P0 — blocks a correct, complete flow - -**G1 — Sync cannot establish contacts, and never builds sending accounts.** -Two compounding defects in `sync_contact_requests` (`network/contact_requests.rs:322`): -(1) **the ingest guard drops reciprocal requests** — any received doc whose sender is -already in `sent_contact_requests` is skipped (`:367-372`), so in the offline-accept -scenario the reciprocal request never reaches `add_incoming_contact_request` (the -only auto-establish trigger) and the contact stays pending-sent forever; (2) even -for contacts that do establish, sync stores the *encrypted* `encryptedPublicKey` and -never decrypts it or registers a `DashpayExternalAccount` — only the explicit -**accept** path does. **Consequence:** "they accepted me → I sync → I pay them" -fails twice over: the contact never establishes via sync, and `send_payment` -(`network/payments.rs:135`) has no sending account. **Fix:** (a) relax the ingest -guard so a received doc whose sender matches a `sent_contact_requests` entry flows -into `add_incoming_contact_request` (which auto-establishes and collapses the -pending entries); (b) on every sync pass, for **every established contact missing an -external account** (not only newly-established ones — this also repairs contacts -left unpayable by the accept path's best-effort registration), validate the -request's key indices via `validate_contact_request` (purpose ENCRYPTION/DECRYPTION -+ ECDSA key type — never ECDH against an unvalidated index; an attacker-crafted -index pointing at an AUTHENTICATION key silently derives a wrong shared secret and -poisons the account), then decrypt the xpub and register the account — and likewise -register a missing **`DashpayReceivingFunds`** account (derivable from the wallet's -own seed, no decryption needed): it is what makes *incoming* contact payments -visible to SPV, its only creation point today is the fresh-send path -(`contact_requests.rs:300`), and after restore-from-seed nothing rebuilds it — -incoming payments would land on unwatched addresses; (c) **failure -policy** — distinguish transient failures (network: retry next sweep) from permanent -ones (decrypt/decode failure: mark the contact "payment channel broken", surface to -FFI/UI, skip until the request changes — no unbounded retry). Must be seed-aware -(skip + log for watch-only until G4). - -**G2 — `send_contact_request` entropy mismatch.** -`rs-sdk/.../contact_request.rs:431-435`: `create_contact_request` computes the -document id from entropy E1, but `send_contact_request` generates *fresh* entropy -E2 for `put_to_platform_and_wait_for_response`. The code comment admits the -"simplification". **Action:** verify whether `PutDocument` re-derives the id from -E2 (in which case the returned `ContactRequestResult.id` is merely *stale*, a -correctness wart) or whether the broadcast actually fails / duplicates. Thread the -*same* entropy through both. Pin with a test asserting `result.id == on-platform id`. - -**G11 — Test coverage (precise breakdown).** -What's **well covered** (≈60 unit tests): crypto (`crypto/dip14.rs` ×10, -`validation.rs` ×8, `auto_accept.rs` ×6), the contact state machine -(`state/managed_identity/contact_requests.rs` ×12, `mod.rs` ×8), the DashPay types -(`established_contact`/`profile`/`contact_request`/`payment`), and persistence -(`wallet/apply.rs` ×26). Plus `tests/contact_workflow_tests.rs` (8 tests) — but -those are **pure in-memory handshake** tests using `noop_persister()` and **fake -identities** (`data: vec![1u8;33]`, not real keys), so they exercise the state -machine, *not* real ECDH/derivation/broadcast. - -What's **completely untested**: the entire **`network/` layer** — the actual -broadcast/sync/pay paths. `grep #[test] src/wallet/identity/network/` → only -`registration.rs` has one. **Zero** tests for -`send_contact_request_with_external_signer`, `sync_contact_requests`, -`sync_profiles`, `accept_contact_request_with_external_signer`, -`register_external_contact_account`, `send_payment`, `create/update_profile`. And -**zero** DashPay tests in Swift. This is a quality-P0: the flow cannot be declared -"done and tested" without (a) network-layer tests via a mock SDK/broadcaster seam, -and (b) a real devnet/regtest end-to-end test. (Test plan: Part 7.) - -**G12 — DashPay sync is not in the recurring sync loop.** -The background `IdentitySyncManager` (`manager/identity_sync.rs`, owned by -`PlatformWalletManager` at `manager/mod.rs:54,139`, run as a cancel-token loop with -a configurable interval and a re-entrancy guard) syncs **token balances only**. -`dashpay_sync()` (= `sync_contact_requests()` + `sync_profiles()`) is invoked -**only on demand via FFI** — i.e. the Swift app must poll it. There is **no -recurring DashPay refresh**. **Fix (the constraints matter more than the -placement):** `dashpay_sync()` is a method on `IdentityWallet` (needs the -wallet-manager lock, per-wallet persister, broadcaster), while `IdentitySyncManager` -is deliberately self-contained (constructed with sdk + persister only; documented as -not reaching into PlatformWallet/WalletManager) and its registry **skips identities -with empty token lists** — so the recurring DashPay pass must **NOT** be driven "per -registered identity" off the token registry (a DashPay-only identity with no watched -tokens would never sync). Instead, inject the wallets map (the same -`Arc>>>` that -`PlatformAddressSyncManager` already receives at `manager/mod.rs:135-138`; snapshot -the wallet `Arc`s under a read guard per sweep) and iterate wallets calling -`wallet.identity().dashpay_sync()` per pass, reusing the existing -cadence/cancel/quiesce/re-entrancy machinery. Whether this lives inside -`IdentitySyncManager` or as a sibling `DashPaySyncManager` is an implementation -detail; coupling DashPay sync to the token registry is the failure mode to avoid. -**Error semantics:** log-and-continue per wallet/identity (matching the existing -loop's contract) — never fail-fast across identities. Note: wrapping `dashpay_sync` -alone only delivers per-*wallet* continue — `sync_contact_requests` currently -`?`-aborts its multi-identity loop on the first fetch error and `dashpay_sync` -propagates immediately, so the per-identity policy must be implemented *inside* -those loops. This recurring pass is the -natural home for the **G1** establish/decrypt/register sweep, the **G13** sent-side -reconcile, and the **G5** tombstone check. Keep the on-demand FFI entry points for -pull-to-refresh. - -### P1 — spec-correctness / production-readiness - -**G3 — `accountReference` hardcoded to 0.** The send path uses -`account_reference = 0` instead of `calculate_account_reference(...)` -(`crypto/dip14.rs:147`). Because the unique index is -`(ownerId, toUserId, accountReference)`, a second request to the same recipient -(key rotation, multi-account) **collides** and is rejected by Platform. Today this -"works" only because the full account xpub is shared directly (recipient decrypts -it and doesn't need to un-mask the account). **Fix:** wire -`calculate_account_reference` into the send path; add the version-bump path for -rotation; have the receive path tolerate / surface non-zero versions (DIP-15 §7.3 -"sender rotated their addresses" notification). **Receive-side scope note (budget -into M3):** the in-memory maps, changeset keys, and SQLite contacts schema are -keyed by counterparty id alone, and the sync ingest guard skips requests from -already-established contacts — surfacing rotation requires re-keying -contact-request state + persistence by `(counterparty, accountReference)` and -letting rotation requests from established contacts through the guard. Without -this, `dp_005`'s "receive path surfaces the rotation" assertion is unimplementable. - -**G4 — Watch-only wallets can't send/accept.** ECDH is derived from the -in-process seed (`identity_handle.rs:424`); only `EcdhProvider::SdkSide` is used. -For hardware/watch-only wallets, the `ClientSide` path (host supplies the shared -secret) must be pushed across the FFI. **Fix:** add an FFI/signer hook that returns -the ECDH shared secret for `(senderKeyIndex, recipientPubKey)` from the secure -element, and route `send_contact_request` / `register_external_contact_account` -through `EcdhProvider::ClientSide`. *(The example app holds the seed, so this is -not a demo blocker — but it is the right architecture.)* **FFI-hook design lands in -M3** (design-only task) so the wallet API doesn't churn in M4: the hook accepts -only the 32-byte ECDH **shared secret** from the host — never the sender's identity -private key across the ABI (the existing `rs-sdk-ffi` -`DashSDKContactRequestParams.sender_private_key` field is the antipattern to avoid, -and worth auditing in its own right). - -**G5 — Reject is local-only, and the recurring loop will undo it.** -`reject_contact_request` (`network/contact_requests.rs:678`, `// TODO` at `:703`) -drops the local entry but writes no tombstone of any kind — and the still-on-platform -immutable document is re-ingested as a fresh incoming request on the next sync. -Today this is masked because sync is on-demand only; **the moment G12 lands, every -background sweep resurrects every rejected request on the same device.** **Fix (two -stages):** **M1 (with G12):** a locally-persisted rejected-request tombstone -consulted by the sync ingest path — keyed by **document id (or -`(sender, accountReference)`)**, NOT bare sender id: requests are immutable, so the -only legitimate way a once-rejected sender can ever re-request is a new doc with a -bumped `accountReference` (the rotation mechanism), and a sender-keyed tombstone -would silently block that forever with no un-reject affordance. Pinning test: -"rejected request does not reappear after a recurring re-sync — and a -bumped-`accountReference` request from the same sender *does*". **M3:** the -on-platform `contactInfo` `displayHidden` write (see G10) for cross-device sync. - -**G13 — Sync never reconciles your own sent requests.** Sync only calls -`fetch_received_contact_requests`; the identity's own sent requests are never -fetched into state (the `fetch_sent_contact_requests` query exists but is -read-only). After restore-from-seed or on a second device, a mutually-established -contact renders as a mere incoming request; tapping Accept re-broadcasts a duplicate -reciprocal with the same `(ownerId, toUserId, accountReference)` triple, which -Platform rejects on the unique index — **Accept fails forever with no recovery -path**. **Fix (M1):** sync also fetches the identity's own sent contactRequest -documents and ingests them via `add_sent_contact_request` (auto-establish fires when -both sides are present) — **with a sent-side ingest guard symmetric to the received -side**: skip docs whose recipient is already in `sent_contact_requests` or -`established_contacts` (`add_sent_contact_request` has no such guard today, so an -unguarded recurring re-ingest creates phantom pending-sent rows + a changeset write -per contact per sweep). Any (re-)establish path must **merge into an existing -`EstablishedContact`** — `EstablishedContact::new` resets alias/note/is_hidden/ -accepted_accounts, so naive re-establish wipes user metadata every sweep. Accept -detects an existing on-platform reciprocal and adopts it instead of re-broadcasting; -"adopt" includes the local registrations a fresh send performs (receiving-account -registration, G1(b)) and runs the same `validate_contact_request` gate before any -`register_external_contact_account` call — the gate applies to **all three paths** -(sync sweep, normal Accept, Accept-adopt). *Pin:* "established contact is stable -and metadata-preserving across two recurring sweeps". - -**G14 — Wrong encrypted-xpub wire format (P0; found by the M1 desk-check, -`INTEROP_DESK_CHECK.md`).** DIP-15 and BOTH reference clients -(iOS dash-shared-core `ecdsa_key.rs:333-341`, Android dashj -`serializeContactPub()` with a hard `len == 69` receive check) use the compact -**69-byte** plaintext `parentFingerprint(4) ‖ chainCode(32) ‖ pubKey(33)`. Our -stack fed `ExtendedPubKey::encode()` into `encrypt_extended_public_key` — and the -DashPay account xpub ends in a `Normal256` child, so that's the **107-byte -DIP-14** serialization → 128-byte ciphertext → fails our own `== 96` assertion -and the contract's `maxItems: 96`. **Consequence:** our send path errored before -broadcast (nothing nonconforming reached chain — blast radius ≈ zero), and our -receive (`ExtendedPubKey::decode`, 78/107 only) rejects every mobile payload and -would mark the channel permanently broken. **Fix (M1 task 7):** compact 69-byte -assembly on send + compact parser on receive (path context reconstructs -depth/child-number); byte-exact vectors from the reference clients. - -**G15 — Key-purpose convention mismatch (P1; same desk-check).** Mobile clients -populate `senderKeyIndex`/`recipientKeyIndex` with key id 0 — an -**AUTHENTICATION**-purpose ECDSA key — while we *send* selecting -ENCRYPTION/DECRYPTION-purpose keys and *validate* (G1(b)) requiring those -purposes. Cross-client requests would be blocked in both directions. **Action -(M1 task 8):** verify empirically against a real testnet mobile contactRequest, -then align — liberal-on-receive (accept the purposes mobile actually uses; keep -the ECDSA key-*type* gate), compatible-on-send (fall back to the mobile -convention when the recipient lacks a DECRYPTION-purpose key). - -### P2 — cleanup / completeness - -- **G6 — Wrong fallback contract id** (relabeled P2 — dead code in default builds): - `rs-sdk/.../dashpay/mod.rs:33` hardcodes the **DPNS** id under - `#[cfg(not(feature="dashpay-contract"))]` — a latent foot-gun. **Fix:** correct - the constant to the DashPay id `Bwr4WHCP…NS1C7` or delete the fallback. - -- **G7 — Dead code:** wire `validate_contact_request` into the send path (replace - the ad-hoc `find(...)`) — note the *receive/sync* side of this validator is pulled - forward into **M1** by G1(b); decide whether to ship auto-accept (then call the - proof gen/verify) or delete it and its FFI param. **Acceptance criterion if - shipped:** any handler acting on `autoAcceptProof` MUST call - `verify_auto_accept_proof` before triggering automatic acceptance — sync stores - the blob unverified today, so wiring auto-accept without the gate lets forged - 38–102-byte blobs auto-establish attacker contacts. -- **G8 — Local placeholder:** store the real 96-byte ciphertext on the local sent - `ContactRequest` (`contact_requests.rs:283`) so the persisted/SwiftData row - matches Platform. -- **G9 — Contract cache:** hold one `Arc` on the wallet instead of - re-loading the bundled contract per op. -- **G10 — `contactInfo` support:** add SDK + wallet + FFI for the `contactInfo` - document (self-encrypted alias/note/displayHidden/acceptedAccounts) so contact - metadata and hides sync across a user's devices. Respect the "≥2 contacts before - publishing" privacy rule. -- **Compact-xpub note:** DIP-15 specifies a 69-byte compact xpub plaintext; - `platform-encryption` currently encrypts a 78-byte serialization (still 96 bytes - out). Both sides of *this* implementation agree, so it interoperates with itself; - verify against the reference DashPay clients (iOS/Android) before declaring - cross-client compatibility. **Sequencing:** the desk-check (compare reference - client code or captured vectors) is cheap and lands in **M1** (task 5) — a wrong - wire format must be caught before three milestones of tests harden it; the live - cross-client e2e stays in M4. - ---- - -## Part 5 — Work plan (what to build, per milestone) - -Each task notes the layer and the **test** that proves it (TDD: write the failing -test first — see Part 7 and the repo's TDD discipline). - -### Milestone 1 — Correctness (the flow actually completes) - -Ordered so the test seam exists before the TDD-gated tasks that need it. - -1. **G11-seam: make the network layer testable.** The **fetch half needs no new - seam** — use the SDK's built-in mock (`SdkBuilder::new_mock` + - `expect_fetch`/`expect_fetch_many`, already used by `identity_sync.rs` tests) - for the sync/establish tests below. The **put/broadcast half** cannot hide - behind a dyn trait (`Sdk::send_contact_request` is generic over 7 type params): - define ONE object-safe trait exposing only the concrete operations - `IdentityWallet` performs (send contact request with SdkSide ECDH, put profile - document), held as a new `IdentityWallet` field defaulting to an - `Arc`-backed impl so public construction and FFI are untouched. - (`send_payment` already takes an injected `broadcaster: B`.) - **DONE (2026-06-10; revised 2026-07-04):** originally shipped as a - Send-boxed `#[async_trait]` `DashPaySdkWriter` trait; the trait was later - removed as an unused seam (no test ever injected it — the sync/establish - tests mock the fetch half via `SdkBuilder::new_mock` instead). - `network/sdk_writer.rs` now ships a concrete `SdkWriter` held as an - `Arc`-backed `IdentityWallet` field: it still erases the - 7-type-param `send_contact_request` / `PutDocument` generics behind two - concrete methods, it just isn't swappable. -2. **G12: fold DashPay sync into the recurring loop.** Per G12: inject the wallets - map and iterate wallets calling `dashpay_sync()` — do **not** drive off the - token registry; log-and-continue error semantics; keep the on-demand FFI entry - points for pull-to-refresh. - - *Test:* a recurring pass drives DashPay sync for every wallet **including - identities with zero watched tokens**; re-entrancy + quiesce still hold. - **DONE (2026-06-10):** sibling **`DashPaySyncManager`** (`manager/dashpay_sync.rs`, - modeled on `PlatformAddressSyncManager`) — the in-struct option was rejected - because `IdentitySyncManager` is registry-driven. Red→green pinned by - `recurring_pass_syncs_every_wallet_including_zero_token_identities`; per-identity - continue pushed into `sync_contact_requests`. -3. **G1 + G13 + G5-tombstone: sync establishes, reconciles, and builds accounts.** - Relax the ingest guard (G1a); ingest own sent requests (G13); consult the - persisted rejected-senders tombstone (G5 stage 1); then, for every established - contact missing an external account: validate key indices → decrypt → register - (G1b), with the transient/permanent failure policy (G1c). **Lock ordering:** - collect candidates while the wallet-manager write guard is held, drop the - guard, then call `register_external_contact_account` — it re-acquires read - locks on the same tokio `RwLock`, which is **non-reentrant**; calling it inline - under the write guard deadlocks on first execution (mirror the accept path's - guard-drop ordering — `network/contact_requests.rs:466` drops the write guard - before calling `register_external_contact_account`). - - *Tests:* offline-accept→pay (Part 7, `dp_004`); rejected request does NOT - reappear after a recurring re-sync; restore-from-seed then Accept does not - double-broadcast (G13); permanent decrypt failure marks the contact unpayable - and stops retrying. - **DONE (2026-06-10):** tombstone keyed `(owner, sender, accountReference)` - (new `rejected_contact_requests` SQLite table + `ContactChangeSet.rejected`); - broken channel = `EstablishedContact.payment_channel_broken` (new `contacts` - column + FFI accessor `established_contact_is_payment_channel_broken`); - metadata-preserving `reestablish_preserving_metadata()`; sweep candidates - collected under the write guard, registered after guard drop. 192 tests green. - *Deviations:* (1) tests pin the decision logic + state machine; the full - mock-SDK offline-accept→pay and accept-adopt flows live in `dp_004`/`dp_005` - on #3549 (per Part 7.4) — too heavy to stub as unit tests; (2) the Swift - persister bridge does NOT yet project `rejected` / `payment_channel_broken` — - added to M2 plumbing (task 8). -4. **G2: entropy threading.** Fix `rs-sdk` `send_contact_request` to reuse the - creation entropy; assert returned id == on-platform id. - - *Test:* `rs-sdk` unit/integration pinning id equality. - **DONE (2026-06-10) — severity verdict: REAL BROADCAST BUG.** `put_document` - uses the document as-is (E1-derived id) with the supplied fresh entropy E2; - drive-abci consensus recomputes `generate_document_id_v0(…, E2)`, compares to - `base.id`, and rejects with `InvalidDocumentTransitionIdError` — **every - `send_contact_request` through this path failed at consensus**. Fix: additive - `entropy: Bytes32` on `ContactRequestResult`, reused by send; pinned by - `contact_request_result_entropy_derives_returned_id` (red = inexpressible - pre-fix; green post-fix). 136 lib + all test targets green; FFI ABI unchanged. -5. **Interop desk-check (verify-only).** Compare the compact-xpub plaintext - (69B DIP-15 vs 78B current), ECDH derivation, and accountReference masking - against reference DashPay iOS/Android client code or captured vectors; record - the result. A mismatch found here re-scopes M1 before tests harden the wrong - format; the live cross-client e2e stays in M4. - **DONE (2026-06-10)** — `INTEROP_DESK_CHECK.md`. Verdicts: - xpub plaintext **FAIL** (→ new **G14**, task 7 below); ECDH **PASS**; - accountReference **PASS-for-now** (mobile ignores it on receive; our masking - helper has two latent bugs for M3 — 107-byte HMAC input + ASK28 byte order, - where iOS and Android also disagree with *each other*). Bonus hazard → **G15** - (key-purpose convention). -7. **G14: compact-xpub wire format (re-scoped into M1 by task 5).** Send: assemble - the 69-byte compact plaintext (`parentFingerprint(4) ‖ chainCode(32) ‖ - compressedPubKey(33)`) from the already-derived contact xpub instead of - `ExtendedPubKey::encode()`; receive: parse the 69-byte compact (both sides - already know the derivation path, so depth/child-number are reconstructable). - Pin with byte-exact vectors mirroring the reference clients (iOS - `ecdsa_key.rs:333-341`, dashj `serializeContactPub()` — quoted in - `INTEROP_DESK_CHECK.md`). 69 → PKCS7 → 80 ‖ IV 16 = exactly 96 bytes. - **DONE (2026-06-10):** codec in `platform-encryption` - (`compact_xpub_bytes`/`parse_compact_xpub`, `COMPACT_XPUB_LEN=69`); - `ContactXpubData::compact_xpub()` + `reconstruct_contact_xpub` in - `crypto/dip14.rs`; rs-sdk callback contract = "69-byte compact", validated - pre-encryption; receive reconstructs from `chain_code`+`pubkey` (metadata - depth/child synthesized — non-hardened CKD unaffected, pinned by - `reconstructed_xpub_derives_identical_addresses`); legacy 78/107 fallback - branch kept. 194 platform-wallet tests green; FFI ABI unchanged (caller doc - contract tightened). -8. **G15: key-purpose verification (decision gate, cheap).** Fetch a real - mobile-created `contactRequest` + its sender identity from testnet; inspect - `senderKeyIndex`/`recipientKeyIndex` purposes. Then align: likely - liberal-on-receive (accept ECDSA keys of the purposes mobile actually uses) - + compatible-on-send (fall back to the mobile convention when the recipient - has no DECRYPTION-purpose key). Implementation in M1 if the verification - confirms the mismatch; the validation gate from G1(b) stays for key *type*. - **VERIFIED (2026-06-10, all 368 testnet contactRequests — - `INTEROP_DESK_CHECK.md` §G15):** the "key 0 AUTHENTICATION" desk-check reading was - stale. Dominant mobile cohort (223 docs): **unbound ENCRYPTION/MEDIUM key - (id 2) for BOTH indices** (recipientKeyIndex → ENCRYPTION — mobile identities - carry no DECRYPTION key); 2026 cohort (68 docs): contract-bound ENC(4)/DEC(5) - — our convention. Consensus enforces neither purpose nor boundedness on these - fields. **Alignment (task 9):** send — prefer recipient DECRYPTION, fall back - to recipient ENCRYPTION; receive — accept ENCRYPTION for sender, - ENC-or-DEC for recipient; keep the ECDSA type gate; purpose mismatch alone - never marks a channel permanently broken. No AUTHENTICATION fallback. -9. **G15 alignment implementation** (per the verified verdict above): relax the - sender/recipient purpose assertions in `rs-sdk` `create_contact_request` - (`:200-239`) and `rs-platform-wallet` key selection + `validate_contact_request` - wiring; tests for the mobile-cohort shape (ENC/ENC, unbound) and our own - (ENC/DEC, bound). - **DONE (2026-06-10):** recipient selection prefers DECRYPTION, falls back to - ENCRYPTION (ECDSA gate kept, no AUTH fallback); validation gained a recipient - purpose gate (AUTH was silently accepted before!) + `purpose_mismatch` flag; - purpose mismatches log-and-skip, never `payment_channel_broken`; ECDH decrypt - path confirmed index-generic (pinned). 204 platform-wallet + 139 dash-sdk - tests green. **M1 complete** (task 6 e2e rides #3549, non-gating). -6. **G11-Rust: full-cycle e2e confirmation** (`dp_003`): `profile → send → sync → - accept → established → pay`, on live testnet via the #3549 bank harness. - **Not M1-exit-gating** (Part 7.4): M1 exits on tasks 1–5; this task is tracked - on #3549 and lands when the framework does. - -**coreHeight backfill rescan (DIP-15 §8.7/§12.6) — DONE (2026-06-24).** Surfaced by -the re-audit after M1's original plan: an incoming payment to a contact's receival -address that landed *before* the address was watched (restore-from-seed, second -device, or the offline-accept→pay window) was silently missed. Fixed by (a) re-import -scanning from birth-height `Some(0)` (`cba515aaf1`) so on-chain history isn't skipped, -and (b) `reconcile_dashpay_rescan` (`18483e4232`, a local-only step of `dashpay_sync` -in `manager/dashpay_sync.rs`) that lowers the wallet's SPV `synced_height` toward -`min($coreHeightCreatedAt)` over established receival contacts so dash-spv's filter -manager re-matches the now-watched addresses against blocks it already scanned. It uses -the inner unconditional height setter (no upstream change) with a per-contact -`dashpay_rescan_triggered` one-shot guard so the recurring sweep doesn't re-lower the -height every pass. The regression is safe (`synced_height` is the filter-scan -checkpoint, decoupled from the monotonic `last_processed_height`); the floor is clamped -to the engine's header/birth floor, and the one SPV caveat is that a rewind into a -never-stored filter range is retried silently every 30s rather than erroring. - -### Milestone 2 — Swift UI (first-class, polished) + Swift tests - -See Part 6 for the screen design. Tasks: - -> **STATUS (2026-06-10): tasks 7–10 DONE** (Phases A–D on -> `feat/dashpay-m1-sync-correctness`). Delivered: FFI sync-control surface -> (`platform_wallet_manager_dashpay_sync_{start,stop,is_running,is_syncing, -> last_sync_unix_seconds,set_interval,sync_now}`), persister payload extended -> (callback arity 8→10: `payment_channel_broken` on `ContactRequestFFI`, -> `ContactRequestRejectionFFI` tombstones), payment-history getter -> (`managed_identity_get_dashpay_payments`); Swift SDK wrappers + -> `PersistentDashpayPayment` + `@Published dashPaySyncIsSyncing`; the full -> DashPay tab (`Views/DashPay/` — 7 files) with all §6.4 states and -> `dashpay.*` accessibility ids; simulator BUILD SUCCEEDED. -> **Spec deltas accepted:** (1) alias/note/hide are a UserDefaults-backed -> device-local store until M3's `contactInfo` (no SwiftData model added); -> (2) contact DPNS labels captured as an add-time hint (not persisted -> elsewhere); (3) AddContact ID-mode preview is cache-only (no -> fetch-profile-by-id FFI). Task 11 (tests) = Phase D. -> **Task 11 DONE (2026-06-10):** 15 SDK unit tests (persister bridge: broken-flag -> on both rows, tombstone scoped to `(owner,sender,accountReference)` w/ rotation -> survival, 10-arg C-callback round-trip, payment upserts, FFI marshalling) + 2 -> UI smoke tests (§6.4 picker states; passed on-simulator). Phase D also found & -> fixed a changeset-atomicity defect (`persistDashpayPayments` missing the -> `!inChangeset` guard — red→green). Totals: swift test 29/29; app tests 237 -> passed / 18 pre-existing network-gated skips; UI smoke green; BUILD SUCCEEDED. -> Full add→approve→pay XCUITest = documented TODO gated on funded testnet -> identities (tracks `dp_003`). - -7. Add `RootTab.dashpay` + `DashPayTabView` with an active-identity picker - (`ContentView.swift`, `SwiftExampleAppApp.swift`) — picker states per §6.4. -8. Extract/rebuild `ContactsView`, `ContactRequestsView` (incoming **+ outgoing**), - `AddContactView`, `ContactDetailView`, `ProfileView`/editor, reusing the - already-wrapped `ManagedPlatformWallet` methods; implement the §6.4 interaction - states (DPNS resolution states, send-collision flow — **AddContactView only**, - not the payment sheet — in-flight rows). Payment - history requires the persister mapping + `PersistentDashpayPayment` model (§6 - intro). Additional persister-bridge plumbing from M1: project the - `rejected` tombstones and `payment_channel_broken` flag into SwiftData (the - Rust SQLite pipeline already persists them; the Swift `on_persist_contacts_fn` - bridge does not yet). -9. Move lists onto `@Query [PersistentDashpayContactRequest]` / - `[PersistentDashpayProfile]` with the §6.4 optimistic-overlay policy; refresh - via `syncContactRequests()` + `syncDashPayProfiles()` in `.task` / - pull-to-refresh, coordinated through the §6.4 single sync-in-progress signal - (requires M1 task 2 — the three-caller invariant can't be exercised until the - G12 background loop exists). **Realtime cadence (per §6.4):** the tab drives the - background loop's interval to 4s on foreground / 15s on background via - `setDashPaySyncInterval` (NavigationStack `onAppear`/`onDisappear`), and every - local mutation (send/accept/QR-send/pay) fires a non-blocking `kickDashPaySync` - so the counterparty side converges without waiting for the next tick. -10. Polish: AsyncImage avatars w/ initial-circle fallback, empty states, loading & - error states, inline success feedback (§6.4), accessibility identifiers on - every interactive control (for XCUITest). -11. **G11-Swift:** unit tests (wrapper round-trips) + XCUITest (add→approve→pay). - (Part 7.) - -### Milestone 3 — Spec completeness (rotation, hide, alias sync) - -12. **G3:** wire `calculate_account_reference` + version bump into send; - receive-path version handling + "addresses rotated" surfacing — **includes the - receive-side re-keying** of contact-request state/persistence by - `(counterparty, accountReference)` (see G3 scope note). -13. **G10 + G5 stage 2:** `contactInfo` document support (SDK + wallet + FFI) → - cross-device reject/hide + alias/note sync. - - **DONE (2026-06-12), 4 commits:** crypto core (DIP-15 derivation - `root/65536'+65537'/idx'`, AES-256-ECB encToUserId, IV‖CBC privateData; - the `privateData` plaintext was initially a CBOR array but is being - migrated to the **DIP-15 varint** format — the contract enforces length - only, so it's a free convention; see `CONTACTINFO_FORMAT_SPEC.md` / - Spec 1), stateless doc↔contact resolution (decrypt every owned doc's - encToUserId), sync step 3 of the recurring pass, publish with the - DIP-15 ≥2-contacts privacy gate (deferred publishes update local state - only), FFI `platform_wallet_set_dashpay_contact_info_with_signer`, - persister round-trip (alias/note/hidden on the established rows, both - directions), and **contact restore at load** (new contacts array on - `IdentityRestoreEntryFFI`) — without which the re-establish sweep wiped - metadata during the deferred-publish window and contacts were invisible - on offline launches. Verified on-sim: alias save → relaunch → survives - and renders. -14. Swift UI for alias/note edit (reuse `EditAliasView`) now backed by - `contactInfo` — remove the M2 "This device only" labels. - - **DONE (2026-06-12):** ContactDetailView reads alias/note/hidden off - the `@Query` contact rows and writes through - `ManagedPlatformWallet.setDashPayContactInfo`; ContactsView hidden - filter + alias display moved off the UserDefaults meta store (which - now only keeps the add-time DPNS hint); labels updated. - -**Receive-side `encryptedAccountLabel` surfacing (DIP-15 §8.5) — DONE (2026-06-24).** -The send side already length-normalizes the label; the receive side now decrypts and -shows it. Decrypted in Rust at the two signer-bearing register sites (the drain -`RegisterExternal` Ok-branch + `accept_register_external_validated`, where the ECDH -`shared` key lives) by `store_contact_account_label` (`network/contact_requests.rs`), -stored on the derived `EstablishedContact.contact_account_label` field (reset in `new`, -`reestablish_preserving_metadata`, and `apply_rotated_incoming_request` so it never -goes stale). It is surfaced **incoming-only** — it is the contact's label for *their* -account, so it is derived strictly from the incoming request and projected onto the -incoming FFI row only (the outgoing row's label is one *we* sent and is never shown), -via `contact_persistence.rs`. Decrypt failures / garbage / control-chars sanitize to -`None` (cosmetic — never breaks the channel), and it resets on rotation. Renders as a -read-only "Their account" row through Swift `contactAccountLabel` → `ContactDetailView`. - -15. **G4 design-only:** specify the FFI ECDH hook (shared-secret-only across the - ABI — never a raw private key; see G4) so M4's implementation doesn't churn - the wallet API. - - **DONE (2026-06-12) — design:** - - **ABI surface (one new callback on the existing host-signer table** — - the same registration path external-signable wallets already use for - transaction signing**):** - ```c - int32_t (*ecdh_shared_secret_fn)( - void *context, - const uint8_t (*wallet_id)[32], - const uint8_t (*identity_id)[32], - uint32_t key_id, // sender's encryption key id - const uint8_t (*counterparty_pubkey)[33], - uint8_t (*out_shared_secret)[32]); // SHA256((y&1|2)||x) — finished secret - ``` - The host derives the identity encryption private key for - `(identity_id, key_id)` from its keychain/secure element and computes - the **finished DIP-15 shared secret host-side**. The private key never - crosses the ABI (the `rs-sdk-ffi` `DashSDKContactRequestParams. - sender_private_key` field is the antipattern this replaces; flagged for - its own audit). Non-zero return = "host cannot produce the secret" - (locked keychain, missing key): the operation fails with a typed - `EcdhUnavailable` error and is NOT treated as a broken payment channel - (our side failed, not the contact's request). - - **Rust routing:** `send_contact_request` / - `register_external_contact_account` branch on wallet key-residency: - seed-resident wallets keep today's in-process derivation - (`EcdhProvider::SdkSide`); external-signable wallets route - `EcdhProvider::ClientSide { get_shared_secret }` where the closure - calls the FFI hook. No public wallet-API signature changes — the - provider choice is internal, which is what de-risks M4. - - **Zeroization:** Rust wipes `out_shared_secret` (`Zeroizing`) after - deriving the AES key; hosts are instructed to do the same with their - intermediate private key (Swift: `withUnsafeTemporaryAllocation` + - explicit reset, mirroring the signer callback's key handling). - - **Same hook serves decrypt-side** (`register_external_contact_account` - needs ECDH with the *contact's* pubkey at OUR key id) — the - `counterparty_pubkey` parameter covers both directions; no second - callback needed. - -### Milestone 4 — Hardening / cleanup - -16. **G4:** watch-only ECDH via `EcdhProvider::ClientSide` pushed across FFI - (implements the M3 design). - - **DEFERRED with design amendment (2026-06-13):** implementation scoping - found the M3 hook (ECDH shared secret only) is **insufficient** for true - watch-only DashPay: the friendship-xpub derivations are hardened and - seed-bound on BOTH flows — send derives the sender↔recipient receiving - xpub (`m/9'/coin'/15'/account'/`, recipient-dependent so not - pre-derivable), and accept derives our receiving xpub for the new - account. A watch-only host therefore needs **three** hooks: ECDH shared - secret (designed in M3), friendship-xpub derivation, and - receiving-account-xpub derivation — or one combined - "derive-DashPay-context" hook returning `(compact_xpub, shared_secret)`. - The contactInfo self-encryption keys (M3 task 13) are seed-bound the - same way and need a fourth surface (or ride the combined hook). - Since the example app attaches the seed at launch (this gap is - explicitly not a demo blocker), shipping the ECDH-only ABI change would - add churn without enabling any watch-only flow. Revisit as its own - design+implementation slice when a hardware/watch-only host exists. -17. **G6:** fix/delete fallback contract id. - - **DONE (2026-06-13):** fallback corrected from the DPNS id to the - deployed DashPay id. -18. **G7:** wire send-path validation; ship-or-delete auto-accept (verify-gate - acceptance criterion applies if shipped — see G7). - - **DONE (send half, 2026-06-13):** the selected key pair gates through - `validate_contact_request` before any ECDH/broadcast. Auto-accept: - decision = **keep dormant** — it activates with M5 invitations behind - the `verify_auto_accept_proof` hard gate (per Part 8.5), not deleted. -19. **G8/G9:** real local ciphertext; contract cache. - - **DONE (2026-06-13):** sent rows store the real 96-byte ciphertext off - the broadcast document; the bundled DashPay contract is cached - process-wide (OnceLock) replacing five per-call re-parses. -20. Live cross-client interop e2e (compact xpub, ECDH, accountReference) vs - reference DashPay clients (the M1 desk-check verified the formats on paper). - - **BLOCKED-EXTERNAL (2026-06-13):** requires driving real DashWallet - iOS/Android builds against a shared network — not runnable in this - environment. The M1 desk-check (`INTEROP_DESK_CHECK.md`) + on-chain census remain - the interop evidence; the contactInfo research (`CONTACTINFO_FORMAT_SPEC.md` Appendix A) found no - reference client implements contactInfo at all, shrinking the live-e2e - surface to contactRequest + payment addresses. Run manually when a - mobile test build is available. - -### Milestone 5 — Invitations (new scope, 2026-06-10; needs its own design pass) - -Onboard users who don't have Dash yet: inviter creates an asset-lock-funded -credit voucher + link (DIP-13 invitation subfeature, `m/9'/5'/5'/3'`); invitee -claims it → identity created from the voucher → invitee's contact request to the -inviter carries an `autoAcceptProof` (path `m/9'/5'/16'/timestamp'`, helpers -already implemented in `crypto/auto_accept.rs`) → auto-established contact after -`verify_auto_accept_proof` (hard gate, see G7/Part 8.5). Scope before -implementation: a research+design slice (invitation create/claim wallet flows, -deep-link format, expiry/revocation, UI) — the platform wallet has the asset-lock -and identity-registration machinery to build on but no invitation flows today. - ---- - -## Part 6 — Swift UI design (the "nice UI") - -**Decision: promote DashPay to a first-class tab (Option B).** Lower-risk Option A -(polish in place under Identities) is the fallback if tab real estate is contested, -but a "nice DashPay UI" wants its own home. All screens reuse already-wrapped -`ManagedPlatformWallet` FFI — **no new network FFI**; the one new plumbing item is -**payment history** (map the Rust `dashpay_payments` changeset overlay in the Swift -persister into a new `PersistentDashpayPayment` SwiftData model + `@Query` — today -no FFI exposes `PaymentEntry` and no SwiftData model exists for it). - -### 6.1 Navigation - -Add `case dashpay` to `RootTab` (`ContentView.swift`), between `identities` and -`contracts`. Tab icon `person.2.fill`, title "DashPay". - -``` -DashPayTabView (NavigationStack) -├─ Active-identity picker (top) — most DashPay UIs assume one active identity; -│ menu of the wallet's managed identities (DPNS name → truncated id). -├─ Profile header card → tap → ProfileView / ProfileEditorView -│ (empty state → "Set up your DashPay profile" CTA → ProfileEditorView) -├─ Username prompt card → "Register a username" → RegisterNameView -│ (shown only when an on-chain DPNS check confirms the active identity -│ has no name; explains that without a username people can't find you -│ by name, and that the profile display name is cosmetic, not searchable) -├─ Segmented control: [ Contacts | Requests ] -│ ├─ Contacts → ContactsView (@Query established) -│ │ row tap → ContactDetailView → "Send Dash" / alias / note / hide -│ └─ Requests → ContactRequestsView -│ ├─ Incoming (Accept / Reject) -│ └─ Outgoing (pending — NEW, currently unrendered) -└─ Toolbar: + (AddContactView) · refresh (sync) -``` - -Wire DashPay sync into the `.task` of `DashPayTabView` and/or the existing global -`GlobalSyncIndicator`: run `syncContactRequests()` then `syncDashPayProfiles()`. - -### 6.2 Screens - -**ProfileView / ProfileEditorView** (promote from `IdentityDetailView`) -- View: large avatar (`AsyncImage` w/ initial-circle fallback), `displayName`, - DPNS handle, `publicMessage`. "Edit" button. Empty state → "Set up your DashPay - profile" CTA (opens `ProfileEditorView` as a sheet — same target as "Edit"). -- Editor: `Form` with `displayName` (≤25), `publicMessage` (≤140), `avatarUrl`; - live char counters; on save fetch avatar bytes for DIP-15 hash/fingerprint; call - `createDashPayProfile` / `updateDashPayProfile(…signer:)`. - -**Username prompt** (`usernamePromptCard`, below the profile header) -- A second setup CTA, independent of the profile one: shown when the active - identity has **no DPNS username** (confirmed by an on-chain `dpnsGetUsername` - check in `.task` + on app-foreground, so an identity that already has one — - just not yet cached, or registered meanwhile on another device — is never - nagged; mirrors `IdentitiesView`'s lazy fetch. A found name is persisted and - saved; a definitive empty result shows the card; a thrown error retries. - Residual: a name registered on another device *while the user sits on this - tab* clears on the next tab switch / app-foreground). Tap → `RegisterNameView`. - Copy makes the username-vs-profile distinction explicit: a **username** is the - searchable handle people type to add you (contact search); the profile - **display name** is cosmetic and not searchable. On registration the Rust path - persists `dpnsName`, so the prompt hides reactively via `@Query`. - -**ContactsView** -- `@Query` established contacts (joined to `PersistentDashpayProfile` for - display). Row = avatar + (alias → displayName → DPNS → truncated id) + last - payment hint. Search bar. Pull-to-refresh = sync. Empty state → "Add your first - contact". - -**ContactRequestsView** (the **new outgoing section** is the headline UI gap) -- **Incoming**: row + Accept (`borderedProminent`) / Reject (`.tint(.red)`), - relative timestamp, sender profile. On accept → success toast + move to Contacts. -- **Outgoing**: pending sent requests (`fetchSentContactRequests` / - `getSentContactRequestIds`), "Pending" badge, sent timestamp. Currently loaded - but never shown — render it. - -**AddContactView** (restyle `AddFriendView`) -- Segmented: **Username (DPNS)** | **Identity ID**. DPNS mode: live prefix search - (`searchDpnsNames`) with result rows (avatar + name); ID mode: paste + validate - base58. Resolve → preview the target profile → "Send request" → `sendContactRequest`. - -**ContactDetailView** -- Profile header; **Send Dash** (presents the polished `SendDashPayPaymentSheet`); - payment history (from `PaymentEntry` via the `PersistentDashpayPayment` mapping — - see §6 intro); editable **alias** / **note** and **Hide** toggle, each labeled - **"This device only"** in M2 (until M3's `contactInfo` backing replaces the - label) so users don't assume sync semantics that don't exist yet. - -**SendDashPayPaymentSheet** (already polished — restyle only) -- Amount in DASH→duffs, spendable balance, over-spend block, recipient - profile/avatar/DPNS, result txid. (Memo is local-only; keep the field hidden or - label it "private note" until on-chain memo exists.) -- Zero-balance state: when spendable balance is 0 (after the async load), disable - the amount field + Send and show "Your balance is 0 DASH — top up your wallet - before sending." instead of an always-disabled interactive form. - -### 6.3 Conventions (must match house style) - -From `SwiftExampleApp/CLAUDE.md`: -- `@EnvironmentObject var walletManager: PlatformWalletManager`, - `var appState: AppState`; `@Environment(\.modelContext)`. -- Lists via `@Query` on `Persistent*` (move off the live-snapshot read). -- Async FFI: `Task { @MainActor in … }`, `defer { isLoading = false }`, resolve - wallet via `walletManager.wallet(for:)`, fresh `KeychainSigner` per submit, - `errorMessage` red caption on catch. -- `Form`/`Section` for editors, `List`/`Section` with count headers for lists. -- SF Symbols (`person.2`, `person.badge.plus`, `paperplane`, `pencil`, - `person.crop.circle`), blue accent, red destructive, green success, - `.borderedProminent` primaries. -- Amounts entered in DASH, converted to duffs (`× 100_000_000`). -- Display precedence: alias → DashPay `displayName` → DPNS → truncated hex. -- **`.accessibilityIdentifier(...)` on every interactive control** (needed for - XCUITest) — e.g. `dashpay.tab`, `dashpay.addContact`, `dashpay.request.accept`, - `dashpay.send.amount`, `dashpay.send.confirm`. -- **Never orchestrate in Swift** — one FFI call per DashPay op. - -### 6.4 Interaction states & edge cases (normative for M2) - -- **Identity picker** (tab root) — three states: (1) no wallet loaded → disabled - "No wallet loaded" label + link to the Wallets tab; (2) wallet but zero - identities → "No identities yet" + CTA to the Identities tab; (3) ≥1 identity → - menu. Exactly one identity → auto-select and hide the picker. Selection persists - across launches via `@AppStorage`. -- **AddContactView (DPNS mode)** — four states: typing → searching (inline - `ProgressView`) → not-found (inline message + clear-and-retry affordance, never a - dead end) → found (profile-preview card; "Send request" enabled only from this - state). ID mode: inline base58 validation gates the send button. (The current - `AddFriendView` dead-ends on "DPNS name not found".) -- **Send-collision flow** — if the target already has an incoming request to us, - alert "This person already sent you a request — Accept it instead?" with - Accept / Continue anyway. (Sending anyway is protocol-valid; it just establishes - the contact.) -- **Request rows in flight** — on Accept/Reject tap, replace both buttons with a - `ProgressView` for that row (prevents double-tap → duplicate accepts); on - success remove the row optimistically; on failure restore the buttons + inline - error on the row. -- **Optimistic overlay over `@Query`** — accept/reject/send mutate Rust state and - the persister callback lands later; bridge the latency window with a local - `@State` overlay set of affected ids filtering the `@Query` results, cleared - when the query reflects the change. (The old `loadFriends()` re-read pattern is - incompatible with pure `@Query` reactivity.) -- **Single sync-in-progress signal** — one `@Published` flag on - `PlatformWalletManager` observed by all three sync callers (`.task`, - pull-to-refresh, the G12 background loop); a pull-to-refresh during an in-flight - sync attaches to it instead of double-firing. -- **Realtime cadence (foreground-fast / background-slow)** — the G12 background - loop runs on a tunable interval (`setDashPaySyncInterval`, clamped ≥ 1s Rust-side; - default = `backgroundSyncSeconds` = 15s). The DashPay tab drops it to - `foregroundSyncSeconds` = 4s at *effective foreground* — the tab is on screen - **and** the app is active — and restores 15s otherwise. "On screen" is driven - from the tab's **NavigationStack** `onAppear`/`onDisappear` (so drilling into a - contact detail or presenting a sheet, neither of which fires the stack's - `onDisappear`, keeps the fast cadence; only a *tab switch* relaxes it); "app - active" is driven from `scenePhase`, so backgrounding the app while on the tab - also relaxes to 15s. The cadence acts only on transitions. This keeps neither an - inactive tab nor a backgrounded app sweeping every few seconds, while incoming - requests / acceptances / payments surface in near real time when the user is - actually looking. **Entry kick:** `setDashPaySyncInterval` only takes effect on - the loop's *next* sleep (it stores an atomic, no wakeup — `dashpay_sync.rs:157`), - so entering the foreground also fires one `kickDashPaySync` — otherwise a tab - re-entry could wait out a leftover up-to-15s sleep before the first fast tick. - (A Rust-side `Notify` on `set_interval` would shorten the in-flight sleep - directly; deferred as an internal refinement — the entry kick achieves the same - user-visible result app-side.) Best-effort: a not-yet-configured manager keeps - its current interval. -- **Post-mutation sync kick** — after a local mutation (send request, accept, - send-via-QR, pay) the handler fires a non-blocking `dashPaySyncNow()` - (`kickDashPaySync`) so the counterparty's state and the established pair converge - promptly instead of waiting a full poll tick. Non-blocking: the sheet dismisses - right away and the Rust manager folds an in-flight pass into a no-op (the single - sync-in-progress signal above). *Bounded, not instant:* if a pass was already - running when the mutation landed, the kick no-ops and convergence waits for the - next tick (≤ the foreground 4s) rather than enqueuing a coalesced re-run. - Complements, doesn't replace, the optimistic `@Query` overlay — the overlay - covers the sender's own row, the kick pulls the other side. -- **Success feedback** — reuse the existing inline success pattern - (`SendDashPayPaymentSheet`'s green inline text); no new toast component in M2 - (the app has no shared toast — only a clipboard `CopiedToast`). -- **Broken payment channel** (surfaces G1(c)) — ContactsView row shows a warning - badge; ContactDetailView disables Send Dash with "Payment channel broken — ask - the contact to send a new request" (re-enables when a new request arrives). -- **Needs-unlock / verify-failed banner** (seedless wallets) — **DONE (2026-06-23; - `9963923e05` Rust+FFI, `841802c587` Swift).** A signerless sweep enqueues - contact-crypto ops it can't finish while the Keychain is locked; - `pending_contact_crypto_count` (`network/contact_requests.rs`) → FFI - `platform_wallet_pending_contact_crypto_count` (`dashpay.rs`) feeds the ~1 Hz Swift - poller into a per-wallet `DashPayUnlockStatus` / `@Published dashPayUnlockStatus`, - rendered as a banner in `DashPayTabView.swift` (orange "N contact(s) waiting to - finish setup" + Unlock, red on seed-mismatch). The count **excludes** - `ContactInfoDecrypt` ops — those re-enqueue every sweep, so counting them would - falsely re-trip the banner ~15s after every unlock; only the account-build ops - (`RegisterReceiving`/`RegisterExternal`) converge to 0 once the payment account is - built. -- **Profile save flow** — on save: disable Save + inline `ProgressView`; success → - dismiss the editor sheet; failure → re-enable Save + red caption below the form. -- **Payment history list** — empty state "No payments yet"; loading = single - inline `ProgressView`; error = keep last-known list + inline caption. - ---- - -### Multi-reviewer code review (2026-06-14) — 8 findings fixed - -Five specialized reviewers (crypto-security, FFI-memory, sync-correctness, -Swift/iOS, silent-failures) audited the M1–M4 diff. Crypto + FFI-memory -boundaries came back clean. Correctness/silent-failure reviewers found bugs -the live UAT had missed (UAT only hit the pending-sent rotation path, which -the reject-tombstone masked). All fixed with red→green regression tests: - -- **P0** rotation re-send to an ESTABLISHED contact reset the version to 0 - → unique-index rejection → contact unrotatable. Lookup now consults - `established_contacts.outgoing_request` (`prior_sent_account_reference`). -- **P0** multi-doc sweep thrash: immutable docs from a rotated sender both - returned every sweep, flipping state + rebuilding the external account - forever. `newest_received_per_sender` collapses to newest-per-sender - before ingest; `apply_rotated` is idempotent. -- **Critical** swallowed persist errors → memory/disk divergence (reject - resurrection). New `PlatformWalletError::Persistence`; reject + send_payment - propagate, self-healing sweep writes log. -- **H1** Sent payments lost at relaunch (map restored empty) → new - `PaymentRestoreEntryFFI` + `restore_dashpay_payments` fold + Swift builder. -- **H2** deferred-publish lied as "synced" → 3-state `ContactInfoPublishOutcome` - through the FFI; ContactDetailView shows the real state. -- Med: zero-ciphertext fallback → hard Err; contactInfo derivation-index - high-water mark; Swift silent contact-drop now logged; crypto - account_index/accountReference invariant documented. - -230/230 Rust lib + FFI tests green, clippy clean, full iOS build green. -NOTE: on-device re-verify of H1/H2 pending — the sim SwiftData store was -reset environmentally (identities gone), so it needs the devnet identity -setup rebuilt first. - -### Devnet UAT round 2 (2026-06-13) — rotation / reject / DPNS verified live - -On paloma with three identities: **reject + tombstone** (rejected request -suppressed across forced re-sync), **G3 rotation end-to-end** (re-send from -the rejected sender broadcast with a bumped accountReference — accepted by -the unique index — and reappeared through the tombstone on the recipient: -the dp_005 scenario, live), **DPNS register → live search → found preview → -send** and the **not-found state** (inline + retry, no dead end), **accept -of the rotation request** (re-established, accounts rebuilt). Findings -fixed: optimistic pending-sent overlay leaked across identity switches -(now reset on picker change). Open UX item: "SPV client is not running" -dead-ends both Send Dash and identity creation — needs auto-start or a -"Start & retry" affordance (product decision pending). - -## Part 7 — Test plan - -Follow the repo TDD discipline (failing test first; red→green in the commit -message). DashPay's correctness-critical pieces are the crypto and the -state-machine handshake — those get the deepest coverage. - -### 7.1 Rust — `rs-platform-wallet` / `rs-sdk` / `platform-encryption` - -Already covered (keep, don't duplicate): crypto round-trips, the contact -state-machine handshake (`tests/contact_workflow_tests.rs` + inline), and -persistence (`wallet/apply.rs`). See G11 for the inventory. - -Unit — **the missing tier is the `network/` layer** (currently 0 tests). Add behind -a mock SDK/broadcaster seam: -- **Recurring sync (G12):** a recurring pass drives `dashpay_sync` for each wallet - — including identities with zero watched tokens (see G12: do not couple to the - token registry); re-entrancy guard + `quiesce` shutdown still hold; interval - changes are picked up. -- **Sync builds external accounts (G1):** given an established contact with an - encrypted xpub, the sync pass decrypts it and registers a `DashpayExternalAccount` - (and skips gracefully for watch-only). -- **Crypto/derivation wiring:** `calculate_account_reference` is actually used by - the send path (G3) and round-trips (un-mask recovers account + version). -- **State machine** (extend existing): idempotent re-sync; accept when both present; - reject removes incoming. - -Offline crypto/encode tier (rs-sdk, no network) — follow the existing -`packages/rs-sdk/tests/fetch/` harness with `--features mocks,offline-testing` -(`Config` from `tests/.env`, `mock::Mockable` + recorded vectors): -- **G2 (entropy):** after `create_contact_request`, the returned id matches the id - derived from the *broadcast* entropy. Pin id equality. -- contact-request wire-shape: `encryptedPublicKey == 96B`, properties map matches - the v1 schema, `accountReference` round-trips. - -**E2E tier — build on the existing framework (PR #3549, -`packages/rs-platform-wallet/tests/e2e/`).** This is the canonical "how we do e2e" -for this crate (see [Part 7.4](#74-alignment-with-the-existing-e2e-framework)): -gated behind the **`e2e` cargo feature**, funded by the testnet **`bank` wallet** -harness (`framework/bank.rs` — `BankWallet::load`, `fund_address`, -`cross_check_balance`), config via `tests/.env` -(`PLATFORM_WALLET_E2E_BANK_MNEMONIC`), one file per case under -`tests/e2e/cases/_NNN_*.rs` registered in `cases/mod.rs`, run with -`cargo test -p platform-wallet --test e2e --features e2e -- --nocapture`. Add a -**DashPay case family** (proposed prefix `dp_*`), modeled on the shielded `sh_*` -suite (PR #3727) which stacks the same way: - -- **dp_001 (profile):** `create_profile` → fetch from Platform → fields match; - `update_profile` bumps revision. -- **dp_002 (send request):** fund 2 bank-derived identities; A - `send_contact_request(B)`; assert the on-platform `contactRequest` - (`encryptedPublicKey==96B`, key indices, accountReference) + id equality (G2). -- **dp_003 (full cycle — the "done" gate):** A `send_contact_request(B)` → B - recurring-sync sees incoming → B `accept` → **both** established → A - `send_payment(B)` confirms on L1 → B records incoming. -- **dp_004 (offline accept → pay, pins G1+G12):** A sends → B accepts → A offline → - A's **recurring sync** runs → A `send_payment(B)` **succeeds** (external account - built during the sweep). *Must fail before the G1/G12 fix, pass after.* -- **dp_005 (rotation, pins G3):** second `send_contact_request` to the same - recipient with a bumped version is accepted (distinct `accountReference`); the - receive path surfaces the rotation. -- **dp_006 (recurring cadence):** the background recurring sync refreshes - contacts/profiles without an explicit FFI call — including for an identity with - zero watched tokens (assert via the bank harness over a couple of sweeps). -- **dp_006b (foreground-fast cadence + post-mutation kick, §6.4):** entering the - DashPay tab lowers the sweep interval to 4s **and fires one immediate sweep** - (because `set_interval` only applies on the loop's next sleep); leaving the tab - *or backgrounding the app while on it* restores 15s (`setDashPaySyncInterval` - round-trips through the FFI). A send/accept fires an extra `dashPaySyncNow()` - that no-ops when a pass is already in flight. UI-level cadence + the - scenePhase/tab-visibility state machine + the entry kick are covered by a manual - two-sim e2e — these are SwiftUI-lifecycle/wall-clock timing properties the - simulator harness can't assert deterministically; the FFI set-interval/sync-now - round-trip is unit-tested Rust-side. - -### 7.2 Swift — `SwiftTests` + `SwiftExampleAppUITests` - -Unit (`SwiftTests/SwiftDashSDKTests/`): -- `ContactRequest(ffi:)`, `EstablishedContact`, `DashPayProfile(ffi:)` / - `DashPayProfileUpdate` round-trips and marshalling (32-byte id in/out, optional - C-strings, `_free` correctness, no leaks). -- `PersistentDashpay*` SwiftData upsert from the persister callback. - -Flow (mirror the existing `PlatformWalletIntegrationTests.swift` harness, testnet): -- send → sync → accept → established → pay, asserting SwiftData rows + balances. - -XCUITest (`SwiftExampleAppUITests/`, keyed on accessibility ids): -- Open DashPay tab → AddContact by DPNS → request appears in Outgoing → (peer - accepts) → appears in Contacts → open contact → Send Dash → confirm txid. -- Use the `simulator-control` skill for SwiftData inspection + screenshots in UAT. - -### 7.3 "Definition of done" per flow - -| Flow | Done when | -|---|---| -| Create/update profile | `dp_001` + Swift editor XCUITest green; profile visible to peer | -| Send contact request | `dp_002` + G2 (entropy) offline test + AddContact XCUITest green | -| Approve request | `dp_003` accept step → both established; Accept XCUITest green | -| Reject request | local reject unit test green; (M3) `contactInfo` hide syncs across devices | -| Send money to contact | `dp_003` pay step + **`dp_004` (offline accept→pay)** + Send XCUITest green | -| Sync (recurring) | `dp_004`/`dp_006` build external account on the recurring sweep; idempotency unit test green | - -### 7.4 Alignment with the existing e2e framework - -The platform-wallet e2e framework **already exists but is unmerged** — PR -**#3549** (`feat/rs-platform-wallet-e2e`, draft). DashPay e2e cases must be authored -**on that branch** (or rebased onto it after it merges); they are not standalone. -Conventions to follow exactly (from `tests/e2e/README.md`): -- Modeled on `dash-evo-tool/tests/backend-e2e/`; runs against **live Dash testnet** - (v3.0) via DAPI, gated behind the `e2e` cargo feature. -- Funding via the **platform-address `bank` wallet** (seed in - `PLATFORM_WALLET_E2E_BANK_MNEMONIC` / `tests/.env`); most DashPay cases never - touch L1 except the `send_payment` step (which spends Core funds → needs the - bank's Core balance, like CR-003/AL-001). -- Test attribute `#[tokio_shared_rt::test(shared, flavor = "multi_thread", - worker_threads = 12)]`; context provider `TrustedHttpContextProvider`. -- New cases: add `tests/e2e/cases/dp_NNN_*.rs`, register in `cases/mod.rs`, document - in `tests/e2e/TEST_SPEC.md` (pin accounting). The shielded suite (PR #3727, - `sh_*`) is the worked example of stacking a feature-area suite on this framework. - -**Sequencing implication:** the DashPay e2e suite rides #3549 — but **M1's exit -criterion is the mock-seam unit/integration tier** (no #3549 dependency), so M1 is -never blocked on the draft PR. `dp_003`/`dp_004` are the e2e *confirmation* of the -same behaviors, tracked on #3549 (authored stacked on it, or added right after it -merges). The offline crypto/encode tier likewise lands immediately. - ---- - -## Part 8 — Risks, decisions, open questions - -1. **UI shape — first-class tab vs polish-in-place.** Recommended: first-class - `DashPay` tab (Part 6). *Decision owner: product.* Fallback documented. -2. **Cross-client interop. RESOLVED (2026-06-10, desk-check - `INTEROP_DESK_CHECK.md`):** xpub plaintext FAIL → G14 fix in M1 task 7; ECDH PASS; - accountReference PASS-for-now (+2 latent masking bugs noted for M3); new G15 - key-purpose hazard → verification gate in M1 task 8. Live cross-client e2e - stays M4. ⚠ A side-finding: our stack was **not** self-consistent either — - the 107-byte plaintext broke our own send path (see G14). -3. **Watch-only / hardware wallets (G4).** Out of scope for the demo app (it holds - the seed) but required for production. **FFI-hook design lands in M3 (task 15)** - — shared secret only across the ABI, never a raw private key (see G4); - implementation in M4. -4. **`accountReference` semantics (G3).** Decide whether to keep "share full - account xpub, ignore masking" (simpler, but breaks rotation via the unique - index) or implement the DIP-15 masking + version flow. Recommended: implement it - (M3) — rotation is a real user need and the unique-index collision is a latent - bug. -5. **Auto-accept (G7). DECIDED (2026-06-10): keep.** Invitations are now in scope - (Milestone 5) and are built on `autoAcceptProof`, so the helpers + FFI param - stay (dormant until M5 wires them). **Hard requirement when wired:** the - `verify_auto_accept_proof` gate before any automatic acceptance (see G7). -6. **`send_contact_request` entropy (G2). RESOLVED (2026-06-10):** real broadcast - bug — consensus rejected every send (`InvalidDocumentTransitionIdError`). - Fixed in M1 task 4; see the DONE note there. -7. **E2E framework dependency.** The DashPay e2e suite rides PR **#3549** (draft, - unmerged). **M1's exit criterion is the mock-seam tier** (Part 7.4), so M1 never - blocks on it; the `dp_*` cases are authored stacked on #3549 or right after it - merges. *Open: name the owner who decides stack-vs-wait before M1 starts.* - ---- - -## Part 9 — Related in-flight work (open PRs) - -Surfaced from the live PR list — these intersect this plan and should be tracked / -coordinated rather than duplicated: - -| PR | Branch | Relevance | -|----|--------|-----------| -| **#3549** (draft) | `feat/rs-platform-wallet-e2e` | **The e2e framework** the DashPay suite must build on (Part 7.4). | -| **#3727** (draft) | `test/rs-platform-wallet-shielded-e2e` | Shielded `sh_*` e2e suite — the **worked template** for a feature-area suite on #3549. | -| **#3787** | `codex/dashpay-dip15-contact-request-docs` | "DashPay contact request encryption guide" — cross-check against Part 2; avoid doc drift. | -| **#3639** | `feat/platform-wallet-external-signable-wallets` | External/signable wallets — the substrate for **G4** (watch-only ECDH via `ClientSide`). Coordinate before building G4. | -| **#3692** | `feat/platform-wallet-rehydration` | Watch-only rehydration from persistor — touches the same watch-only path as G4. | -| **#3817** | `feature/coinjoin-sweep-and-recovery` | DashSync→SDK migration context (the broader effort DashPay sits inside). | -| **#3750** (NO MERGE) | `feat/platform-wallet-consumer-hardening` | FFI/consumer hardening — may move FFI signatures the Swift layer depends on. | - ---- - -### Appendix — evidence sources - -- [`INTEROP_DESK_CHECK.md`](./INTEROP_DESK_CHECK.md) — - cross-client (iOS DashSync / Android dashj) interop evidence + testnet census. -- [`CONTACTINFO_FORMAT_SPEC.md` Appendix A](./CONTACTINFO_FORMAT_SPEC.md) — - contactInfo wire conventions (this repo sets the de-facto convention). - -The transient working-research files (DIP paraphrase, SDK/contract survey with -worktree-relative file:line citations) were trimmed from the tree; find them in -this branch's git history under `docs/dashpay/research/`. diff --git a/docs/dashpay/SYNC_CORRECTNESS_SPEC.md b/docs/dashpay/SYNC_CORRECTNESS_SPEC.md deleted file mode 100644 index 4b8ab6b4a87..00000000000 --- a/docs/dashpay/SYNC_CORRECTNESS_SPEC.md +++ /dev/null @@ -1,441 +0,0 @@ -# DashPay sync correctness — contact requests **and** profiles (mirror Android `PlatformSyncService`) - -Status: **IMPLEMENTED (2026-06-18)** — both stages shipped on -`feat/dashpay-m1-sync-correctness` (PR #3841), 5-lens review (§9) folded in first. -Stage 1 = paginated retrieve-all + per-identity high-water cursor + 10-min overlap -at a 15s cadence (`network/contact_requests.rs`); stage 2 = id-keyed -`contact_profiles` cache for established + pending senders (`network/contact_info.rs`, -`accessors.rs`). Both are surfaced in the UI and **durably persisted** through the -changeset pipeline to *both* backends (SQLite persister + SwiftData); the high-water -cursor stays in-memory by design (a cold restore does one safe full re-fetch). -Owner: rs-sdk / platform-wallet -Priority: **FIRST** of the DashPay correctness track (ahead of the contactInfo -format migration and the ignore feature). - -This spec covers **two consecutive stages of the same Android sync loop**: - -| Stage | Android (`PlatformSyncService`) | Us before | Delivered | -|-------|--------------------------------|-----------|-----------| -| 1. Contact-request fetch | `updateContactRequests()` — incremental, paginated, high-water | present but **broken** (truncated at 100, no high-water) | **fixed** — retrieve-all + high-water cursor | -| 2. Contact-profile fetch | `updateContactProfiles(userIds)` — batch `whereIn $ownerId` | **absent** (synced only our *own* profile) | **added** — id-keyed cache, established + pending senders | - -Neither is an optimization: stage 1 is a **correctness bug** (real requests are -permanently buried) and stage 2 is a **missing feature** (contacts have no name -or avatar in the UI). The Android wallet (`dash-wallet`, on `kotlin-platform`) -already does both; this spec mirrors that proven design. Delivered as **two -commits** (stage 1, then stage 2) on one branch. - ---- - -## 1. Problem - -### 1.1 Stage 1 — our contact-request fetch is wrong, not just slow - -`packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs`: - -```rust -where toUserId == me, order_by $createdAt, limit: 100, start: None -``` - -`start: None` + a fixed `limit: 100`, re-run every sweep: - -- **Re-fetches the first page from the beginning every sweep** — pays the full - fetch + GroveDB proof-verify each time for data we already have. -- **Truncates at 100 and never paginates** — with ≥100 requests, newer (or, by - `$createdAt asc`, older) legitimate requests are **never fetched**. A spammer - (or a popular identity) **buries real requests permanently**. -- **No durable high-water / cursor** — no notion of "what's new since last sweep". - -### 1.2 Stage 2 — contact-profile sync is entirely absent - -`packages/rs-platform-wallet/.../network/profile.rs::sync_profiles` runs over -`identity_manager.all_identities()` — only **managed** identities (our own), -never contacts (`manager/accessors.rs:54`). So we publish and refresh **our own** -profile but **never fetch a contact's** displayName / avatar / publicMessage. The -UI shows only a raw identity id (or a local alias). Neither `EstablishedContact` -nor any incoming-request sender has a cached profile anywhere. - -## 2. The reference — Android `PlatformSyncService` - -Verified 2026-06 against `github.com/dashpay/dash-wallet` + -`github.com/dashpay/kotlin-platform` (the current JVM platform lib, -`org.dashj.platform:dash-sdk-*`; **not** the stale `android-dashpay`). One -re-entrancy-guarded ticker (`TickerFlow(15.seconds)`) runs, in order: - -``` -updateContactRequests() // stage 1: incremental, paginated, high-water - → discovers userIds (contacts + pending senders) -updateContactProfiles(userIds) // stage 2: batch whereIn $ownerId, cache by userId -checkDatabaseIntegrity()/FixMissingProfiles() // self-heal missing profiles -``` - -- Stage 1 high-water: `SELECT MAX(timestamp)` per direction; **10-min overlap - rewind**; incremental `$createdAt > afterTime` + `startAfter` cursor + - `limit(-1)` = retrieve-all. -- Stage 2 fetches profiles for the userIds drawn from contact-request rows - (**including pending incoming senders** — that's how the request UI shows a - requester's name/avatar), keyed in a `dashpay_profile` table by `userId`, - independent of relationship state. - -## 3. Goal - -1. Make our **contact-request** sync incremental, fully-paginated, - high-water-tracked, and skew-safe, for **both** directions — no truncation, - each request fetched ~once, a flood can't bury anything. -2. Add **contact-profile** sync: fetch established contacts' **and pending - incoming senders'** profiles in batches, cache them (id-keyed) so the UI shows - name + avatar on both the contacts and the requests screens, refresh, and - self-heal any missing profile without unbounded re-querying. - -## 4. Design - -### 4.1 High-water cursor (stage 1) - -**Storage (resolves Q-a).** Android keeps every contact-request row and derives -`MAX(timestamp)`. Our model collapses requests, so we can't `MAX()` a raw table. -We persist **two scalar fields on `ManagedIdentity`** — `high_water_received_ms: -Option` and `high_water_sent_ms: Option` — riding the existing -`IdentityEntry` snapshot (changeset → both persisters → FFI restore), **not** a -separate table. Two integers per identity need no relational shape. - -**The advance invariant (the heart of stage-1 correctness).** Get this wrong and -we reintroduce the burying bug. The cursor: - -1. **Advances only on a fully-exhausted, error-free paginate** of that direction. - "Exhausted" = a page returned `< limit` docs (possibly empty); a final page of - exactly `limit` requires one more fetch to confirm. **Any** fetch/proof error - mid-loop ⇒ **do not advance that direction's cursor this sweep** (leave it at - the prior value; the overlap re-fetches next sweep). -2. **Advances to `max($createdAt)` over every doc *fetched* this sweep** — - *including* docs that ingest then parse-skips, collapses - (`newest_received_per_sender`), or suppresses (ignore/tombstone). The cursor - records **fetch-completeness, not ingest-success**. Ignore `unwrap_or(0)` - sentinels: advance to the max of *present* (`Some`) timestamps only, and never - below the current value. -3. **Never stamps to wall-clock `now`.** On a zero-doc fetch the cursor is left - unchanged. States: `Absent` ⇒ query `$createdAt > 0` (full); `Present(t)` ⇒ - query `$createdAt > (t − OVERLAP_MS)`. - -**Why cursor-loss is safe (the written contract):** every collapsed / suppressed -doc is, by construction, deterministically reproducible from a full re-fetch of -the immutable on-chain set. So **under-shoot is free** (a lost/low cursor just -triggers one full re-fetch; ingest is a fixpoint) and **over-shoot buries**. -Therefore **restore tolerates only under-shoot**: on any restore-consistency -doubt, clamp the cursor to `min(persisted, max($createdAt) over restored contact -rows)`, or reset to `0`. A restored-too-high cursor is a correctness bug. - -**`OVERLAP_MS` is correctness-load-bearing, not cosmetic.** The lower bound is -exclusive (`>`) and the `userIdCreatedAt` index is non-unique on `$createdAt`, so -multiple requests can share a `$createdAt` at a page boundary. The overlap is -what re-includes them; **`OVERLAP_MS = 0` is an invalid configuration**, not a -tuning knob. Default `10 * 60_000` (copy Android). - -### 4.2 The request query (rs-sdk, stage 1) - -`fetch_received_contact_requests` / `fetch_sent_contact_requests` gain -`after_created_at: Option` + cursor pagination: - -```rust -where: [ toUserId == me, $createdAt > (high_water − OVERLAP_MS) ] -order_by: $createdAt asc // REQUIRED — binds the userIdCreatedAt index and - // avoids the "verified-absent" proof trap -start: StartAfter(last_doc_id) // ephemeral, per-loop pagination cursor -``` - -Two distinct cursors, do not conflate: within-sweep pagination uses -`Start::StartAfter(last_document_id)` (a 32-byte doc id, per-loop); the **durable -high-water** persists `max($createdAt)` (cross-sweep, §4.1). Loop pages until -exhausted (§4.1 rule 1). **Precondition (Q-c, stage 1):** before replacing the -working `limit:100` query, verify on testnet that the paginated `$createdAt > t` -+ `StartAfter` form returns a known existing doc (not a verified-absent empty -proof) — the current query's `order_by` comment documents this exact trap. - -### 4.3 Request sweep flow (platform-wallet `sync_contact_requests`) - -1. Read `high_water_received` / `high_water_sent` (Absent ⇒ full). -2. Fetch received `> (hw_received − OVERLAP)`, paginated; fetch sent likewise. -3. Ingest via the existing path — `newest_received_per_sender` collapse, ignore - suppression, auto-establish. **Idempotency is load-bearing**: the overlap - re-delivers seen docs every sweep, so ingest MUST be a fixpoint (it is). -4. **Per direction, iff its paginate exhausted without error** (§4.1 rule 1): - advance the cursor to the max `$createdAt` *fetched* this sweep (§4.1 rule 2). - On any error, skip the advance for that direction. - -### 4.4 Contact-profile fetch (rs-sdk + platform-wallet, stage 2) - -**Query (resolves Q-c stage 2 + Q-cap).** New `fetch_profiles_for(owner_ids)`: - -```rust -where: [ $ownerId In [id0, id1, …] ] // ≤ IN_CAP ids per query -order_by: [] // EMPTY — unique ownerId index, no trap -start: None // each owner yields ≤1 profile; no pagination -``` - -The `profile` doctype has a **unique single-property `ownerId` index**, so an -`In $ownerId` set lookup proves presence/absence cleanly with **empty -`order_by`** and **no pagination** (mirrors the working `profile.rs` point query, -Equal→In). `IN_CAP = 100` is a **hard cap** enforced at query-build -(`rs-drive/src/query/conditions.rs:361`); the `In` array **rejects duplicates** -(`:368`) and **rejects empty** (`:355`). So the caller **dedups** the id set and -**skips** the query entirely when a chunk (or the whole target set) is empty. - -**Target set (resolves the §4.3-vs-§4.4 contradiction): iterate the FULL set -every sweep, not "touched ids".** Each sweep, collect: -`{ established_contacts[].contact_identity_id } ∪ { incoming_contact_requests[].sender }` -across managed identities, **dedup**, and **skip ids that are themselves managed -identities on this wallet** (their profile is their own `dashpay_profile`, which -is authoritative — see §4.7). The stage-1 "touched this sweep" set is at most a -*fetch-these-first hint*, never the iteration set — the existing aggregator -discards `sync_contact_requests`'s return value anyway, and a "touched-only" set -would break both self-heal and first-run backfill (every pre-existing contact is -uncached but untouched). - -**Filter, then chunk, then fetch:** - -1. Drop ids that are **cached and fresh**, and ids that are **confirmed-absent - and checked recently** (negative cache, see below). What remains is the fetch - set — on first run after upgrade this is *every* contact (the dominant, - expected first-sweep cost; bounded by contact count). -2. Chunk the remaining ids into groups of `IN_CAP`, run one `In` query per chunk. -3. **Per-chunk log-and-continue isolation:** a chunk's fetch/proof failure logs - and continues to the next chunk; the freshness/checked markers advance **only - for ids in successfully-fetched chunks**, never sweep-wide on partial failure. - A persistently-failing chunk must not starve the others. - -**Self-heal & the no-profile negative cache.** A contact may have **no `profile` -document on-platform** (profiles are optional). The `In` query simply omits them. -Without a guard, "cached? false" stays true forever and they're re-queried every -sweep — the unbounded-retry pathology `payment_channel_broken` (G1c) exists to -avoid. So record a **confirmed-absent marker with a checked-at timestamp**; the -fetch set targets "no cached profile **and** not checked within the backoff -window". Self-heal then *is* the normal path (an uncached/expired contact re-enters -the fetch set) — no separate `FixMissingProfiles` loop. - -### 4.5 Profile storage — **Option B** (id-keyed cache) - -A new map on `ManagedIdentity`: - -```rust -pub contact_profiles: BTreeMap, -// ContactProfileEntry = { profile: Option, checked_at_ms: u64 } -// profile: Some(..) = fetched & present; None = confirmed-absent (negative cache) -// checked_at_ms: last fetch attempt, drives the self-heal backoff -``` - -Chosen over a field on `EstablishedContact` because the cache must serve **every -relationship state** — established contacts, **pending incoming-request senders** -(requests screen), and **ignored senders** (future Ignored list) — none of which -share one struct. This is the product decision (§4.6) and matches Android's -relationship-independent `dashpay_profile` table. Plumbing (the `dashpay_payments` -5-site pattern; **the two most-forgotten are the merge rule and the store-side -apply** — miss either and contacts silently vanish on relaunch): - -1. field on `ManagedIdentity`; -2. `IdentityEntry` field + `from_managed` (`changeset.rs`); -3. **merge rule** in `IdentityChangeSet::merge` — per-key last-write-wins (the - `dashpay_payments` merge at `changeset.rs:489-495` is the template); -4. FFI: a **contact-keyed** accessor (distinct from the existing identity-keyed - own-profile one), e.g. `platform_wallet_get_contact_profile(wallet, - owner_identity_id, contact_identity_id) -> profile?`, + an - `IdentityRestoreEntryFFI` field + `restore_contact_profiles` fn - (mirror `restore_dashpay_payments`, `persistence.rs`); -5. SwiftData `PersistentDashpayProfile` keyed by `(ownerId, contactId)` (mirror - `PersistentDashpayPayment`) + the **store-side write/apply**. - -**Boundary invariant:** `contact_profiles` holds **only the five public profile -fields** parsed from the on-chain `profile` document. It must never receive any -field derived from the encrypted `contactInfo.privateData` path (which carries -private relationship state — alias/note/hidden/ignore). Keep these two stores -distinct so the contactInfo migration (Spec 1) can't accidentally cross them. - -### 4.6 Scope & privacy - -**Scope (product decision): established contacts + pending incoming-request -senders now; ignored senders ride the same cache when the Ignored list lands.** -This matches Android's observable behavior (requester names in the request UI). - -**Privacy posture (resolves Q-scope).** Fetching a *pending* sender's profile is a -public read, but issuing `whereIn $ownerId [sender_ids]` right after their -requests land is a query-pattern an observer could correlate with your inbound -set. We **accept** this because the marginal leak is small: the contact-request -documents are *already public* (indexed by `[toUserId, $createdAt]`), and the -DAPI node serving our `toUserId == me` request query — which we must run — -**already learns the entire inbound set**. Fetching those public profiles adds -little. This is materially weaker than the R1 leak (which *creates a new on-chain -document* about a non-contact). Documented and accepted; the R1 track may later -minimize query-pattern metadata if desired. - -### 4.7 Cache write semantics - -- **Full-REPLACE, not merge.** A fetched profile document is the authoritative - *complete* state for that owner; storing it **overwrites** the cached entry via - `profile_from_properties` (full parse). This is the **opposite** of the - own-profile *update* path (`merge_profile_properties`, read-modify-write) — do - **not** reuse that helper here, or a contact who *removes* `avatarUrl` would - keep showing a stale avatar forever. -- **All-empty parse ⇒ confirmed-absent, not cached-present.** A doc that parses to - an all-`None` profile is treated as a negative-cache hit (§4.4), not a fresh - empty profile, so self-heal keeps it honest. -- **Persist only on change.** Compare the fetched profile to the cached one before - writing; emit no changeset when unchanged. This keeps the deferred-Q-inc - "refetch-all each sweep" first cut a **persistence fixpoint** — no write - amplification, the same discipline stage 1 enforces. -- **`avatarUrl` validation at insert.** Validate before caching: **`https://` - scheme only**, length-capped (state the contract's max). Treat the cached url as - **untrusted** input downstream — it is attacker-controlled and the UI will load - it (an unsanitized `http:`/`file:`/`javascript:` url is an SSRF / tracking-pixel - vector; a tracking url tied to your IP confirms "you have this contact"). -- **own-vs-contact authority.** If a target id is itself a managed identity on this - wallet, skip the contact fetch; that identity's own `dashpay_profile` wins. - -### 4.8 Driver wiring (`dashpay_sync.rs`) - -Add `sync_contact_profiles()` as a **distinct** step **between** the existing -`sync_profiles()` (own identities) and `sync_contact_infos()`. It is -**log-and-continue, not error-returning** (matches `sync_contact_infos` / -`reconcile_incoming_payments`): a contact-profile fetch failure degrades *display* -only and must never change the sweep's pass/fail outcome. **Do not** fold it into -`sync_profiles` — that function is scoped to `all_identities()` (own) and writes a -different store. Ordering: it must run **after** `sync_contact_requests` so a -contact established this sweep is fetched the same tick. - -### 4.9 Interactions (specify, don't discover) - -- **Un-ignore resync (deferred to the ignore refactor, but constrained here):** - un-ignore must re-fetch the un-ignored sender's requests. The - ignore/reject tombstone is keyed by `(sender, accountReference)` and **does not - store `$createdAt`**, so a *precise* "rewind the cursor past their `$createdAt`" - is **not implementable from the tombstone alone**. Therefore: **un-ignore ⇒ - clear (reset to Absent) the received cursor** → one full re-fetch (cheap, safe - per §4.1). If a targeted rewind is ever wanted, add `$createdAt` to the tombstone - first. The ignore work owns the call site; this is the mechanism constraint. -- **contactInfo-before-contactRequests ordering:** DIP-15 says fetch contactInfo - first (so contacts don't flicker on `displayHidden`). Out of scope here; noted. -- **Cursor as at-rest metadata:** the high-water timestamps are derived from public - on-chain `$createdAt`, but they are a session-activity residue at rest — exclude - the cursor (and the whole DashPay store) from iCloud backup, since a device-local - ignore/blocklist and activity residue should not sync to backup. - -## 5. Non-goals - -- Changing the sweep cadence. -- The **account** half of `checkDatabaseIntegrity` (we already rebuild contact - accounts) — only the **profile** half is in scope (§4.4 self-heal). -- **Avatar image bytes / rendering** — we cache the fields (`avatarUrl` + - hashes); downloading/showing the image is app-layer (but the url is validated - at cache insert, §4.7). -- **Per-profile `$updatedAt`-incremental refetch (Q-inc).** The composite - `$ownerId In […] AND $updatedAt > marker` is **not provable in one query** (an - `In` on the first index field plus a range on the second isn't a contiguous - index range). The first cut refetches all contact profiles each sweep (bounded - by contact count, and a persistence fixpoint per §4.7); a real incremental would - be per-owner equality (loses the batch) or client-side staleness — a follow-up. - -## 6. Implementation surface - -**Stage 1 (commit 1):** -- `rs-sdk/.../dashpay/contact_request_queries.rs` — `after_created_at` + the - `StartAfter(doc_id)` pagination loop; drop `limit:100, start:None`. -- `platform-wallet/.../network/contact_requests.rs::sync_contact_requests` — - read/advance cursors per §4.1/§4.3 (advance gated on exhaustion + no error). -- `ManagedIdentity` gains `high_water_received_ms` / `high_water_sent_ms` - (`Option`) + `IdentityEntry` + merge + both persisters + FFI restore. - -**Stage 2 (commit 2):** -- `rs-sdk/.../dashpay/` — `fetch_profiles_for(owner_ids)` (empty `order_by`, `In`, - dedup, chunk at `IN_CAP=100`, skip-empty). -- `platform-wallet/.../network/profile.rs` — `sync_contact_profiles` (full-set - target, negative cache, per-chunk isolation, full-replace, persist-on-change, - `avatarUrl` validation); reuse `profile_from_properties`. -- `ManagedIdentity.contact_profiles` + the 5 plumbing sites (§4.5), incl. the - contact-keyed FFI accessor + `PersistentDashpayProfile` SwiftData model. -- `dashpay_sync.rs` — wire `sync_contact_profiles` per §4.8. -- UI bind in the **real** consumers: `Views/DashPay/ContactsView.swift` (list - row name/avatar), `ContactDetailView.swift` (header), `ContactRequestsView.swift` - (requester name/avatar), via the existing `DashPayContactMeta` / `DashPayProfileView`. - (There is **no** `FriendsView`.) - -## 7. Test plan - -**Stage 1:** -- **Incremental:** two sweeps; second issues `$createdAt > hw` and ingests only the - delta (no re-fetch beyond the overlap). -- **No-bury:** 150 requests → all eventually fetched via pagination. -- **Equal-timestamp page boundary:** N>limit requests sharing one `$createdAt` - straddling a page cut → all eventually ingested (pins the overlap as - correctness, not just skew). -- **Partial-page failure:** inject a page-2 error → the cursor does **not** advance - and the next sweep re-fetches from the old high-water. -- **Collapsed-doc reachability:** after a cursor wipe, an older-ref doc that was - collapsed away reappears (proves cursor-loss safety / under-shoot). -- **Restore over-shoot guard:** a restored cursor higher than the restored contact - rows still re-fetches the missing contacts (over-shoot clamped to under-shoot). -- **Idempotency:** overlap re-delivery creates no phantom rows / duplicate writes. - -**Stage 2:** -- **Batch/chunk + dedup:** N>IN_CAP contacts (with a duplicate id) → ⌈N/IN_CAP⌉ - chunked queries, deduped, all cached. -- **First-run backfill:** a wallet restored with M established contacts and zero - cached profiles fetches all M on the first sweep even though stage 1 ingests no - new request. -- **Pending-sender profile:** a pending incoming-request sender's profile is - fetched and reachable via the contact-keyed FFI accessor. -- **No-profile negative cache:** a contact with no on-platform profile is fetched - at most once per backoff window, not every sweep. -- **Chunk isolation:** chunk 2 of 3 fails → chunks 1 & 3 cache, chunk 2's contacts - retried next sweep (not marked done). -- **Shrinking profile (full-replace):** cache a full profile, then ingest a doc - missing `avatarUrl` → cached `avatar_url` becomes `None`. -- **Persist-on-change fixpoint:** a steady-state sweep with unchanged profiles - writes zero changesets. -- **avatarUrl validation:** a profile with a non-`https` url is rejected/sanitized - at cache insert. -- **own-vs-contact:** a contact that is also a managed identity resolves to the - own `dashpay_profile`, not a duplicate contact fetch. -- **Round-trip:** a contact profile survives relaunch (changeset → persister → - restore), like `dashpay_payments`. - -## 8. Open questions (most resolved by the review) - -- **Resolved — Q-a** (cursor storage): two scalar `Option` fields on - `ManagedIdentity` (not a table). -- **Resolved — Q-store:** Option B (id-keyed `contact_profiles`), per the - product decision (§4.5/§4.6). -- **Resolved — Q-scope:** established + pending senders; privacy accepted (§4.6). -- **Resolved — Q-c:** stage-1 keeps `order_by $createdAt`; stage-2 uses empty - `order_by` on the unique `ownerId` index, no pagination. (Stage-1 paginated - form still needs the one-time testnet proof check, §4.2.) -- **Resolved — Q-cap:** `IN_CAP = 100`, dedup, skip-empty. -- **Resolved — Q-inc:** not provable as a single batch query; deferred (§5). -- **Open — Q-b:** `OVERLAP_MS = 10 min` (copy Android) — keep, but confirm it - comfortably exceeds observed platform time-skew; **must stay > 0** (§4.1). -- **Open — Q-backoff:** the no-profile negative-cache recheck interval (§4.4) — - propose "once per N sweeps" or a wall-clock window; pick during impl. -- **Open — Q-checked-clock:** the `checked_at_ms` backoff may use wall-clock - (acceptable — it gates re-query cost, not cursor correctness) vs a sweep - counter; decide during impl. - -## 9. Review resolutions (traceability) - -Folded in from the 5-lens review (feasibility / scope / adversarial / security / -flow). The load-bearing changes vs the first draft: - -- **Cursor advance invariant rewritten** (§4.1) — advance only on error-free - *exhausted* pagination, over docs *fetched* (not *applied*), never wall-clock, - under-shoot-only on restore, overlap mandatory. Closes the two CRITICAL burying - holes (advance-past-failed-page, advance-past-collapsed-doc). -- **Cursor storage simplified** to two scalar fields, not a table (Q-a). -- **Stage-2 query shape resolved from the contract indices** (§4.4) — unique - `ownerId` index ⇒ empty `order_by`, no pagination, `IN_CAP=100`, dedup, - skip-empty (Q-c, Q-cap). Q-inc shown unprovable as a batch. -- **Stage-2 negative cache + per-chunk isolation + full-replace + - persist-on-change** added (§4.4/§4.7) — closes infinite-refetch, partial-failure - starvation, stale-field, and write-amplification holes. -- **Target set = full set (established + pending), every sweep** (§4.4) — closes - the §4.3-vs-§4.4 contradiction and the first-run-backfill gap. -- **Storage = Option B** with the full 5-site plumbing called out (merge rule + - store-apply emphasized), public-data boundary (§4.5). -- **avatarUrl validation** + **privacy posture for pending-sender fetch** (§4.6/4.7). -- **Driver hook pinned** as a distinct log-and-continue step (§4.8); **UI surface - corrected** to the real views (no `FriendsView`). -- **Un-ignore = clear-cursor** because the tombstone lacks `$createdAt` (§4.9). diff --git a/docs/sdk/CODE_REVIEW_NOTES.md b/docs/sdk/CODE_REVIEW_NOTES.md deleted file mode 100644 index af6f1b20b0f..00000000000 --- a/docs/sdk/CODE_REVIEW_NOTES.md +++ /dev/null @@ -1,214 +0,0 @@ -# Final multi-agent code review: Kotlin DashPay registration keys - -**Review date:** 2026-07-21 -**Branch:** `feat/kotlin-sdk-dashpay-registration-keys` -**Implementation base:** `6efa83bb53` -**Reviewed implementation commits:** `70b0852edb`, `3a64940af8`, -`c31ca592d4`, `4a25717471`, plus the concurrently-added persistence refactor -`5147d5baa9` - -## Review method - -Three independent agents reviewed the actual diff and call graph under separate -lenses, followed by a primary-agent source audit and full build/test run: - -1. Rust wire format, registration invariants, FFI callers, and consensus-facing - key policy. -2. Kotlin derivation/persistence/zeroization lifecycle, funding policy, resume - exclusion, and the helper-reuse decision. -3. Cross-language fixture and edge-case test adequacy, including the invitation - caller and environment-bound on-chain assertion. - -The agents were instructed to review only and made no edits or commits. While -the review was running, `5147d5baa9` was committed by an external concurrent -session; it was reviewed as part of the resulting branch head and was not -rewritten. - -## Verified correct in the implementation - -- `parse_pubkey_rows` is a pure `&[u8]` parser. JNI conversion is confined to - thin update/registration adapters. -- Empty-list, duplicate-key-ID, and key-ID-0 = MASTER + AUTHENTICATION checks - are registration-only. The shared update parser still accepts ordinary - add-key lists without key ID 0. -- All four Android registration JNI exports use the rich registration decoder: - resume, Core-funded, Platform-address-funded, and shielded-from-pool. -- The fifth `decode_identity_pubkeys` caller, invitation claim, still uses the - shared FFI decoder and therefore receives duplicate-ID rejection without - receiving the JNI-only key-ID-0 policy. -- `role_for_registration_key_id` and `decode_pubkeys_blob` are deleted, not - merely superseded. -- Kotlin explicitly rebuilds the base roles as MASTER/AUTHENTICATION, - CRITICAL/AUTHENTICATION, HIGH/AUTHENTICATION, and CRITICAL/TRANSFER. -- Keys 4/5 are ECDSA secp256k1, ENCRYPTION/DECRYPTION, MEDIUM, writable, and - bounded to the canonical DashPay contract's `contactRequest` document type. -- Fresh registration performs one six-key derivation pass. It does not derive - an overlapping base-four set first. -- Resume derives and submits only the base four keys. The DashPay pair is not - added to an already-funded asset-lock resume. -- The checked-in golden binary is genuinely shared: Kotlin compares its encoder - output to that exact resource, while Rust includes and decodes the same file - and pins its contract ID to `dashpay_contract::ID_BYTES`. -- Strict codec coverage includes truncation, legacy layout, trailing bytes, - invalid bounds/boolean bytes, negative IDs, interior NUL, duplicate IDs, and - update-without-key-0 behavior. FFI tests cover invalid DPP role bytes. - -## Findings fixed during this review - -### 1. Private material survived two failure windows - -`IdentityKeyPreview.decodeAll` wiped the JNI blob only after a complete parse. -A malformed/truncated blob could leave both the source blob and already-copied -private scalars unsanitized. Separately, a blocking JNI preview could finish -after its coroutine was cancelled; `withContext` would then discard the -secret-bearing result before the caller reached provisioning cleanup. - -Fix: - -- Decode under `try/catch/finally`, wiping the source blob on every exit and - wiping all partially decoded private arrays on failure. -- Add `opWithCleanupOnCancellation`, which scrubs a completed result if prompt - cancellation discards it during dispatcher handoff. -- Route both registration preview APIs through that cleanup-aware gate. -- Add malformed-blob and blocking-JNI cancellation regressions. Both tests were - observed failing before the production fix and passing afterward. - -### 2. Six-key Core funding minimum was not enforced - -The app accepted any numeric Core amount even though six creation keys raise -the protocol floor from 228,000 to 241,000 duffs. A below-floor asset lock can -be broadcast before Platform rejects the identity create. - -Fix: - -- Mirror `IdentityCreateTransition::calculate_min_required_fee_v1` for the - current key count. -- Use one amount-policy function for both submit enablement and the click-time - preflight. -- Add boundary tests for the four-key/six-key floors and the resume no-new-funds - case. The new test was observed failing before the implementation and passing - afterward. - -### 3. Invitation claim lacked its required caller-level regression - -The existing duplicate-ID unit test called `decode_identity_pubkeys` directly -and only stated in a comment that invitation claim used it. It did not exercise -the fifth caller itself. - -Fix: - -- Add a valid-invitation/duplicate-row test through - `platform_wallet_claim_invitation`. -- Assert duplicate rejection happens before wallet lookup, signer use, or - network work, and that output sentinels remain zeroed. - -### 4. Coverage and documentation were weaker than the implementation claims - -Fix: - -- Correct the Rust module documentation: the pure structural parser does not - validate DPP role discriminants; the downstream FFI conversion does. -- Add direct invalid key-type/purpose/security-level FFI coverage and a - MASTER-with-wrong-purpose registration invariant test. -- Strengthen Kotlin tests to prove the exact public-key storage key, non-zero - scalar at persistence time, wallet ownership, post-persist scrubbing, - DashPay key type/purpose/security/read-only flags, and exact bounds. -- Correct the helper rationale: the add-key flow persists before broadcast; - the real incompatibilities are its existing-key/max+1 update policy and - per-slot derivation, versus registration's fixed 0..N batch policy. - -## PR #4173 automated-review follow-up - -The first automated PR review found three additional blocking regressions. All -three were independently traced to the current source and fixed: - -1. **Mutable controls could redirect an in-flight registration.** The click - handler captured the key-count choice before suspension but later reread - `fundingSource` and `selectedRecoveryLock`. It now resolves source, amount, - identity index, and a defensive copy of the selected lock into one immutable - submission snapshot before the coroutine starts. Preparation, dispatch, - coordinator tracking, and navigation use only that snapshot. A regression - mutates the backing form values and lock txid after capture and verifies the - submission is unchanged. -2. **Platform-address packing did not reserve the six-key creation fee.** The - default native strategy deducts the fee from the post-spend remainder of - BTreeMap input 0. The packer now mirrors the consensus formula - `2,000,000 + 6,500,000 * keyCount + 500,000 * inputCount`, models Rust's - `(address type, unsigned hash)` ordering, and selects the smallest input set - that contributes the requested identity balance while leaving the full fee - on input 0. This preflight runs before key derivation/persistence. The - reviewer's 70M-balance/30M-spend case was observed failing before the fix; - exact one-input and two-input boundaries are also covered. -3. **Resume lost its HD-slot provenance check.** Rich consensus rows do not - carry a derivation index, so replacing `IdentityKeyPreview` had deleted the - old per-key guard. Provisioning now returns a `RegistrationKeySet` that keeps - the common preview identity index beside the rich rows, rejects mixed-slot - previews before persistence, and resume verifies the set index matches the - tracked lock before JNI. The wrong-slot regression was observed calling JNI - before the fix and passing afterward. - -The full Kotlin SDK/app build, unit-test suites, and instrumented-test-source -compilation passed after these fixes. The PR's initial emulator check hit the -workflow's documented Android Keystore unlock-state race; its failed-job rerun -passed the complete native build and API-35 instrumented suite without a code or -workflow change. - -## Left for human judgment or environment-bound verification - -1. **On-chain bounds assertion is still manual.** No unit test can prove what a - testnet node persisted. Register a fresh identity, fetch it, and assert keys - 4/5 contain the exact DashPay contract ID plus `contactRequest` bounds. Then - run Add Contact as a separate smoke test; Add Contact success alone is not - proof of the bounds. -2. **Public Kotlin/logical wire compatibility.** Public fresh-registration - methods now consume rich `IdentityPubkey` lists, resume consumes a - provenance-carrying `RegistrationKeySet`, and the opaque `byte[]` layout - changed without a version/magic prefix. The parser fails closed on - legacy/malformed shapes, but an old Kotlin artifact must not be paired with - the new native library. Release coordination or an explicit compatibility - layer remains a product/API decision. -3. **No full Compose four-way dispatch seam test.** Static tracing confirms all - three fresh paths pass the same six-row list and resume passes the four-row - list; policy tests cover the funding-source gate and immutable submission - snapshot. Extracting a larger pure screen-dispatch seam solely for - orchestration testing is a maintainability choice, not a discovered - production defect. -4. **Kotlin still trusts Rust's preview row keypair correspondence.** Swift - independently revalidates private/public correspondence. Kotlin documents - why it does not duplicate private-key math; whether to add an equivalent - native validation surface remains a parity-hardening decision. -5. **No existing-identity backfill.** Already-created Android identities still - need the Add Identity Key flow. This remains the reviewed non-goal. -6. **Dedicated provisioning helper.** Keeping `DashpayKeyProvisioning` is - justified by the fixed registration IDs and one batch derivation. Commit - `5147d5baa9` shares the dangerous persist-and-scrub primitive with - `IdentityKeyAdditionFlow`, avoiding lifecycle duplication while preserving - the distinct policies. - -## Verification results - -All required local verification passed: - -```text -cargo test -p rs-unified-sdk-jni --lib - 29 passed - -cargo test -p platform-wallet-ffi --lib - 201 passed - -env RUSTC_WRAPPER= cargo clippy --workspace --all-features - passed (the configured sccache was not permitted in the sandbox) - -cargo fmt --check --all - passed - -JAVA_HOME=/opt/homebrew/opt/openjdk@17 \ -ANDROID_HOME=/opt/homebrew/share/android-commandlinetools \ -./gradlew :sdk:assembleDebug :sdk:testDebugUnitTest \ - :app:assembleDebug :app:testDebugUnitTest \ - :sdk:compileDebugAndroidTestKotlin - passed -``` - -Testnet/device registration, the on-chain bounds assertion, and Add Contact -were intentionally not attempted because they are environment-bound. diff --git a/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md b/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md deleted file mode 100644 index 25aed864dd6..00000000000 --- a/docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md +++ /dev/null @@ -1,705 +0,0 @@ -# Kotlin/Swift SDK parity and shared-logic consolidation - -**Status:** REVIEWED v2 — approved after Swift/Rust FFI, Android/JNI/Room, and -adversarial architecture reviews -**Baseline:** PR #3999, `feat/kotlin-sdk-and-example-app` at -`6dbc72a54df72d26eb9c4a014b425d2b95134e4e` -**Scope:** `rs-platform-wallet`, `rs-platform-wallet-ffi`, `rs-unified-sdk-jni`, -`swift-sdk`, `kotlin-sdk`, SwiftExampleApp, and KotlinExampleApp -**Related specifications:** `docs/dashpay/DIP15_INVITATIONS_SPEC.md`, -`docs/dashpay/KOTLIN_MIGRATION_SPEC.md`, -`docs/dashpay/KOTLIN_MIGRATION_FOLLOWUPS_SPEC.md`, and -`docs/dashpay/PENDING_CONTACT_CRYPTO_RELOCATION_SPEC.md` - -**Implementation state on this branch:** partial, not release-complete. The -release-blocker slices are implemented at source/test level, but the executable -manifest remains authoritative for unclosed device, process-restart, and legacy -store gates. Android invitations and the S1–S4 shared-policy moves are explicitly -permitted follow-ups; this commit must not be presented as completing those later -slices. - ---- - -## 1. Problem - -PR #3999 establishes an Android/Kotlin SDK and ports SwiftExampleApp. The port is -large enough that file- or screen-count parity is no longer a reliable correctness -measure. Review found three kinds of drift: - -1. Host persistence callbacks do not implement the same Rust persistence contract. - Some Android omissions disable shared features; one Android callback writes - incorrect authoritative data. -2. Protocol or wallet policy is duplicated in Kotlin and Swift even when Rust - already owns, or should own, the rule. The copies have started to diverge. -3. The parity document records source-file presence rather than executable feature - capability, restart behavior, and protocol-domain coverage. - -The goal is not literal host-code equality. Kotlin/Swift UI, lifecycle, secure -storage, and database adapters remain platform-native. The goal is one shared -implementation of protocol/wallet decisions and equivalent host persistence, -recovery, and example-app capability. - -### 1.1 Baseline and already-integrated work - -Implementation starts from exact PR head `6dbc72a54d` or a descendant. That head -already contains these squash integrations: - -- `516b265bf5` / #4106 — shared wallet deduplication and dead FFI/JNI removal; -- `f74465227f` / #4041 — shared and iOS DIP-13 invitation implementation; -- `97477d1c88` / #4093 — generalized existing-asset-lock top-up and iOS resume; -- `aba6af2420` / #4126 — shared and iOS Orchard viewing-key persistence. - -The `refactor/platform-wallet-dedup`, `feat/dip15-dashpay-invitations`, and -`feat/kotlin-sdk-dashpay-migration` worktrees are historical design/review -references. They are behind the baseline and **must not be merged or cherry-picked**. -This effort adds the missing Android adapters and new shared APIs on the exact -baseline while preserving all later seed-binding, provider, and rollback fixes. - -## 2. Non-negotiable invariants - -1. **Rust owns protocol and wallet policy.** Coin selection, reservation, - authorization, pricing, network endpoint interpretation, protocol constants, - proposal-rule selection, and recovery state machines must not be independently - reimplemented by Kotlin and Swift. -2. **Host bridges adapt; they do not orchestrate.** JNI and Swift wrappers may map - types, dispatch queues, callbacks, cancellation, and errors. A host wrapper must - not compose multiple fallible Rust calls into a new wallet transaction state - machine. -3. **Persistence contracts are capability-checked.** Backends attest individual - capabilities such as atomic changesets, invitations, Orchard FVKs, provider - restore, and deferred contact crypto. A feature requiring durability must fail - closed before broadcast when any required capability is absent. A present no-op - callback violates the contract. -4. **Persisted identity is immutable.** Balance/update callbacks may not rewrite an - address's derivation identity unless the callback explicitly represents a - derivation-map mutation. -5. **Protocol integer domains survive every ABI.** Every protocol field whose valid - consensus domain spans full `u64` must round-trip across Kotlin and Swift. A - narrower carrier is permitted only where Rust enforces a narrower consensus - bound. -6. **Recovery is part of feature parity.** A flow is not “ported” until it can resume - after process death at every point where durable state or on-chain value exists. -7. **Parity is executable.** Every capability row names an automated test or an - explicit device/testnet gate. Counts derived from source files are informational - only. - -## 3. Ownership boundary - -| Concern | Shared Rust | Host SDK | Example app | -| --- | --- | --- | --- | -| Transaction input selection and reservation | Own | Invoke one composite API | Collect user intent | -| Masternode endpoint discovery | Own | Map endpoint result | Display diagnostics only | -| Token authorization, group rules, price quote | Own | Map typed decision/quote | Render decision and collect inputs | -| Protocol constants and amount validation | Own | Preserve exact domain | Format values | -| Recovery state machine | Own | Persist/restore required records and invoke resume | Present resumable operations | -| Persistence schema and callbacks | Define callback semantics/capabilities and ABI version | Implement in Room/SwiftData/Keychain/Keystore | Never bypass SDK persistence | -| UI/navigation/lifecycle | No | Expose async/cancellation-safe API | Own | - -The following duplication is intentional: Room versus SwiftData entities and DAOs, -Android Keystore versus Apple Keychain, Compose versus SwiftUI, and ABI type mapping. -Those implementations must nevertheless satisfy the same Rust callback semantics. - -## 4. Correctness blockers - -**Current status (manifest reconciliation):** C1-C3 are the release-blocker -slices described in the implementation-state note above as implemented at -source/test level. For Kotlin, the manifest marks all three SDK capabilities -supported: C1 (`persistence.platform_address_identity`) has a restart-covering -Room regression; C2 (`core.atomic_send`) has shared reservation/failure unit -coverage plus the host `CORE-05` manual case, with restart correctly marked -`not_applicable`; and C3 (`tokens.full_u64_domain`) has Kotlin/JNI/Room/app -coverage plus a restart-covering device migration test. Swift still has open -restart gates for C1 and C3, so the manifest remains authoritative. The -“Required change” text below is retained as historical design rationale, not as -a claim that these Kotlin changes remain unimplemented. - -### C1 — Android platform-address balance persistence corrupts derivation indices - -The Android balance callback currently copies callback `accountIndex` and -`addressIndex` into the durable address row. Conflict-removal events may carry the -index of a competing address while intentionally leaving the authoritative -address/index bijection unchanged. Swift already ignores those two callback fields. - -**Required change** - -- Update only balance, nonce, used state, and height in the Android balance path. -- Preserve the stored account/address indices for an existing address. -- Add a regression test that seeds address A at index A, delivers a zero-balance - callback for A carrying index B, restarts/restores, and asserts A still maps to A. -- Document the callback semantics beside the JNI callback declaration. -- C1 is prevention-only on the unreleased PR #3999 baseline and requires no Room - migration. If a distributed beta database must later be repaired, recovery must - come from a Rust-authoritative address-pool re-emit, never SQL or host parsing of - derivation paths. - -**Acceptance:** the pre-existing row's account/address tuple and derivation path are -unchanged; Rust's restored bijection maps the canonical address at index A; and a -later valid credit to A is accepted. Database-level uniqueness or cleanup of benign -zero-balance conflict remnants is not required. - -### C2 — Core transaction construction is not atomic - -The shared builder separates funding from signing. Two concurrent calls can select -the same UTXO. Android serializes its wrapper, but Swift exposes the split public API -and other consumers can bypass the Android mutex. - -**Required change** - -- Add the composite to `rs-platform-wallet` first and expose it through thin C/JNI - adapters. Selection and recording in the account `ReservationSet` must be one - indivisible operation: no competing selection may observe the chosen inputs as - available. -- The design must not depend on holding the wallet-manager lock across a host - mnemonic-resolver callback. If key-wallet cannot reserve before signing, add the - required reservation primitive there or use an explicit per-wallet atomic gate in - platform-wallet. -- Add a finalize API that consumes an unfunded/configured builder, atomically funds - and reserves it, then signs. Validation/signing failure and explicit abandon - release the reservation; definitive broadcast rejection releases it; ambiguous - `MaybeSent` retains it under the existing TTL/reconciliation policy. -- Return a new opaque/V2 signed-transaction handle containing fee, funding account, - and reservation metadata. Do not extend `FFICoreTransaction` in place or alter an - existing C layout. Add explicit broadcast and abandon functions. -- Route Kotlin and Swift convenience sends through the composite. Deprecate the old - split builder symbols and public Swift sequence without removing their ABI in this - release. - -**Acceptance:** a barrier-forced concurrent same-UTXO test produces disjoint -reservations or one typed reserved/insufficient-funds failure. Tests also cover -validation/sign failure, explicit abandon, definitive rejection, ambiguous send, -builder consumption, and double-free safety. - -### C3 — Host SDKs preserve protocol `u64` values end to end - -Kotlin previously narrowed token amounts and direct-purchase costs to signed -`Long`. SwiftData uses its original signed `Int64` balance column as a -schema-neutral raw-bit carrier and exposes unsigned values at the SDK/UI boundary; -no alternate iOS balance column or migration-only code path is introduced. - -**Required change** - -- Public Kotlin token APIs use `ULong` (or one `TokenAmount` value type backed by - `ULong`). Internal native declarations remain `Long`/`jlong` raw-bit carriers so - existing JNI names and descriptors remain stable. Kotlin passes `value.toLong()`; - Rust reinterprets the bits with `as u64` and does not reject the sign bit. -- `ULong` and `Long` erase to the same JVM carrier. Any deprecated checked-`Long` - compatibility adapter must therefore have a distinct Kotlin name or explicit - `@JvmName`, reject negative values before the raw-bit native call, and be listed in - a per-method compatibility table. -- Java callers use one documented `BigInteger` or eight-byte adapter layered on the - same native path. Do not create a parallel native implementation. -- Audit mint, burn, transfer, set-price, purchase amount/cost, max supply, distribution - values, results, persistence, comparisons, and UI formatting. Preserve the existing - unsigned-decimal max-supply JSON behavior. -- In Room v5, replace signed SQL semantics such as `TokenBalanceEntity.balance` plus - `WHERE balance > 0` with an order-preserving unsigned representation. Use a fixed - eight-byte big-endian BLOB (lexicographically unsigned-order-preserving) and - explicit zero comparison, or document an equally lossless/order-preserving schema; - values at/above `2^63` must not disappear from DAO results. -- Separate codec-boundary tests (`0`, `Long.MAX_VALUE`, `2^63`, `u64::MAX`) from - operation semantics where zero may still be invalid. - -**Acceptance:** every full-domain token `u64` round-trips through JNI, Room, DAO -queries, and UI formatting without loss or signed-order errors. - -Direct-purchase quotes additionally follow Drive's operation semantics rather -than applying a generic full-`u64` arithmetic rule: amount is limited to DPP's -`2^48 - 1` distribution maximum; single-price multiplication saturates at -`u64::MAX`; set-price multiplication rejects overflow; and a configured zero -price remains valid. - -**Kotlin source-compatibility accounting:** PR #3999 introduces an unreleased -Kotlin SDK surface, so the signed declarations below have no published consumer -contract to deprecate. They are intentionally corrected in place; adding signed -overloads would preserve an invalid negative-value domain and, because `Long` and -`ULong` erase to the same JVM carrier, would complicate the public ABI. Java uses -`JavaTokenActions`/`BigInteger`; JNI descriptors remain unchanged. - -| Kotlin operation | Corrected parameter(s) | Compatibility disposition | -| --- | --- | --- | -| `mint`, `burn`, `transfer` | token amount `Long` → `ULong` | unreleased source break; same raw `jlong` JNI ABI | -| `setPrice` | price `Long` → `ULong` | unreleased source break; same raw `jlong` JNI ABI | -| `purchase` | amount and expected cost `Long` → `ULong` | unreleased source break; same raw `jlong` JNI ABI | -| `updateConfig` | optional max supply `Long?` → `ULong?` | unreleased source break; JSON/native encoding remains unsigned | -| distribution/claim numeric values | protocol `u64` values → `ULong` | unreleased source break; Java checked adapter where exposed | - -## 5. Android persistence and restart parity - -### P1 — Orchard full-viewing-key persistence - -Implement Room storage and JNI callbacks for persist/load/free of shielded viewing -keys, matching the existing Swift persistence contract. - -The exact shared preflight is `atomic_changesets + shielded_viewing_keys`. The -`shielded_viewing_keys` bit is backend-attested and then intersected with the -complete persist/load/free callback triplet; generic wallet-list `wallet_restore` -is not part of this contract because seedless rebind loads FVK rows directly. - -- Key rows uniquely by `(walletId, accountIndex)`; wallet IDs are already - network-specific, so do not add a redundant network key. -- Store exactly 96 FVK bytes. A present malformed row fails closed instead of - silently falling back to the mnemonic. -- Implement callback allocation/free pairing, duplicate-account upsert, wallet purge, - and wrong-wallet/network isolation. -- Reserve Room schema v6 for this entity and export its schema JSON. - -**Acceptance:** create/bind shielded state, terminate, make the mnemonic unavailable, -restart, and successfully rebind/sync from the stored viewing key. Unit/instrumented -tests cover corrupt length, multiple wallets/accounts, callback allocation/free, and -wallet deletion. - -### P2 — Invitation persistence and Android invitation UX - -The shared/iOS invitation protocol, durability ordering, and reclaim semantics are -defined by `DIP15_INVITATIONS_SPEC.md`; this specification does not fork them. - -Implement Android: - -- Push-only invitation persistence callbacks and Room UI schema, including failure - propagation. Rust does not rehydrate the invitation list; tracked asset-lock - restore preserves reclaimability. -- Create, parse/claim, sent-invitations, and reclaim SDK wrappers. -- Deep-link/QR handling with the canonical legacy-compatible envelope. -- Create, Claim, Sent Invitations, and Reclaim screens. -- Test-plan cases `DP-12...DP-19`, including interrupted create/reclaim and - already-consumed ambiguity. - -Creation must remain gated by Rust's exact invitation capability set: -`atomic_changesets + asset_lock_funding_indices + invitations + wallet_restore`. -The narrower `persists_durably()` compatibility wrapper is not sufficient for new -feature code. A no-op callback is not an acceptable compatibility mode. A callback -failure returns nonzero inside the changeset begin/end round and rolls it back. -Persist `reclaimInFlight` transactionally before consume; write `Reclaimed` -only after observed success and use the canonical conservative `Claimed` ambiguity -classification. Room is v8 in the serialized migration chain. Purge by wallet and -never log URI/WIF secrets. - -### P3 — Provider-special-transaction restoration - -Android already stores raw transaction bytes, but payload-only provider transactions -create no TXOs. A TXO join therefore cannot reconstruct wallet/account ownership. - -**Required change** - -- Preserve existing C layouts: `TransactionRecordFFI` already carries - `block_position`/`has_block_position`, and `AccountChangeSetFFI` already surrounds - its transactions with the full typed account identity. Change only the JNI/Kotlin - transaction callback parameters so each call explicitly forwards the enclosing - account fields and the transaction's existing position fields. Do not add C POD - fields or rely on mutable “current account” callback state. -- Add nullable/default block-position columns and a transaction↔account involvement - cross-reference. Reserve Room v7 for these additions and exported schema. -- On cold load, select provider transaction kinds 2...5 through wallet/account - involvement, marshal raw consensus bytes plus context/block metadata into the - existing `ProviderSpecialTxRestoreEntryFFI`, and keep every backing byte buffer - alive until the restore release callback. -- Rust re-decodes the payload and rebuilds ownership/masternode grouping; decoded - host columns are not restore authority. - -**Acceptance:** a payload-only provider transaction belonging to wallet A restores -only to A, survives without a TXO, preserves same-block ordering and optional block -position, and malformed raw data is diagnosed/skipped without crashing. - -### P4 — Deferred contact-crypto queue - -Do not add write-only host persistence. Implement restore in the shared wallet -start-state path first, then add the callback contract and both Room and SwiftData -stores in the same slice. After the relocation described by -`PENDING_CONTACT_CRYPTO_RELOCATION_SPEC.md`, restore hydrates identities first and -then fans rows into the owning `ManagedIdentity` across both identity buckets. -Unknown-owner rows are retained/quarantined or explicitly diagnosed, never silently -dropped. Define POD ownership/free semantics, idempotent operation identity, clear -tombstones, wallet scoping, and corrupt-row behavior. Until then, document that the -recurring sweep re-enqueues work and parity is delayed rather than durable. - -`PersistenceCallbacks` currently has no struct-size negotiation. New callback slots -must use a versioned `PersistenceCallbacksV2`/constructor unless the release explicitly -declares framework and wrapper lockstep source ABI. In either case add cbindgen header, -C layout, and Swift `MemoryLayout` pins; never silently insert fields into the current -layout. - -## 6. Recovery parity - -### R1 — Existing asset-lock resume on Android - -Generic Platform-address (`ADDR-03`) and shielded resume are already implemented and -remain unchanged. Bridge only the missing identity operations: - -- `platform_wallet_resume_identity_with_existing_asset_lock_signer` for registration; -- `platform_wallet_topup_identity_with_existing_asset_lock_signer` for top-up. - -Also bridge tracked-lock enumeration/status (with paired array free) so UI rows are -not reconstructed from private Room assumptions. Eligible generic rows have funding -type registration (`0`) or top-up (`1`/`2`) and a resumable status from Built through -ChainLocked (`0...3`). Funding type invitation (`3`) is never offered by generic -recovery. The shared wallet retains a consumed lock as a terminal tombstone so an -exact-outpoint retry remains a typed already-consumed error in the same process and -after restoration; actionable recovery lists exclude status `4`. - -The registration result's managed-identity handle must be adopted immediately and -freed on every post-call failure. Both operations borrow the mnemonic resolver under -the manager teardown gate. The Android UI uses the same restored outpoint and never -creates a second funding transaction. Generic paths always pass -`consumeInvitationVoucher=false`; only P2 reclaim may pass `true`. - -**Acceptance:** separate registration-resume and top-up-resume coverage (`ID-16` -covers top-up), interruption immediately after Core broadcast and before Platform -submission, and typed handling of untracked, foreign, and already-consumed locks. A -`Built` row re-broadcasts the same transaction and never creates a second distinct -funding transaction. A type-3 row is rejected by generic resume. - -### R2 — Compact-filter rescan on Android - -Expose the shared SPV rescan operation through JNI/Kotlin and add the height picker -and rescan state to the Android sync screen. The call rewinds an in-memory compact -filter checkpoint; it does not itself scan. A running SPV manager acts on the next -tick, a stopped manager acts on next start, equal/forward requests are harmless -no-ops, and unknown wallets return typed errors. Per-wallet failures are collected. -The rewind is not durable: process death before the filter loop consumes and persists -progress loses the rescan request, so the host/user must reissue it. Correct the -misleading shared Rust documentation when R2 is implemented. Do not promise -cancellability or durable rewind without adding a durable rescan-intent contract. - -### R3 — Contested usernames by identity - -Bridge both existing shared operations: - -- `platform_wallet_sync_contested_dpns_names`, which performs one network fetch and - full-snapshot persistence so resolved contests disappear; -- `managed_identity_get_contested_dpns_names`, which returns the cached array, with - its paired free function. - -Alternatively add one Rust composite returning an owned `DpnsNameArray`. Replace -Android's bounded local-label probing with this path. - -**Acceptance:** an identity with more than eight locally unknown contested names is -shown completely with one logical query. - -## 7. Shared-policy consolidation - -### S1 — Masternode discovery - -Discovery currently bootstraps DAPI before an SDK handle exists. Add a standalone -shared Rust entry point taking `(network, quorum_base)` and returning owned typed -records containing both Core peer address and DAPI URL, with an explicit free -function; alternatively move discovery into Rust SDK construction and expose its -cached result. Extract a pure endpoint parser in -`rs-sdk-trusted-context-provider` so the provider and bridge share one parser. - -Define explicit-configuration precedence, HTTP status/timeout/failure fallback, -version/status filtering, string ownership, testnet's missing-port default (`1443`), -bracketed IPv6, and malformed/missing port behavior. Delete Kotlin and Swift host -fetch/parsing only after both consume the shared result; hosts must not fetch twice. -Replace the Kotlin test that currently pins the incorrect `443` default. - -### S2 — Funding selection and account scope - -This concerns DIP-17 Platform credit addresses and nonces, not Core UTXOs from C2. -Add separate account-scoped Rust composites for identity registration/top-up from -Platform addresses. Inputs are `(wallet_id, PlatformPayment account_index, target -credits)`. Rust enumerates hydrated and derived candidates, fetches authoritative -balances/nonces, applies deterministic selection and fee constraints, signs/submits, -and returns the result. - -If multiple Platform Payment accounts exist, the account index is mandatory. Tests -cover only-the-chosen-account, fresh-restart hydrated candidates, exact target, -insufficient funds, and concurrent balance/nonce revalidation. Delete every -Kotlin/Swift pre-enumeration and greedy packing loop in the adopting slice. - -### S3a — Token authorization and proposal evaluation - -After C3, expose a versioned Rust decision result for action + token configuration + -actor context containing allowed/denied, a stable reason discriminant, and zero or -more authorization alternatives/group rules. Include every group-capable action, -especially `maxSupplyChangeRules`; do not collapse alternatives into one “required -key.” Define stable `repr` discriminants or versioned JSON plus typed host decoding -and owned-array/free rules. - -### S3b — Direct-purchase quote - -Expose a separate Rust quote result for schedule + amount containing selected -threshold, unit price, and full-domain `u64` total. Both apps render this result and -delete their local tier/price arithmetic. Rust remains authoritative at broadcast. - -### S4 — Protocol constants and codecs - -Expose versioned consensus identity-funding denomination sets separately from purely -presentational UI presets. Address validation returns typed family, network, payload, -and failure reason rather than `Bool`. Invitation validation already exists in -`platform_wallet_parse_invitation`; Android bridges that API rather than creating a -second codec. Remove hard-coded protocol tables and app-local Base58/Bech32 validators -only when the shared replacements are adopted. - -## 8. Immediate example-app parity fixes - -These are independent, low-risk fixes and do not wait for the larger shared APIs: - -1. Add `DedicatedTransition.CREATE_DOCUMENT`, route Android `documentCreate` to the - existing `CreateDocument` screen using selected contract/type, and remove the - unused `documentFields` catalog input unless it is passed as a real prefill. -2. Include `maxSupplyChangeRules` in Kotlin and Swift pending-proposal discovery - as an explicitly temporary compatibility fix; delete it when S3a replaces host - discovery entirely. -3. Display immature Core balance in Swift WalletDetail and do not label a wallet - containing only immature funds “Empty Wallet.” -4. Correct parity rows for document transitions, invitations, rescan, recovery, and - contested-name discovery. - -## 9. Executable parity manifest - -Replace manually maintained totals with a checked-in manifest. Each capability has: - -```yaml -id: invitations.reclaim -shared_apis: - - platform_wallet_topup_identity_with_existing_asset_lock_signer - - platform_wallet_resume_identity_with_existing_asset_lock_signer -required_persistence_capabilities: - - atomic_changesets - - asset_lock_funding_indices - - invitations - - wallet_restore -hosts: - swift: - sdk: supported - example_app: supported - restart: tested - reason: null - kotlin: - sdk: unsupported - example_app: unsupported - restart: required - reason: "P2 not implemented at the PR #3999 baseline" -verification: - - host: swift - kind: manual - file: packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md - id: DP-19 -``` - -Allowed host states are `supported`, `partial`, `unsupported`, and -`not-applicable`. A capability can be `supported` only when: - -- all listed shared APIs are reachable, when the capability needs shared APIs; -- required persistence capabilities are registered; -- restart behavior is tested when value or durable state can exist; -- its automated verification exists and passes, or the manifest records a release - manual/device gate. - -`shared_apis` is optional because some capabilities are host-only. Restart state is -`required`, `tested`, or `not_applicable`, not a boolean. Verification kinds are -`unit`, `integration`, `device`, or `manual` and include a file, stable ID/test name, -and command where automated. CI validates schema, symbols/files/test IDs, reason -requirements, and generated counts; it does not pretend to prove runtime reachability -by static assertion. `PARITY.md` becomes generated prose or a thin index. - -## 10. Implementation sequence - -### Slice 0 — Spec and regression harness - -- Land this reviewed spec. -- Add the parity-manifest schema/checker and initial manifest representing reality. -- Correct stale documentation without claiming missing features are complete, - including obsolete Swift `core_wallet_send_to_addresses` test-plan references and - shipped Swift invitation rows still marked as bridge-only. - -### Slice 1a — Android address-index safety - -- C1 only. No schema migration. - -### Slice 1b — Atomic shared Core send - -- C2 Rust/key-wallet reservation primitive, new opaque FFI result, both host - adoptions, and old-ABI deprecation. - -### Slice 1c — Lossless token domains - -- C3 compatibility table, JNI raw-bit boundary, v5 unsigned token storage, and both - host/domain tests. C3 gates S3a/S3b. - -These are separate PRs/rollback units. - -### Slice 2a — Serialized Android schema foundation - -- Land/export v5 for C3 unsigned token storage. -- Reserve and land Room v6 for P1 FVK storage. -- Reserve and land Room v7 for P3 block position and transaction-account - involvement. -- Reserve Room v8 for P2 invitation UI persistence. -- Export each schema; test each adjacent migration and v4→latest. Do not develop - parallel conflicting migrations from the same schema version. -- Define a versioned, explicit backend-attested `PersistenceCapability` bitset and - intersect/validate it against structurally required callback groups. Callback - presence alone cannot attest semantic sub-capabilities: for example, - `provider_transactions` shares the broad wallet-list restore callback but is valid - only when the backend actually populates and frees its provider restore payload. - Canonical v1 capabilities are `atomic_changesets`, - `asset_lock_funding_indices`, `invitations`, `shielded_viewing_keys`, - `provider_transactions`, `unsigned_token_storage`, `pending_contact_crypto`, and - `wallet_restore`. `asset_lock_funding_indices` covers account registration and - address-pool watermark persistence; `pending_contact_crypto` covers both durable - queue additions and removals. These names are the public manifest/diagnostic - namespace; source-level compatibility aliases do not create additional bits. - Expose initialization diagnostics and add missing-capability preflight tests per - feature, including a wallet-list callback present while provider capability - remains absent. -- Retain `persists_durably()` only as a compatibility wrapper derived from - `atomic_changesets + asset_lock_funding_indices + invitations`; new feature code - checks its exact required capability set. Invitation creation additionally - requires `wallet_restore`, because a committed voucher is not restart-safe unless - its originating wallet and funding-index state can both be reconstructed. - -### Slice 2b — Android viewing-key callbacks - -- P1 behavior atop v6, including native instrumentation round trip. - -### Slice 2c — Android provider restoration - -- P3 behavior atop v7. This may remain `unsupported` in the manifest until an - Android masternode consumer exists, but must not be claimed as parity. - -### Slice 2d — Android identity recovery - -- R1 tracked-lock listing plus registration/top-up resume. No DB migration. - -### Slice 2e — Android SPV and DPNS queries - -- R2 and R3 as independent commits/PRs. No DB migration. - -Each vertical slice includes its JNI descriptor/symbol smoke and native Android -library build; JVM tests alone do not validate native binding. - -### Slice 3 — Invitations - -- P2 as one vertical feature using v8 and the shipped shared/iOS invitation - semantics. R1 lands before invitation reclaim. -- Do not combine it with generic persistence cleanup; invitation broadcast ordering - and durability gates must remain independently reviewable. - -### Slice 4 — Shared-policy consolidation - -- S1 endpoint discovery. -- S2 account-scoped funding selection. -- S3a proposal/authorization decisions. -- S3b purchase quote. -- S4 constants/codecs. - -Each host implementation is deleted in the same slice that exposes and adopts its -shared replacement; do not leave two live paths. - -### Slice 5 — Deferred durability - -- P4 only after the per-identity queue relocation and shared cold-load restore exist. - -### Release gates - -PR #3999 release blockers are Slice 0, C1, C2, C3, P1, P3 if provider parity is -advertised, R1, and truthful manifest/docs. Invitation Android parity, R2/R3, and -S1-S4 may ship as explicitly `unsupported`/`partial` follow-ups unless product scope -requires them for the same release. Definition of done for the overall program does -not force every consolidation item into one unbounded merge gate. - -## 11. Test matrix - -| Risk | Shared Rust | JNI/Kotlin | Swift | Device/testnet | -| --- | --- | --- | --- | --- | -| Address index conflict | provider event fixture | handler restart/restore | existing semantic pin | Android restore smoke | -| Concurrent Core sends | barrier-forced same-UTXO race and reservation lifecycle | composite wrapper + JNI ownership | composite wrapper + C ownership | two live sends | -| `u64` boundary | ABI encode/decode | raw-bit JNI + Room/DAO/UI unsigned ordering | `UInt64` parity | Android JNI symbol smoke | -| Viewing-key restart | bind without seed | v5→v6 + callback/free round trip | existing callback test | Android seedless restart | -| Provider restore | payload decode/ownership | v6→v7, multiwallet membership, malformed bytes | existing restore | Android callback round trip | -| Asset-lock resume | tracked-lock state machine | process-death recovery | existing resume tests | funded testnet | -| Invitations | protocol/persistence ordering | v7→v8 + DP-12...19 | retain DP-12...19 | cross-platform claim | -| Discovery | port/IPv6 fixtures | no host parser remains | no host parser remains | testnet discovery | -| Token proposal rules | all action-rule variants | render typed result | render typed result | group co-sign | -| Deferred crypto | identity-first restore/fan-out | vtable + Room crash restart | vtable + SwiftData crash restart | locked-seed restart | - -Required validation per slice: - -- `cargo fmt --all -- --check` and targeted Rust tests; -- `cargo clippy` for changed Rust crates and all targets; -- `cargo test -p rs-unified-sdk-jni --lib`, Android native library build, Kotlin JVM - tests under JDK 17, Room `MigrationTestHelper` adjacent and v4→latest tests, and - instrumented callback round trips for P1/P3; -- Swift package tests and iOS framework build for Swift/FFI slices; -- cbindgen header regeneration/diff, C struct size/layout pins, Swift `MemoryLayout` - pins for direct structs, and null/zero-count/double-free buffer tests; -- on-device external-function smokes for R1/R2/R3/C3 and resolver-handle teardown; -- `git diff --check`. - -## 12. Compatibility and rollout - -- Database migrations are additive and serialized as v5 unsigned token storage, v6 - FVK, v7 provider membership/position, and v8 identity-key derivation - breadcrumbs (pending-repair durability, dashpay/platform#4060); the - invitations migration shifts to v9. Do not destructively rewrite address - identity columns. -- New Rust FFI functions are additive and new result PODs are opaque/versioned. - Existing released split-builder entry points are deprecated before removal; - existing struct layouts and JNI descriptors do not change silently. The - unreleased Kotlin token source surface is corrected from signed `Long` to - `ULong` in place as accounted in C3, while retaining identical raw `jlong` - descriptors and a checked Java `BigInteger` adapter. -- Persistence exposes feature-specific capabilities. Registration reports missing - callback sets at initialization where possible, and required feature APIs fail - closed before broadcast. -- The parity manifest initially records known gaps. CI prevents regression but does - not require all gaps to close in the first slice. - -### Keystore rework divergences and convergences (dashpay/platform#4060) - -Recorded in `sdk-parity-manifest.json`; rationale here: - -- **`KeySecurityPolicy` + Keystore alias split (Kotlin-only, deliberate).** - Android Keystore fixes authentication parameters at key generation, so the - AUTH_GATED/DEVICE_BOUND policies require distinct aliases; iOS Keychain has - no per-alias auth-parameter analog (item access control covers the same - ground), so the manifest marks Swift `not-applicable` — no Swift port is - planned. The lockless-device degradation (AUTH_GATED writes redirect to the - DEVICE_BOUND alias, surfaced via `effectiveKeySecurityPolicy`) is likewise - Android-specific: KeyMint rejects gated key generation without a secure - lock screen. -- **Platform-wallet code 98 is now CONVERGENT.** Kotlin previously collapsed - the blanket Option-miss code into the top-level `DashSdkError.NotFound` - while Swift kept it in the wallet family; Kotlin now maps 98 to - `DashSdkError.PlatformWallet.NotFound` (BREAKING for hosts that caught the - top-level type from platform-wallet operations). -- **Durable pending-repair surface (Kotlin-only, port candidate).** - `pendingIdentityKeys` + forced/verified `repairIdentityKey` + the Room v8 - derivation breadcrumbs have no Swift counterpart; the manifest records the - gap as `unsupported` for Swift. -- **Structured `SigningKeyUnavailable` discriminator (both hosts).** The - signer completion carries a typed `error_code` (rs-sdk-ffi - `DashSDKSignerErrorCode`), restored as platform-wallet code 31 on both - hosts. The Rust-internal segment rides the machine prefix - `signer_error:key_unavailable: ` through `ProtocolError::Generic` (a typed - rs-dpp variant was rejected for serialization blast radius — accepted - residual). The Kotlin `MESSAGE_MARKER` text sniff survives ONLY as a - deprecated fallback for the #4191 merge-order transition (marker-based - classification predating the typed code) and for conversion paths that - lose the machine prefix; mixed old-native/new-Kotlin artifacts are - unsupported outright (the sign-completion JNI arity changed 3→4 args). - Remove it (and the marker's matcher role) in the next minor release. - -## 13. Explicitly out of scope - -- Redesigning DIP-13/DIP-15 invitation wire formats. -- Simultaneous multi-account DashPay contacts; that remains governed by - `MULTI_ACCOUNT_SPEC.md` and its product gate. -- Replacing JNI or C FFI with a new binding generator. -- Making Room and SwiftData schemas structurally identical. -- Treating example-app visual layout differences as parity failures when capability, - accessibility contract, and behavior are equivalent. - -## 14. Definition of done - -This effort is complete when: - -1. C1-C3 have regression coverage and both host SDKs use the safe/shared paths. -2. Android registers all persistence capabilities required by features it advertises. -3. Android can resume every funded identity/invitation operation already supported - by iOS. -4. S1-S4 have one live shared implementation with both host copies removed. -5. Every supported capability is represented by the executable manifest and named - tests; no manual parity count contradicts runtime capability. -6. Cross-platform invitation claim and concurrent-send device smoke tests pass. diff --git a/docs/sdk/PR3999_WALLET_LIFECYCLE_HARDENING_SPEC.md b/docs/sdk/PR3999_WALLET_LIFECYCLE_HARDENING_SPEC.md deleted file mode 100644 index 2a9ec4decce..00000000000 --- a/docs/sdk/PR3999_WALLET_LIFECYCLE_HARDENING_SPEC.md +++ /dev/null @@ -1,652 +0,0 @@ -# PR #3999 — Kotlin SDK wallet-lifecycle hardening spec - -Covers the 4 open blocking review findings on PR #3999 -(`feat/kotlin-sdk-and-example-app`) that were not yet addressed: - -1. `PlatformWalletManager.kt:347` — init cleanup starts after fallible - child constructors. -2. `PlatformWalletManager.kt:778` — alias cleanup ignores ownership - recorded only in another wallet's index. -3. `PlatformWalletManager.kt:783` — identity-key persistence can still - run after wallet deletion. -4. `CreateWalletScreen.kt:85` — configuration changes discard the only - recovery-phrase copy. - -Findings 2 and 3 both live in the `removeWallet` / `WalletStorage` -private-key lifecycle and are designed together (§2). 1 and 4 are -independent (§1, §3). - -## Spec review findings (applied) - -Three independent review agents (feasibility, security, scope/simplicity) -checked a first draft of this spec against the actual source. §1 and -the overall scope of §2/§3 came back clean; three must-fix issues were -found and are already folded into the sections below: - -1. **Compile-breaking (feasibility):** the original §2 sketch declared - `isOwnedByAnotherWallet` as a `private` extension function on - `PrivateKeyExclusion`. It's unreachable from `removeWallet`'s - `withPrivateKeyExclusion { }` lambda (only the interface's own - members resolve there) and `private` besides. Fixed: declared on - the `PrivateKeyExclusion` interface itself, like the existing - `deleteOwnerIndex`. -2. **Deadlock-risk (feasibility + security):** the original §2 - `storeIfAbsent` sketch called `derive()` (a Rust FFI call) *inside* - `privateKeyMutex.withLock`, directly violating `WalletStorage.kt`'s - own documented invariant that the locked block must never call into - native code. Fixed: two-phase check → derive-outside-lock → - re-check-and-store. The security pass also caught that the - existence check must treat a present-but-undecryptable ciphertext - blob (a real, already-supported legacy state — see - `isPrivateKeyDecryptable`) as absent, or the fix would silently - defeat that re-derive path. -3. **New risk introduced (security), inconsistent with the rest of the - screen (feasibility):** the original §3 sketch also moved the - wallet-creation coroutine's launch from `rememberCoroutineScope()` - onto `viewModel.viewModelScope`. Feasibility flagged that the - screen's other captured state (`isCreating`, `error`, - `navController`) stays composable-scoped, so a `viewModelScope` - coroutine surviving a config change would mutate/navigate through - dead references. Security separately flagged that `viewModelScope` - surviving longer than the composition (e.g. past navigation-away) - combined with `onCleared()` scrubbing the phrase could wipe the - *only* copy before the emergency dialog is ever shown, for a - creation that fails after the user has already left the screen — a - worse outcome than the bug being fixed. Fixed: keep - `rememberCoroutineScope()` for the launch; only the *storage* of an - already-caught phrase moves to the ViewModel. - -Additionally, the security pass found one HIGH-severity gap the -feasibility/scope passes didn't have the angle to catch: the -tombstone-set design ("a deleted wallet's id is never reused") is -false — re-importing the same recovery phrase after an accidental -delete is a real, supported flow, and would permanently brick under -the original always-on tombstone. Fixed: `createWallet` (the same -entry point both "new" and "restore from phrase" go through) now -clears any stale tombstone for that wallet id before storing. - -Two lower-severity items were surfaced and are intentionally *not* -fixed in this pass (see the "Accepted, not fixed" note in §2 and the -platform-limitation note in §3) — both are pre-existing or -theoretical, not regressions this spec would introduce, and closing -them would widen the diff for gaps that either have no live caller -today or are an accepted platform ceiling. - ---- - -## §1. Init cleanup ordering (finding @ `PlatformWalletManager.kt:347`) - -### Problem - -`PlatformWalletManager`'s primary constructor initializes, in source -order: `scope` (192) → `teardownGate`/`_syncEvents`/`eventBridge` -(203-317) → `mnemonicResolver` (325) → `signer` (326-327) → -`identityKeyDeriver` (337-341) → `persistenceHandler` (343-347) → … -→ `nativeInitialization = initializePlatformWalletNativeManager(...)` -(477-490). - -`initializePlatformWalletNativeManager` already contains a correct -cleanup transaction — on failure it cancels `scope` and closes -`mnemonicResolver`/`signer`/`persistenceHandler` (in that order, -suppressing secondary failures) before rethrowing. But it only guards -failures inside *itself* (native bundle create, manager-handle fetch, -capability reads). It cannot guard `mnemonicResolver`, `signer`, or -`persistenceHandler`'s own constructors, because those already ran to -completion (or threw) *before* `nativeInitialization` is reached — a -throw there aborts the whole primary constructor with no -`PlatformWalletManager` instance to call `close()` on, so: - -- `mnemonicResolver`'s JNI handle (`MnemonicNative.createResolver`) - leaks if `signer`, `identityKeyDeriver`, or `persistenceHandler` - throws after it. -- `signer`'s JNI handle (`SignerNative.createSigner`) leaks if - `identityKeyDeriver` or `persistenceHandler` throws after it (and - `database.platformAddressDao()`, evaluated as `signer`'s 4th - constructor arg, can itself throw before `signer`'s own constructor - even runs). -- `persistenceHandler`'s owned single-thread `Executor` - (`Executors.newSingleThreadExecutor { "dash-persistence" }`) leaks - if it's the one that throws, or leaks the executor while everything - *before* it (resolver, signer) also leaks. - -`identityKeyDeriver` itself holds no releasable resource (confirmed: -plain Kotlin object, no native handle, not `AutoCloseable`). - -### Chosen approach - -Group the four child constructions into one `init`-time block that -builds each as a local `var`, wraps construction in `try`, and on any -`Throwable` closes whichever locals were already assigned (in reverse -construction order) before rethrowing — the same "roll back the -locals, only adopt on success" shape the Swift equivalent -(`PlatformWalletManager.swift`'s `configure(...)`) already uses, -adapted to Kotlin's val-in-constructor idiom via a small holder: - -```kotlin -private class CoreChildren( - val mnemonicResolver: MnemonicResolverAndPersister, - val signer: KeystoreSigner, - val identityKeyDeriver: IdentityKeyPrivateKeyDeriver, - val persistenceHandler: PlatformWalletPersistenceHandler, -) - -private val coreChildren: CoreChildren = run { - var mnemonicResolver: MnemonicResolverAndPersister? = null - var signer: KeystoreSigner? = null - try { - val resolver = MnemonicResolverAndPersister(walletStorage) - .also { mnemonicResolver = it } - val keySigner = KeystoreSigner( - walletStorage, network, biometricGate, database.platformAddressDao(), - ).also { signer = it } - val deriver = IdentityKeyPrivateKeyDeriver( - network = network, - mnemonicResolverHandle = resolver.nativeHandle, - walletStorage = walletStorage, - ) - val handler = PlatformWalletPersistenceHandler( - database = database, privateKeyDeriver = deriver, network = network, - ) - CoreChildren(resolver, keySigner, deriver, handler) - } catch (e: Throwable) { - runCatching { signer?.close() } - runCatching { mnemonicResolver?.close() } - scope.cancel() - throw e - } -} -private val mnemonicResolver get() = coreChildren.mnemonicResolver -private val signer get() = coreChildren.signer -private val identityKeyDeriver get() = coreChildren.identityKeyDeriver -private val persistenceHandler get() = coreChildren.persistenceHandler -``` - -- `persistenceHandler` needs no explicit close-on-catch: if its own - constructor throws, it never assigned itself anywhere, and it's the - last child built, so there's nothing after it to fail and orphan it. -- `scope.cancel()` on the failure path mirrors what - `initializePlatformWalletNativeManager`'s own cleanup already does - for *its* failures; nothing has been launched on `scope` yet at this - point (it's created earlier, at line 192, unused until later), so - cancelling it here is cheap and keeps both failure paths consistent. -- All downstream references to `mnemonicResolver`, `signer`, - `identityKeyDeriver`, `persistenceHandler` throughout the file are - unaffected — they become `private val ... get() = ...` delegating - properties instead of directly-initialized `private val`s, same - read-only surface, no call-site changes. -- `nativeInitialization`'s own cleanup transaction (109-128) is - untouched — it still guards its own failure window exactly as - today. - -### Alternatives rejected - -- **Wrap the whole primary constructor body in try/catch.** Kotlin - doesn't allow arbitrary try/catch around property initializers - mixed with the constructor parameter list in a readable way, and it - would force converting *every* property after this point (including - `identityRegistration`, `voteCasting`, etc., none of which are - fallible or hold resources) into part of the guarded region for no - benefit — larger surface than necessary. -- **`private constructor` + `companion object { fun create(...) }` - factory** (the pattern the finding suggests by analogy to Swift). - Rejected for this specific class: every call site that currently - does `PlatformWalletManager(...)` (`WalletManagerStore`'s factory - lambda, tests) would need to change to `PlatformWalletManager.create(...)`, - and the class already exposes a large public API assuming direct - construction succeeds or throws synchronously — converting to an - external factory is a bigger surface change for the same outcome as - the local-var-holder approach above, which achieves the identical - rollback guarantee without touching any call site. - -### Failure modes covered / not covered - -- Covered: any single child's constructor throwing, at any position in - the four, cleans up every JNI-owning child constructed strictly - before it. -- Not covered (explicitly out of scope for this finding, flagged for - a separate follow-up): `KeystoreSigner.close()` itself does not - cancel `KeystoreSigner`'s own internal `scope` - (`CoroutineScope(SupervisorJob() + Dispatchers.IO)`, `KeystoreSigner.kt:53`) - — that's a pre-existing gap in `KeystoreSigner`'s own `close()` - logic, orthogonal to *when* cleanup runs (which is what this finding - is about). Noting it here so it isn't lost, not fixing it in this - pass to keep the diff surgical. - -### Test plan - -Add to `PlatformWalletManagerInitializationTest.kt` (or a new -`PlatformWalletManagerConstructionTest.kt` if constructing a real -`PlatformWalletManager` needs more fixture setup than that file -currently has): a test that injects a `walletStorage`/`database`/etc. -combination where `KeystoreSigner`'s construction throws (e.g. via a -fake `platformAddressDao()` or a `SignerNative.createSigner` stub that -throws — check what's fake-able without real JNI, per that file's -existing lambda-injection pattern for `nativeCreate`/`nativeManagerHandle`/etc., -since `PlatformWalletManagerInitializationTest` already fakes native -calls at that granularity). Assert: -- The thrown exception propagates (construction still fails). -- `mnemonicResolver`'s `close()` was invoked (spy/count). -- No leaked JNI handle assertion is directly measurable from a JVM - unit test without a real native lib loaded; the practical proof is - "close() was called on every child constructed before the failing - one," which is what the test asserts. - ---- - -## §2. Cross-wallet private-key ownership (findings @ `:778`, `:783`) - -### Shared root cause - -`WalletStorage` (`sdk/src/main/kotlin/.../security/WalletStorage.kt`) -stores private-key ciphertext **globally**, keyed only by pubkey hex -(`privkey.`, no wallet-id component), because sibling network -wallets derived from one mnemonic (Testnet/Devnet/Regtest all share -DIP-9's non-mainnet derivation path) can legitimately derive the same -pubkey and are expected to share that one ciphertext entry. Ownership -is tracked **per-wallet** via a separate index (`privkeyowners.` -→ `Set`), and all wallets share one process-wide -`WalletStorage` instance and one `privateKeyMutex`. - -Two related gaps fall out of that design, both inside `removeWallet` -(`PlatformWalletManager.kt:718-791`): - -**(a) finding @ `:778`.** `aliasesToDelete` (764-770) decides an alias -is safe to delete when no *other Room `public_keys` row* (a committed, -on-chain-registered key) references it outside this wallet's -identities. It never checks whether a *sibling wallet's durable owner -index* (`privkeyowners.`) already claims the alias — -so a sibling wallet that pre-stored (but hasn't yet committed a -`public_keys` row for) the same shared alias loses its ciphertext when -this wallet is deleted. - -**(b) finding @ `:783`.** Two independent gaps, not one: -- `PlatformWalletPersistenceHandler`'s own persist-callback path - (`onPersistIdentityKeyUpsert` → `IdentityKeyPrivateKeyDeriver.hasStored` - then `.deriveAndStore` → `WalletStorage.storePrivateKey`) *is* - exclusion-fenced (`withCallbackExclusion`), but `hasStored` and the - eventual `storePrivateKey` inside `deriveAndStore` are two separate - calls with no single lock spanning both — a sibling caller can store - between the check and the write. -- Two **app-level** call sites — `CreateIdentityScreen.kt:219-227` - and `IdentityKeyAdditionFlow.kt:161-165` — call - `walletStorage.storePrivateKey(...)` directly from their own - `scope.launch`/coroutine, entirely outside - `withPrivateKeyExclusion`/`withCallbackExclusion`/`teardownGate`. - Neither `WalletStorage` nor `storePrivateKey` has any concept of - "this wallet was just deleted," so a store that was already - in-flight when `removeWallet` ran completes anyway, resurrecting the - just-deleted wallet's owner-index entry with fresh ciphertext. - -### Chosen approach - -*(Revised after multi-agent spec review — see "Spec review findings" -below for what changed and why.)* - -Three additions to `WalletStorage`, all executed under the existing -`privateKeyMutex` (via `withPrivateKeyExclusion`/its internal scope), -so no new lock is introduced: - -**1. Cross-wallet ownership query**, used to fix `:778`. Added to the -`PrivateKeyExclusion` **interface** (not a private extension function -— extension functions can't see `WalletStorage`'s private members and -aren't reachable from inside a `withPrivateKeyExclusion { }` lambda, -where only the interface's own receiver is in scope), implemented in -`privateKeyExclusionScope` alongside the existing `deletePrivateKeys`/ -`deleteOwnerIndex`: - -```kotlin -interface PrivateKeyExclusion { - suspend fun deletePrivateKeys(pubkeyHexes: Collection) - suspend fun deleteOwnerIndex(walletId: ByteArray) - - /** True if any wallet OTHER than [excludingWalletId] still claims - * [pubkeyHex] in its durable owner index. */ - suspend fun isOwnedByAnotherWallet(pubkeyHex: String, excludingWalletId: ByteArray): Boolean -} - -private val privateKeyExclusionScope = object : PrivateKeyExclusion { - // ...existing overrides... - override suspend fun isOwnedByAnotherWallet( - pubkeyHex: String, - excludingWalletId: ByteArray, - ): Boolean { - val excludingHex = excludingWalletId.toHex() - val prefs = store.data.first() - return prefs.asMap().any { (key, value) -> - key.name.startsWith(PRIVKEY_OWNERS_PREFIX) && - key.name.removePrefix(PRIVKEY_OWNERS_PREFIX) != excludingHex && - (value as? Set<*>)?.contains(pubkeyHex.lowercase()) == true - } - } -} -``` - -`removeWallet`'s `aliasesToDelete` computation (`PlatformWalletManager.kt:764-770`) -becomes: - -```kotlin -val aliasesToDelete = buildList { - for ((pubkeyHex, publicKeyData) in keysByPubkeyHex) { - val referencedElsewhere = database.publicKeyDao() - .countReferencesOutsideIdentities(publicKeyData, ownedIdentityIds) > 0 - val ownedElsewhere = isOwnedByAnotherWallet(pubkeyHex, walletId) - if (!referencedElsewhere && !ownedElsewhere) add(pubkeyHex) - } -} -``` - -Already runs inside `walletStorage.withPrivateKeyExclusion { ... }` -(736) — as an interface method, `isOwnedByAnotherWallet` resolves on -that lambda's `PrivateKeyExclusion` receiver directly, same as -`deleteOwnerIndex` does today, keeping it under the same lock as the -delete that follows — no TOCTOU between the check and -`deletePrivateKeys`/`deleteOwnerIndex`. - -**2. Atomic-enough check-and-store**, used to fix the `hasStored`/ -`deriveAndStore` half of `:783` (and the same race underlies `:778`'s -"rollback path" note). **Not a single lock-held call** — `WalletStorage.kt`'s -own documented invariant on `withPrivateKeyExclusion` is explicit: *"must -also never call into native code (a persistence callback parked on this -lock can be holding native locks)."* `derive()` is a Rust FFI call -(`IdentityNative.deriveIdentityPrivateKeyWithResolver`), so it cannot run -inside `privateKeyMutex.withLock`. Instead, a double-checked pattern — -lock only for the existence check/write, derive in between, re-check -before writing: - -```kotlin -/** If [pubkeyHex] has no *usable* stored ciphertext (absent, or present - * but undecryptable — see [isPrivateKeyDecryptable]), derive it via - * [derive] and store it; either way record [ownerWalletId] in the - * owner index. Returns whether a derive+store actually happened. */ -suspend fun storeIfAbsent( - pubkeyHex: String, - ownerWalletId: ByteArray, - derive: suspend () -> ByteArray, -): Boolean { - // Fast path: already usable under any owner — just record ownership. - if (privateKeyMutex.withLock { addOwnerIfUsableLocked(pubkeyHex, ownerWalletId) }) { - return false - } - // Derive OUTSIDE the lock (native call) — another writer may store - // the same alias while this runs. - val derived = derive() - return privateKeyMutex.withLock { - if (addOwnerIfUsableLocked(pubkeyHex, ownerWalletId)) { - false // lost the race while deriving; the winner's copy stands - } else { - storePrivateKeyLocked(pubkeyHex, derived, ownerWalletId) - true - } - } -} -``` - -`addOwnerIfUsableLocked` treats "present but not -`isPrivateKeyDecryptable`" the same as absent (so the legacy-blob -re-derive path this codebase already supports keeps working — see -"Spec review findings" #2) — only a present-and-decryptable entry -short-circuits to "just add ownership." `IdentityKeyPrivateKeyDeriver.deriveAndStore` -calls `storeIfAbsent` instead of the current separate -`hasStored`/`storePrivateKey` pair; `PlatformWalletPersistenceHandler`'s -`existedBefore` becomes `!storeIfAbsent(...)`'s result directly, -removing the `runCatching { hasStored }.getOrDefault(true)` fallback -entirely (a genuine simplification, not just a safety fix). This isn't -a single atomic transaction (derivation still happens outside the -lock, matching `storePrivateKey`'s existing outside-the-lock derive -pattern today), but it closes the specific gap the finding names: the -existence check and the eventual write are no longer two calls with an -unguarded window where a sibling wallet's write is invisible to the -first check *and* silently overwritten by the second. - -**3. Deletion tombstone**, used to fix the app-level-bypass half of -`:783`: - -```kotlin -// WalletStorage — guarded by privateKeyMutex. Process-lifetime, but -// explicitly cleared on wallet (re-)creation (see below) — a deleted -// wallet's id CAN be reused within one process (re-import of the same -// recovery phrase after an accidental delete is a real, supported -// flow), so this must not be "set once, never cleared." -private val tombstonedWalletIds = mutableSetOf() -``` - -`removeWallet`'s locked section additionally calls -`walletStorage.tombstoneWallet(walletId)` (same -`withPrivateKeyExclusion` block, right alongside `deleteOwnerIndex`, -so tombstoning happens atomically with the alias cleanup it's -protecting). `PlatformWalletManager.createWallet` (`:541-...`, the -single entry point for both "new wallet" and "restore/re-import from -mnemonic" — same function, both paths pass a mnemonic) calls -`walletStorage.clearTombstone(walletId)` right after the native create -succeeds and before `storeMnemonic`, so a re-imported wallet with a -previously-tombstoned id is immediately usable again, matching -`storePrivateKey`'s pre-existing "keyed globally by wallet id, -deterministic from seed+network" contract. - -`storePrivateKey` (and the new `storeIfAbsent`, via the same -lock-held check) reject writes for a tombstoned `ownerWalletId`, -throwing a new `WalletTombstonedException(walletId)` — the two -app-level call sites (`CreateIdentityScreen.kt:219-227`, -`IdentityKeyAdditionFlow.kt:161-165`) need to catch it. **Accepted, -not fixed, in this pass:** `storePrivateKey`'s `ownerWalletId` param is -nullable (`= null`); a null-owner call bypasses both the tombstone -check and the owner-index union `removeWallet` reads. No current -caller on the derive/register path passes null, so this is a -theoretical gap, not a live bug — noted rather than closed by, e.g., -making the parameter non-nullable (a wider API change touching every -call site for a path nothing currently exercises). What they *do* on -catch is a product decision, not purely mechanical: - -- `CreateIdentityScreen`: the key was already derived and the identity - isn't registered yet — the safest behavior is to abort registration - with a clear "wallet was removed during setup" error, since there's - no wallet left to register against. -- `IdentityKeyAdditionFlow`: adding a key to an *existing* identity on - a now-deleted wallet — same abort-with-error behavior; the identity - itself is decoupled from the wallet's local state at this point. - -**I want to confirm that "abort with a clear error, don't silently -drop it" is the right call for both sites before implementing** — it's -the conservative default but it's the one piece of this section that's -a product/UX decision rather than a pure correctness fix, so flagging -it explicitly for sync rather than assuming. - -### Alternatives rejected - -- **Namespace ciphertext storage per-wallet** (so sibling wallets each - get their own copy instead of sharing one `privkey.` entry). - Rejected: this is a deliberate existing design choice (DIP-9 sibling - wallets sharing one mnemonic are expected to share key material by - construction), not a bug — re-namespacing would be a much larger, - riskier storage-format migration for a problem the ownership-index - fix already solves without touching the ciphertext layer. -- **A single process-wide "wallet lifecycle" lock instead of the - targeted tombstone check.** Rejected: `privateKeyMutex` already - serializes every `WalletStorage` mutation; adding a *second*, - coarser lock spanning arbitrary app-level coroutines - (registration flows, key-addition flows) would be far more invasive - and risks new deadlocks between `withCallbackExclusion` (native - callback path) and app-driven UI coroutines. The tombstone check - reuses the existing lock and only adds a rejection condition. - -### Failure modes - -- `isOwnedByAnotherWallet` false positive/negative: a false negative - (misses a legitimate other-wallet claim) reproduces today's bug; a - false positive (over-retains an alias nobody else needs) only costs - a stray ciphertext entry, not a lost key — asymmetric risk correctly - favors retention. -- `storeIfAbsent` racing two *concurrent* callers for the *same* new - pubkey: the mutex serializes them, so the second caller's `derive()` - result is discarded once `hasPrivateKeyLocked` sees the first - caller's write — matches today's `deriveAndStore`'s intended - idempotency, now actually atomic. -- Tombstone check: a wallet ID is only ever tombstoned by `removeWallet` - itself, under the same lock as the check, so there's no window where - a store could race the tombstone write. - -### Test plan - -New `WalletStorageTest.kt` (currently doesn't exist — flagged as a gap -by the research pass): -- `isOwnedByAnotherWallet` returns true when wallet B's owner index - contains the alias and wallet A is being deleted; false when no - other wallet claims it. -- `storeIfAbsent` returns `false` and does not overwrite ciphertext - when the pubkey already has an entry (from any owner); returns - `true` and writes on first store; two concurrent calls for the same - new pubkey result in exactly one ciphertext write. -- `storePrivateKey`/`storeIfAbsent` throw `WalletTombstonedException` - for a tombstoned wallet id; a non-tombstoned sibling wallet's calls - are unaffected. - -Extend `removeWallet`'s coverage (no dedicated test file currently -exercises `removeWallet` at all — another gap flagged by the research -pass) with a sibling-wallet scenario: wallet A and B share a derived -pubkey via B's *pending* (not-yet-committed) owner-index entry; delete -A; assert B's ciphertext and owner-index entry survive. - ---- - -## §3. `CreateWalletScreen` mnemonic loss on config change (finding @ `:85`) - -### Problem - -`unrecoverablePhrase` (the sole surviving copy of a mnemonic when every -durable store *and* rollback attempt failed, carried by -`WalletCreateRollbackException.mnemonic`) is held in plain -`remember { mutableStateOf(null) }` inside the `@Composable`. -That's deliberately not `rememberSaveable` (so the plaintext never -serializes into the saved-state `Bundle` — correct instinct) but as a -result it also doesn't survive activity recreation from rotation, -locale, or theme changes, which discards the composition and loses the -only copy — the underlying wallet creation already ran, Room rows -exist, but they're now permanently seedless with no recovery path. - -There is no existing ViewModel for this screen; wallet creation runs -on `rememberCoroutineScope()`, not `viewModelScope`. - -### Chosen approach - -*(Revised after multi-agent spec review — the coroutine-scope change -originally proposed here was dropped; see "Spec review findings" -below.)* - -Add `CreateWalletViewModel : ViewModel()` holding -`unrecoverablePhrase` as a plain `mutableStateOf` property — -the same shape `TokenActionViewModel` already establishes elsewhere in -this app module (plain `ViewModel`-retained `mutableStateOf`, not -`SavedStateHandle`-backed) — so it survives config-change-driven -recreation via the normal `viewModel()` retention contract, without -ever touching `SavedStateHandle`/the Bundle: - -```kotlin -class CreateWalletViewModel : ViewModel() { - var unrecoverablePhrase by mutableStateOf(null) - private set - - fun recordUnrecoverablePhrase(phrase: String) { - unrecoverablePhrase = phrase - } - - /** Explicit scrub once the user has acknowledged the backup dialog. */ - fun clearUnrecoverablePhrase() { - unrecoverablePhrase = null - } - - override fun onCleared() { - unrecoverablePhrase = null - super.onCleared() - } -} -``` - -`CreateWalletScreen` takes `viewModel: CreateWalletViewModel = -viewModel()` (standard Compose factory). **The wallet-creation -coroutine itself keeps launching on `rememberCoroutineScope()`, as -today** — only *where the caught phrase is stored* changes. The -`WalletCreateRollbackException` catch calls -`viewModel.recordUnrecoverablePhrase(phrase)` instead of assigning -local `remember` state; the emergency dialog reads -`viewModel.unrecoverablePhrase` and calls -`viewModel.clearUnrecoverablePhrase()` on acknowledgement — same UX, -same non-dismissable-until-acknowledged shape, just backed by -ViewModel state instead of composition-scoped `remember`. This is -deliberately the smallest change that closes the named finding: the -finding is about the phrase being lost *after* it was already -successfully caught (a later rotation wipes the composition's -`remember` state); it is not about the creation coroutine surviving -mid-flight cancellation, which is a different (and not reported) -concern. Moving the *launch* itself onto `viewModelScope` was -considered and rejected — see "Spec review findings" #3. - -`String` immutability means true byte-level zeroization isn't possible -here (same platform limitation the finding implicitly accepts by -saying "explicitly scrubbed... field," not "zeroized bytes") — the -`clear`/`onCleared` calls null the reference promptly, which is the -practical ceiling for a JVM `String`. No existing code in this app -module holds secrets in a `ViewModel`, so this establishes a new (but -narrow, single-field) pattern rather than reusing one — noted for -awareness, not a blocker. - -### Alternatives rejected - -- **`rememberSaveable` with a custom `Saver` that encrypts before - serializing.** Rejected: still writes ciphertext into the - saved-state `Bundle`, which can be included in Android's automatic - backup / restored on a different security surface than intended; - the finding explicitly calls for avoiding `SavedStateHandle`/Bundle - entirely, and `ViewModel` retention already solves the actual - problem (surviving config change) without that exposure. -- **Process-level singleton / repository holding the phrase.** - Rejected: broader lifetime than needed (a `ViewModel` scoped to this - screen's `NavBackStackEntry` already outlives config changes and is - cleared on real navigation-away, which is the right lifetime — a - singleton would need its own manual clearing discipline for no - benefit). - -### Test plan - -Kotlin unit test (Robolectric not required — pure `ViewModel` logic, -no Room/Compose): `CreateWalletViewModelTest.kt` — -`recordUnrecoverablePhrase` sets the field; -`clearUnrecoverablePhrase`/`onCleared()` (via -`ViewModelStore.clear()`) null it. This is a pure-JVM test unlike the -findings in §1/§2, so it's the one item in this spec I can actually -compile-check locally now that JDK 17 + the Android SDK were located -on this machine — I'll run `./gradlew :app:testDebugUnitTest` for it -alongside the others. - ---- - -## Cross-cutting test plan - -All four items get real `./gradlew :sdk:testDebugUnitTest -:app:testDebugUnitTest` runs before this is called done — the earlier -report that I couldn't test Kotlin locally was wrong; JDK 17 -(`openjdk@17` via Homebrew) and the Android SDK (`platforms/android-35`, -`build-tools/35.0.0`) are both present on this machine, just not on -the default `java_home`/`ANDROID_HOME` search path this session -started with. - -## Open questions for sync (before coding) - -1. §2, tombstone rejection UX: confirm "abort registration/key-add - with a clear error" is correct for both - `CreateIdentityScreen`/`IdentityKeyAdditionFlow`, vs. some retry/ - silent-skip behavior. -2. §2 is the largest, most fund/key-safety-critical piece of this - spec (touches `WalletStorage`'s locking and storage format - indirectly via the new tombstone set). Confirm you want it done in - this pass rather than split into its own follow-up PR after #3999 - lands — it's the one item where "ship it now" vs. "land the other 3 - and follow up" is a real tradeoff given the review-cycle history on - this branch already (140 review passes, most from automated - reviewers finding new issues on each push). -3. §2, accepted gap: `storePrivateKey`'s nullable `ownerWalletId` - bypasses the tombstone/owner-index union entirely for a null-owner - call. No current caller passes null on the derive/register path, so - this is flagged rather than closed (closing it means auditing every - `storePrivateKey` call site to require an owner). Confirm you're - fine leaving this as a documented gap rather than widening the API - change to cover it now. diff --git a/packages/dashpay-contract/README.md b/packages/dashpay-contract/README.md index 7e87e8cb45d..8c36e98903a 100644 --- a/packages/dashpay-contract/README.md +++ b/packages/dashpay-contract/README.md @@ -59,6 +59,11 @@ To run tests, simply run npm test ``` +## Design notes + +- Count proofs are public. Never add a countable index on `contactRequest` that groups by sender under a recipient, such as `[toUserId, $ownerId]`: it would let anyone list who contacted whom, with counts. A count keyed on `toUserId` alone reveals only a total. +- Ignoring a sender is local to each device. If it ever syncs across devices, it must be one list the owner encrypts to themselves, not a `contactInfo` document per ignored sender. A `contactInfo` about someone who is not a contact exists publicly, and its creation time lines up with the incoming `contactRequest`, which reveals who was ignored. + ## Contributing Feel free to dive in! [Open an issue](https://github.com/dashpay/platform/issues/new/choose) or submit PRs. diff --git a/packages/kotlin-sdk/CLAUDE.md b/packages/kotlin-sdk/CLAUDE.md index 68c2ff5a77b..bdca8c34ef9 100644 --- a/packages/kotlin-sdk/CLAUDE.md +++ b/packages/kotlin-sdk/CLAUDE.md @@ -46,6 +46,11 @@ are data encrypted under Keystore-wrapped AES keys). `GlobalRef`s. - `PlatformWalletManager` is network-locked at construction. Network switch = destroy + new instance (`WalletManagerStore`), never reconfiguration. +- Screens read Room `Flow`s or snapshot data copied at the JNI boundary. They + never hold a native handle wrapper in composition: `NativeCleaner` can free + it in the middle of a read. +- Bridge an FFI function only when the reference Swift app has a caller for + it. ## Building @@ -84,6 +89,13 @@ export DASH_GRADLE_BUILD_ROOT=/Volumes/DashBuild/gradle-build - Instrumented tests (`sdk/src/androidTest`): FFI smoke test (`FfiSmokeTest`) is the A-M1 gate — library loads, version resolves, SDK handle round-trips. +- JVM and Robolectric tests never load `libdash_sdk_jni`. JNI symbol names, + the hand-written method descriptors in `rs-unified-sdk-jni` and argument + marshaling are exercised only by the instrumented tests, and a mismatch + fails at runtime, not at compile time. Change a Rust descriptor and its + Kotlin signature in the same commit, and extend `FfiSmokeTest` + (`persistenceBridgeDescriptorsAllResolve`) and `WalletManagerRoundTripTest` + when adding a persistence or callback slot. - Testnet integration tests are tagged and opt-in (`-Ptestnet=true`). ## Keeping parity with iOS @@ -92,3 +104,17 @@ The reference implementation is `packages/swift-sdk` + its SwiftExampleApp. When porting behavior, cite the Swift source file in the KDoc. Reuse iOS accessibility identifier strings verbatim as Compose `testTag`s for cross-platform UAT parity. + +Parity status lives in `docs/sdk/sdk-parity-manifest.json`. After a +capability changes, edit the manifest and run +`python3 scripts/check_sdk_parity_manifest.py --write-summary`; CI rejects a +stale `PARITY_SUMMARY.md`. Never hand-edit counts in `PARITY.md` or +`PARITY_SUMMARY.md`. The rules the manifest encodes: + +- A capability is `supported` only when it names an automated test or a + recorded device or manual gate. +- Recovery is part of parity: where durable state or on-chain value exists, a + flow is ported only when it resumes after process death, so its `restart` + must be `tested`. +- Differences in visual layout are not parity failures when the capability, + accessibility identifiers and behavior match. diff --git a/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md b/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md index 13784fd4003..1cb02d3fa80 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md +++ b/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md @@ -121,7 +121,7 @@ Most Platform actions have hard preconditions. Establish these fixtures before s | ID | Action | Layer | Tier | Status | Tags | Entry point & test notes | |---|---|---|---|---|---|---| -| ID-01 | Create identity (Core-funded asset lock) | Cross | Essential | ✅ | | `CreateIdentityScreen` / `IdentityRegistrationController` → `platform_wallet_register_identity_with_signer`. New identity + credit balance appear. | +| ID-01 | Create identity (Core-funded asset lock) | Cross | Essential | ✅ | | `CreateIdentityScreen` / `IdentityRegistrationController` → `platform_wallet_register_identity_with_signer`. New identity + credit balance appear. After a fresh six-key registration, fetch the identity and check that keys 4 and 5 are ECDSA ENCRYPTION / DECRYPTION MEDIUM keys bound to the DashPay contract id and `contactRequest` (the `RegistrationKeys` table). A successful Add Contact does not prove the bounds; no unit test can see what the node stored. | | ID-02 | Load / discover identity from wallet | Platform | Essential | ✅ | | `LoadIdentityScreen` / `SearchWalletsForIdentitiesScreen` → `platform_wallet_discover_identities`. | | ID-03 | View identity (info / balance / revision / keys) | Platform | Essential | ✅ | | `IdentityDetailScreen`, `KeysListScreen`, `KeyDetailScreen`. | | ID-04 | Transfer credits identity → identity | Platform | Essential | ✅ | | `IdentityDetailScreen` → **Transfer Credits** (dialog, `TransferCreditsScreen`) → `platform_wallet_transfer_credits_with_signer` (Keystore-signed). Recipient entered via `RecipientPicker` (local identity / paste base58 id / DPNS name). | @@ -137,6 +137,7 @@ Most Platform actions have hard preconditions. Establish these fixtures before s | ID-14 | Credit transfer between two on-device identities (A → B) | Platform | Thorough | ✅ | multiwallet | `IdentityDetailScreen` → **Transfer Credits** (`ID-04`), recipient = wallet B's identity (via `RecipientPicker`). Switch to B; verify credit balance rose. | | ID-15 | Same identity restored into two wallets (duplicate seed) | Platform | Uncommon | ✅ | multiwallet | Importing the same mnemonic as a second wallet derives the **same** identity; verify consistency. | | ID-16 | Resume identity top-up from a tracked asset lock | Cross | Manual | 🚧 | | Source UI selects the Rust-tracked exact outpoint, excludes locks bound to another identity, and reaches the compiled tracked-lock list/free and existing-lock registration/top-up JNI bridges. Device gate: interrupt after Core broadcast, restart, resume the same Built transaction/outpoint, and verify foreign/untracked/consumed locks return typed failures without creating a replacement funding transaction. | +| ID-17 | Signing after biometric re-enrollment | Platform | Manual | ✅ | | Physical device with auth-gated Keystore keys. Re-enroll biometrics (add or remove a fingerprint or face), then sign any identity transition: expect `SigningKeyUnavailable` (code 31). Repair the key from the wallet's key-health sheet (`WalletKeyHealthSheet` → `PlatformWalletManager.repairIdentityKey`); the next sign succeeds. The CI emulator cannot re-enroll biometrics, so the invalidation recovery is pinned only through the fake Keystore at the unit tier. | ### 4.3 Platform Addresses (DIP-17 credit addresses) — `Domain=Address` @@ -333,7 +334,7 @@ Membership of each feature category across **all** sections (primary section mem - **Document** — `DOC-01..15` - **Token** — `TOK-01..20` - **Shielded** — `SH-01..17` -- **DashPay** — `DP-01..19` (`DP-12..19` = invitation create, claim, persistence, reclaim; funded evidence 2026-07-23 in `docs/dashpay/KOTLIN_INVITATIONS_SPEC.md` §7) +- **DashPay** — `DP-01..19` (`DP-12..19` = invitation create, claim, persistence, reclaim; funded run 2026-07-23) - **System / Diagnostics** — `SYS-01..08` ### Tag index diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/DashPayUnlockAndSyncTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/DashPayUnlockAndSyncTest.kt index b6f66ea5ca6..9fd179b594c 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/DashPayUnlockAndSyncTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/DashPayUnlockAndSyncTest.kt @@ -22,7 +22,7 @@ import org.junit.runner.RunWith /** * Instrumented coverage for the K2 seedless-unlock topology and the - * DashPay sync-service lifecycle (KOTLIN_MIGRATION_SPEC.md §K2), through + * DashPay sync-service lifecycle, through * the real native lib: * * - The **happy-path unlock is the end-to-end proof of the out-buffer diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt index 34ec9d4ade4..77154af19e8 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/wallet/WalletManagerRoundTripTest.kt @@ -119,7 +119,7 @@ class WalletManagerRoundTripTest { * * Of the K1 getters, `searchDpnsNames` is deliberately untested here: * it is a live network query and belongs to the `-Ptestnet=true` - * tier (KOTLIN_MIGRATION_SPEC.md §7.4). + * tier. */ @Test fun dashPayRestoreRoundTripsPaymentsContactProfilesAndSyncState() = runBlocking { diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 94ebbb0267f..e74e3b00954 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -684,6 +684,13 @@ abstract class DashDatabase : RoomDatabase() { * API 16+; writes go through the persistence handler inside * `withTransaction`, mirroring the changeset bracketing contract of * `platform-wallet-ffi`. + * + * Room runs WAL with `synchronous=NORMAL`: a committed transaction + * survives process death, but a power loss can roll back the most + * recent commit. That is accepted for every persistence capability + * the handler attests (the same class of risk as iOS); + * `synchronous=FULL` was deliberately not adopted because it costs + * an fsync on every commit. */ fun create(context: Context): DashDatabase = Room.databaseBuilder(context, DashDatabase::class.java, DATABASE_NAME) diff --git a/packages/rs-platform-wallet/PERSISTENCE_REDESIGN.md b/packages/rs-platform-wallet/PERSISTENCE_REDESIGN.md deleted file mode 100644 index 23609bcb8b8..00000000000 --- a/packages/rs-platform-wallet/PERSISTENCE_REDESIGN.md +++ /dev/null @@ -1,1017 +0,0 @@ ---- -title: "Persistence Redesign — BDK-style changesets across key-wallet and platform-wallet" -type: refactor -status: in-progress -date: 2026-04-08 -updated: 2026-04-10 ---- - -# Persistence Redesign - -Living plan for the BDK-style changeset persistence work that spans -`rust-dashcore/key-wallet` and `platform/packages/rs-platform-wallet`. -Supersedes the "PR-22 ChangeSet-based persistence" row in `PLAN.md` — that -was the first attempt, which grew stale and has been partially rewritten. - -Branches (all called `feat/platform-wallet2` in their respective repos): - -- **rust-dashcore** `feat/platform-wallet2` — base `v0.42-dev` -- **platform** `feat/platform-wallet2` — base `feat/platform-wallet` - -## Why - -The original `feat/platform-wallet` attempt modified dashcore's -`WalletManager` to use per-wallet `Arc>` locks and a -`WalletPersistence` trait baked into the manager. It caused 7× SPV -slowdown from lock contention. - -`feat/platform-wallet2` takes a different approach: - -- Keep dashcore's `WalletManager` **identical to `v0.42-dev`** — no - per-wallet locks, no baked-in persistence. -- Build persistence on top of a **BDK-style changeset API** that - every wallet mutation emits, and that an external persister consumes - at its own pace. -- The changeset carries the wallet's **native types** (`Utxo`, - `TransactionRecord`, `ManagedIdentity`) rather than flattened - persistence-friendly entries. Persistence backends translate at - the storage layer. - -## Shape of the design (BDK pattern) - -The mental model is BDK's `apply_block` / `apply_changeset` — mutation -methods return a `ChangeSet` describing what changed; apply methods -consume one to restore state during load. - -**Key-wallet side (`rust-dashcore/key-wallet`):** - -``` -WalletChangeSet -├── account_keys : Option // HD accounts added -├── chain : Option // synced height / block hash -├── balance : Option // cached balance delta -└── per_account : BTreeMap - // one bucket per ManagedCoreAccount - -AccountChangeSet -├── addresses_used : BTreeSet

-├── highest_used : BTreeMap -├── utxos_added : BTreeMap // native type, not flattened -├── utxos_spent : BTreeSet -├── utxos_instant_locked: BTreeSet -└── transactions : BTreeMap -``` - -**Mutation API:** every per-account mutation method on `ManagedCoreAccount` -returns `(value, WalletChangeSet)`. The method writes into -`cs.per_account[self.account_type.to_account_type()]` directly — no -address-based routing at accumulation time. - -**Apply API:** `ManagedWalletInfo::apply_changeset(wallet, cs)` delegates -per-bucket to `ManagedCoreAccount::apply_changeset(account_type, bucket)` -via a direct `get_by_account_type_mut` lookup. No scanning, no fallbacks. - -**Invariants:** -- Idempotent — applying the same changeset N times = applying it once. -- Monotonic on state flags — `is_confirmed` / `is_instantlocked` OR'd - on replay; never regress. -- No re-emission — apply doesn't return a new changeset. -- Best-effort routing — buckets with unknown `AccountType` are silently - skipped with a `tracing::warn!`. -- Loud on `account_keys` failures — a missing account cascades, so - errors bubble up. - -**Platform-wallet side (`packages/rs-platform-wallet`):** - -`PlatformWalletInfo` wraps a `ManagedWalletInfo` inside `core_wallet` and -adds four platform-specific fields: - -- `identity_manager` — registered identities, DPNS names, top-ups -- `tracked_asset_locks` — asset lock lifecycle (created → IS-locked → used) -- `platform_address_balances` — credit balances per platform address -- `token_balances` / `token_watched` — Platform token balances - -Each gets its own sub-changeset. The top-level `PlatformWalletChangeSet` -will look like: - -```rust -pub struct PlatformWalletChangeSet { - pub core: Option, // delegated to core_wallet - pub identities: Option, - pub contacts: Option, - pub platform_addresses: Option, - pub asset_locks: Option, - pub token_balances: Option, // NEW -} -``` - -`PlatformWalletInfo::apply_changeset` will delegate `core` to -`ManagedWalletInfo::apply_changeset` and apply platform-specific -sub-changesets in-place. - -## What's done - -| Phase | Commit | What | -|-------|--------|------| -| **1+2** | `8384ae88` (dashcore) | `changeset` module skeleton, `Merge` trait, `AddressPool::set_highest_used`, `ManagedCoreAccount::{insert,remove}_utxo`, `Wallet::add_account` made idempotent, first round of tests | -| **3+4+5+6** | `66e6462d` (dashcore) | BDK-style mutate-and-return API: every `ManagedCoreAccount` / `ManagedWalletInfo` mutation method returns `(value, WalletChangeSet)`. First review pass flagged: field rename drift, routing gaps, update_utxos state-flag bug. | -| **P3 review fix** | `1d6b6160` (dashcore) | Rename `last_revealed` → `highest_used`; monotonic UTXO state flags on replay; single-address routing strategy in apply; ApplyChangeSet trait extraction | -| **7** | `e018f773` (dashcore) | `ManagedWalletInfo::apply_changeset` restore path — address-routed | -| **7 review fix** | `1d6b6160` (dashcore) | (same commit as above) | -| **7.5 (BDK)** | `c07207a0` (dashcore) | **Structural:** `WalletChangeSet` restructured to BDK-style `per_account: BTreeMap`. Deletes ~5 routing helpers. Mutation methods pre-route at emission time. `AccountType` gets `Ord/PartialOrd/Hash` derives. | -| **7.5 review fix** | `6111c8fb` (dashcore) | `transactions: Vec → BTreeMap` (free last-write-wins on merge, kills O(n²) dedup loop); `update_utxos` inline style; `mark_utxos_instant_send` extend-not-assign bug fix; `PlatformPayment` replay regression fix via `contains_account_type`; `tracing::warn!` on unknown bucket; `apply` → `apply_changeset` rename; ApplyChangeSet trait deleted (unused); `_match` → `_matching` accessor rename; Ord landmine doc comment; stronger mixed-bucket test; stale doc fixes | -| **7.5 (platform wire-through)** | `a193fa3c` (platform) | `PlatformWalletInfo::update_balance` / `mark_instant_send_utxos` return `WalletChangeSet` — trait signature match | -| **8** | `45a9b126` (dashcore) + `c89f8412` (platform) | Drop `add_to_state` flag from address generation — every real caller was passing `true`, the one internal `false` was vestigial, the `false` branch produced ghost addresses. Net -14 LOC. **Also being extracted as a standalone PR off base branches** (see "Open PRs" below) because it's conceptually independent. | - -## Commit chain - -``` -c89f8412 (platform) refactor(platform-wallet): drop add_to_state arg -45a9b126 (dashcore) refactor(key-wallet): drop add_to_state flag -6111c8fb (dashcore) fix(key-wallet): Phase 7.5 review pass -c07207a0 (dashcore) refactor(key-wallet): BDK-style per-account changeset shape -1d6b6160 (dashcore) fix(key-wallet): Phase 7 review pass on apply_changeset -e018f773 (dashcore) feat(key-wallet): apply_changeset for idempotent restore -66e6462d (dashcore) feat(key-wallet): BDK-style mutate-and-return changesets -8384ae88 (dashcore) feat(key-wallet): changeset module + prep work -b8ffa26f (dashcore) feat(key-wallet-manager): insert_wallet + get_wallet_and_info_mut - (preceded by v0.42-dev head: dda1db7a) -``` - -Tests at each step: key-wallet 491 passing, key-wallet-manager 39 passing, -key-wallet-ffi compiles clean, platform-wallet compiles clean. - -## Where we are right now - -Mid-Phase-9a. The decision fork on `PlatformWalletChangeSet` was -resolved in favour of **Option A — unify** (the platform-wallet-local -sub-changeset types are legacy from the evo-tool migration and should -be deleted in favour of embedding `key_wallet::changeset::WalletChangeSet`). - -**Survey findings** (completed task P9a-1): - -`PlatformWalletChangeSet` at -`packages/rs-platform-wallet/src/changeset/changeset.rs:368` currently -has its own copies of: - -- `ChainChangeSet` (line 37) — duplicates `key_wallet::changeset::ChainChangeSet` -- `TransactionChangeSet` + `TransactionEntry` (lines 69, 92) - — duplicates key-wallet's, but with a flattened `TransactionEntry` - that drops most of the native `TransactionRecord` fields (direction, - input/output details, transaction_type, …) -- `UtxoChangeSet` (line 115) — **lossy**: stores - `BTreeMap` (just value!), drops address/script/coinbase/confirmed/etc -- `AccountChangeSet` (line 251) — uses - `BTreeMap<(u32, DerivationPathReference), u32>` keys vs key-wallet's - per-account `BTreeMap` - -All four predate the key-wallet `WalletChangeSet` work. The comment at -the top of the file even says *"Sub-changesets are modelled after the -real types used in `key-wallet`"* — they were always intended as -stand-ins. - -Genuinely platform-specific (keep as-is): -- `IdentityChangeSet` + `IdentityEntry` -- `ContactChangeSet` + `ContactRequestEntry` -- `PlatformAddressChangeSet` + `PlatformAddressEntry` -- `AssetLockChangeSet` + `AssetLockEntry` - -Missing (needs to be added): -- `TokenBalanceChangeSet` — for `PlatformWalletInfo.token_balances` / `token_watched` - -**`PlatformWalletPersistence` trait** at -`packages/rs-platform-wallet/src/changeset/traits.rs:23` already exposes -`store` / `flush` / `load`. The existing skeleton -`PlatformWallet::apply()` at `platform_wallet.rs:236` only wires -`asset_locks.restore_from_changeset_blocking(...)` with TODOs for the -rest — it'll be rewritten to become -`PlatformWalletInfo::apply_changeset`. - -The `queue_persist` / `flush_persist` / `load_persisted` methods on -`PlatformWallet` carry a `TODO: What these methods for? can we remove?` -comment — they're dead plumbing that predates this redesign and will -be replaced by the proper emit/apply flow in Phase 9c/9d. - -**`ApplyChangeSet` trait:** deleted from key-wallet in Phase 7.5-fix -(it was unused outside a single `WalletManager` wrapper). No stale -references remain in platform-wallet. - -## Write-path architecture — one write, one path - -The goal is **one write path per wallet state type**: - -``` -mutation API on PlatformWalletInfo (or core_wallet) - │ returns PlatformWalletChangeSet - ▼ -orchestrator merges changesets into a buffer - │ - ▼ -SqliteWalletPersister flushes buffer → Database::* writer methods → SQLite -``` - -No wallet-state writes bypass the persister. Direct `Database::*` -writes are allowed **only** for tables that have no corresponding -in-memory field on `PlatformWalletInfo` — lifecycle, settings, -caches, and UI metadata. - -### Catalogue of wallet-state tables and their PlatformWalletInfo mapping - -**Covered today (Category A):** - -| Table | PlatformWalletInfo field | Current write status | -|-------|--------------------------|----------------------| -| `utxos` | `core_wallet.per_account[..].{utxos_added,utxos_spent,utxos_instant_locked}` | **Persister-only**: `insert_utxo` has 0 non-test callers; `drop_utxo` has 1 non-persister caller in `model/wallet/utxos.rs::remove_selected_utxos` (tx building) | -| `wallet_transactions` | `core_wallet.per_account[..].transactions` | **Persister-only**: `replace_wallet_transactions` has 0 callers anywhere — dead code | -| `wallet_addresses` | `core_wallet.per_account[..].addresses_used` + `highest_used` | Persister writes subset; some direct writes via address balance updates | -| `wallet.(balance, last_terminal_block)` | `core_wallet.balance`, `core_wallet.metadata.synced_height` | `update_wallet_balances` called directly from backend tasks — duplicate | -| `identity` | `identity_manager.identities[id].identity` | **Duplicate**: `insert_local_qualified_identity` / `update_local_qualified_identity` have ~25 direct call sites across `backend_task/identity/*` AND `sync_identity_to_platform_wallet` mirrors to `IdentityManager` afterwards. Wrong direction — should be mutation-first, persister-second. | -| `top_up` | `identity_manager.identities[id].top_ups` | Same as identity — direct write, then sync | -| `wallet_identity_dpns_names` | `identity_manager.identities[id].dpns_names` (type mismatch: `Vec` vs runtime `Vec`) | Persister-only; table created inline by persister (not in v1.0-dev) | -| `asset_lock_transaction` | `tracked_asset_locks` | Direct writes from `backend_task/identity/register_identity.rs`, `top_up_identity.rs` + persister path — **duplicate** | -| `platform_address_balances` | `platform_address_balances` | `set_platform_address_info` / `delete_platform_address_info` called directly — need to go through changeset | -| `identity_token_balances` | `token_balances` | `insert_token_identity_balance` called directly + persister path — **duplicate** | -| `token` | `token_watched` (partial — metadata NOT in wallet) | Direct writes for token metadata (ticker, name, decimals). Only `token_watched: BTreeSet` is in the wallet; full metadata stays direct. | -| `dashpay_contacts` | `identity_manager.identities[id].established_contacts` | Direct writes from `backend_task/dashpay/*` — should emit contact changeset | -| `dashpay_contact_requests` | `identity_manager.identities[id].{sent,incoming}_contact_requests` | Direct writes from `backend_task/dashpay/*` — should emit contact changeset | - -**Out of scope (Category C — legitimately direct):** - -No in-memory field exists, no reason to add one. These stay as -direct writes: - -- `settings` — theme, password, network, UI state (not wallet state) -- `wallet` lifecycle columns — encrypted seed, salt, nonce, alias, is_main, password_hint, uses_password, core_wallet_name (create/rename/delete wallet) -- `dashpay_profiles` — avatar bytes, display name, public message (**gap candidate**: could add `profile: Option` to `ManagedIdentity` — defer, documented below) -- `dashpay_payments` — payment history (**gap candidate**: per-contact payment log — defer) -- `dashpay_contact_address_indices` — derivation indices for DashPay contact payments -- `dashpay_address_mappings` — address → contact runtime cache -- `contact_private_info` — encrypted local contact metadata (UI layer) -- `contested_name`, `contestant` — DPNS voting state -- `contract` — data contract metadata cache -- `proof_log` — audit trail -- `scheduled_votes` — voting scheduler -- `shielded_notes` — Zcash shielded integration (separate subsystem) -- `single_key_wallet` — legacy migration path -- `identity_order`, `token_order` — UI sort order - -**Gap candidates (Category D — should be in PlatformWalletInfo but aren't):** - -These have existing DB tables but nothing in `PlatformWalletInfo` -tracks them. They'd be legitimate category A once added. Deferred -out of scope for Phase 9a: - -1. **DashPay profiles** → add `profile: Option` on - `ManagedIdentity`. Fields: display_name, public_message, - avatar_hash, avatar_bytes (optional), created_at. -2. **DashPay payments** → add `payments: BTreeMap<(Identifier, Txid), PaymentEntry>` - on `ManagedIdentity` or top-level. Tracks per-contact payment - history with amounts, direction, timestamps. -3. **DashPay contact address derivation indices** → track per-contact - receive index for BIP44 contact payment paths. Currently in - `dashpay_contact_address_indices`. - -## Evo-tool coupling — the DB layer is stable, the persister is not - -Phase 9a-2 (unify `PlatformWalletChangeSet`) immediately breaks -evo-tool's `SqliteWalletPersister` at -`dash-evo-tool/src/changeset/sqlite.rs` because it imports every -deleted type (`ChainChangeSet`, `TransactionChangeSet`, -`TransactionEntry`, `UtxoChangeSet`, `AccountChangeSet`, …). Evo-tool -also has backend-task code in `register_identity.rs`, -`top_up_identity.rs`, and `contact_requests.rs` that constructs those -types directly. - -### What stays — the DB schema and query modules - -Research against evo-tool `v1.0-dev` (the canonical stable branch — -no feat/platform-wallet2 persistence work) confirms the **DB schema -is already rich enough** to hold the new native changeset types: - -- **`utxos`** (`src/database/initialization.rs`, `src/database/utxo.rs`) - — has `txid BLOB, vout INTEGER, address TEXT NOT NULL, value INTEGER NOT NULL, script_pubkey BLOB NOT NULL, network TEXT NOT NULL`. - The current persister writes empty-string placeholders for - `address` / `script_pubkey`; the columns themselves are sufficient - for native `key_wallet::Utxo` data. Missing columns for - `is_coinbase`, `is_confirmed`, `is_instantlocked`, `block_height` - — these are either recomputed on load or would need a schema - migration. -- **`wallet_transactions`** — has - `seed_hash, txid, network, timestamp, height, block_hash, net_amount, fee, label, is_ours, raw_transaction, status`. - Every column that the native `TransactionRecord` needs already - exists. `input_details` / `output_details` / direction / - transaction_type are recomputed from the deserialized - `raw_transaction` on load. -- **`wallet`** (`src/database/wallet.rs`) — has `seed_hash`, balance - columns, `last_terminal_block`, network metadata. Matches - `ChainChangeSet` + `BalanceChangeSet` needs. -- **`wallet_addresses`** — `seed_hash, address, derivation_path, path_reference, path_type`. - Maps to address pools but the relationship to key-wallet's - per-pool `highest_used` needs translation. -- **`platform_address_balances`** — `seed_hash, address, balance, nonce, updated_at, last_full_sync_balance`. - Matches `PlatformAddressChangeSet` 1:1 (with an extra `nonce` - column that the native platform_address_balances BTreeMap doesn't - carry — that's a gap, either drop the nonce column or add a nonce - field to the runtime state). -- **`asset_lock_transaction`** — has every field - `AssetLockEntry` carries (transaction_data, amount, instant_lock_data, - chain_locked_height, identity_id, account_index, funding_type, - identity_index, proof_data). -- **`identity`**, **`top_up`**, **`dashpay_*`** — identity metadata, - top-ups, contacts. Match the existing `IdentityChangeSet` / - `ContactChangeSet` shapes (with known gaps: identity timestamps, - contact metadata). - -### What was introduced by the persister and will be deleted - -- **`wallet_account_state`** — not in v1.0-dev. Created inline by - `SqliteWalletPersister::persist_accounts` via - `CREATE TABLE IF NOT EXISTS`. Schema: - `(seed_hash, account_index, path_reference, last_revealed, network)`. - **Needs migration decision:** either promote into a first-class - table in `initialization.rs` (keyed by the new per-account shape — - `(account_index, pool_type)` instead of `(account_index, path_reference)`) - or fold the reveal watermark into `wallet_addresses` derivation_path. -- **`wallet_identity_dpns_names`** — not in v1.0-dev. Created inline - by the persister. Schema: `(identity_id, name, network)`. Only - stores string names; `DpnsNameInfo` metadata from the runtime - `ManagedIdentity.dpns_names: Vec` is dropped. - -### Direct writes — the persister doesn't own everything - -The current persister **does not serialize all wallet writes**. -Direct writes that bypass the persister still exist at call sites in -SPV processing and backend tasks, calling domain writer functions -like `Database::insert_utxo`, `drop_utxo`, `replace_wallet_transactions`, -`store_wallet`, `update_wallet_balances`, `store_identities`, etc. - -This means: **the adapter doesn't need to replace all writes — -only the ones the current persister handles.** Direct writes keep -working as-is. The persister becomes a translation layer between -`PlatformWalletChangeSet` and a subset of the existing -`database::*` writer methods. - -### Strategy — thin adapter, not raw SQL - -The current `SqliteWalletPersister` (~1000 LOC) writes raw SQL that -duplicates logic already present in `database/wallet.rs`, -`database/utxo.rs`, `database/identities.rs`, etc. The **right -approach** is to rewrite it as a **thin translation layer**: - -```rust -// Pseudocode for the new SqliteWalletPersister::flush_one: -fn flush_one(&self, wallet_id: WalletId, cs: PlatformWalletChangeSet) -> Result<()> { - let seed_hash = wallet_id; // evo-tool uses WalletId == seed_hash - - // 1. Core wallet deltas from cs.core - if let Some(core) = &cs.core { - // 1a. Chain state → UPDATE wallet.last_terminal_block - if let Some(chain) = &core.chain { - if let Some(h) = chain.synced_height { - self.db.set_wallet_terminal_block(seed_hash, h, ...)?; - } - } - // 1b. Balance delta → recompute absolute + UPDATE wallet.(confirmed|unconfirmed|total)_balance - // (requires reading current balance first — not pure delta) - // 1c. Per-account buckets → iterate and delegate - for (account_type, bucket) in &core.per_account { - // UTXOs added — delegate to Database::insert_utxo per entry - for (outpoint, utxo) in &bucket.utxos_added { - self.db.insert_utxo( - outpoint.txid.as_byte_array(), - outpoint.vout, - &utxo.address, // real Address, not placeholder! - utxo.txout.value, - utxo.txout.script_pubkey.as_bytes(), - network, - )?; - } - // UTXOs spent — delegate to Database::drop_utxo - for outpoint in &bucket.utxos_spent { - self.db.drop_utxo(outpoint, network)?; - } - // Transactions → Database::replace_wallet_transactions (or insert path) - // — the native TransactionRecord has everything the - // existing wallet_transactions row needs - // highest_used → wallet_account_state (if we keep this table) - // OR update wallet_addresses based on the bucket's - // addresses_used + pool-type discriminator - } - } - - // 2. Platform-specific sub-changesets — existing delegation points - if let Some(asset_locks) = &cs.asset_locks { - for entry in asset_locks.asset_locks.values() { - self.db.store_asset_lock_transaction(seed_hash, entry, ...)?; - } - } - if let Some(identities) = &cs.identities { - self.db.store_identities(seed_hash, identities.identities.values(), ...)?; - } - // ... etc - - Ok(()) -} -``` - -**Key property:** every SQL statement lives in `database/*.rs`; the -persister is just translation + routing. Much smaller (~200 LOC vs -~1000), no raw SQL duplication, and writes go through the same -paths as the "direct writes" that bypass the persister — which -means the two paths can't diverge. - -### Gaps that need resolution before landing - -1. **Balance delta tracking.** The native `BalanceChangeSet` is a - *delta* (signed). The existing `wallet` table stores *absolute* - values. The adapter must either: - - Read the current balance, apply the delta, write the absolute. - (Racey if another writer races — but we hold the WalletManager - write lock at this point, so OK.) - - Store deltas-over-time, compute absolute on load. (Bigger - change, not worth it now.) - - **Or punt**: balance recompute happens on every - `update_balance()` call which re-derives from UTXOs. The - persister doesn't need to write balance at all — the next load - will recompute from the persisted UTXO set. -2. **`wallet_account_state` — keep or fold in?** The new per-account - shape is keyed by `AccountType` (enum) + `AddressPoolType` per - pool. The existing `wallet_account_state` uses - `(account_index, path_reference)`. Decision: **keep the table, - migrate the schema** to `(account_type_discriminant, - account_index, pool_type, last_revealed)`. One commit, additive. -3. **Identity timestamps** (`last_updated_balance_block_time`, - `last_synced_keys_block_time`). Not persisted today; they're - ephemeral cache on `ManagedIdentity`. Either add columns to - `identity` or accept them as runtime-only. **Decision: runtime - only.** They're just "don't hammer the network" timestamps, not - authoritative state. -4. **DPNS names as `Vec` vs `Vec`.** Pre-existing - data-shape mismatch. The current persister stores only strings, - dropping `DpnsNameInfo` metadata (expires_at, document_id). - **Decision: fix during this pass** — update `IdentityEntry.dpns_names` - to `Vec` and the persister to store the metadata. -5. **Contacts stored per-identity at runtime, at wallet level in - changeset.** Pre-existing data-shape mismatch. `ContactChangeSet` - uses `(from_identity, to_identity)` tuples keyed at the wallet - level, but `ManagedIdentity` holds `established_contacts`, - `sent_contact_requests`, `incoming_contact_requests` per-identity. - **Decision: fix during this pass** — route contact entries into - the owning `ManagedIdentity` on apply; change `ContactChangeSet` - to key by `(owner_identity_id, contact_identity_id)` matching - the DB tables. - -## What's next — Phase 9 breakdown - -Ordering matters here because platform-wallet and evo-tool share types -via path-dep. Breaking evo-tool's build is OK between sibling commits -on the same branch, but we want to land fixes paired so CI can go -green within a few commits of each other. - -### 9a-1 ✅ platform-wallet restructure (committed as `44d15fac`) - -Unified `PlatformWalletChangeSet`: -- Embedded `core: Option`. -- Deleted duplicate `ChainChangeSet`, `TransactionChangeSet`, - `TransactionEntry`, `UtxoChangeSet`, `AccountChangeSet`, and - `PlatformAddressEntry` (latter folded into - `PlatformAddressChangeSet.addresses: BTreeMap` - matching runtime shape). -- Added `TokenBalanceChangeSet`. -- Bridged platform-wallet's richer `Merge` trait to key-wallet's - via a one-off impl on `WalletChangeSet`. -- platform-wallet builds and its 72 unit tests pass. - -**Status:** landed. Evo-tool's current `feat/platform-wallet2` does -NOT build until 9a-4 lands. - -### 9a-2 platform-wallet mutation methods return changesets - -**Where:** platform-wallet, across: -- `src/wallet/identity/manager.rs` — `IdentityManager::add_identity`, - `remove_identity`, `set_label`, `set_last_scanned_index`, - `set_primary_identity`. And the per-identity mutations on - `ManagedIdentity`: `add_dpns_name`, `add_top_up`, - `update_balance_block_time`, `update_keys_sync_block_time`. -- `src/wallet/identity/managed_identity/contact_requests.rs` — - `add_sent_contact_request`, `add_incoming_contact_request`, - `remove_sent_contact_request`, `remove_incoming_contact_request`. -- `src/wallet/asset_lock/manager.rs` — `record_asset_lock`, - `update_status` (created → IS-locked → chain-locked). -- `src/wallet/platform_addresses/wallet.rs` — direct mutations of - `platform_address_balances` map. -- `src/wallet/tokens/*.rs` — `add_watched_token`, - `update_token_balance`. - -**Goal:** each mutation method returns `PlatformWalletChangeSet` -(or a narrower sub-changeset) instead of `()`. Callers -`result.changeset.merge(cs)` into an accumulator, same pattern as -key-wallet's `ManagedCoreAccount::mark_address_used` etc. - -**Not in scope:** wiring the accumulated changesets to the -persister. That's 9a-5. - -Commit on platform-wallet `feat/platform-wallet2`. Platform-wallet -builds and its unit tests pass. Evo-tool still broken until 9a-4. - -### 9a-3 `PlatformWalletInfo::apply_changeset` (restore path) ✅ - -Landed in two commits: -- `a48aeb3064` — prereq: carry full `EstablishedContact` in - `ContactChangeSet.established` (latent 9a-2 schema bug — auto-establish - paths emitted only the `(owner, contact)` pair, losing the underlying - `ContactRequest`s and making faithful replay impossible). -- (this commit) — `src/wallet/apply.rs` with the canonical - `PlatformWalletInfo::apply_changeset` plus the `apply_identity_entry` - helper on `IdentityManager` and the `apply_*_contact_request` / - `apply_established_contact` helpers on `ManagedIdentity`. - -Final shape: - -```rust -pub fn apply_changeset( - &mut self, - wallet: &mut Wallet, - cs: &PlatformWalletChangeSet, -) -> Result<(), ApplyError> -``` - -Sequencing: `cs.core` → identities (insert + remove + primary fixup + -scan watermark) → contacts (sent / incoming inserts → tombstone removes -→ established promotions, each routed to the owning `ManagedIdentity` by -`(owner, contact)` key) → platform addresses (insert + tombstone) → -asset locks (insert with `amount_duffs` → `amount` rename + tombstone) → -token balances (balance updates + watched/unwatched/removed_balances) → -`update_balance()` recompute and mirror into the lock-free `Arc`. - -Invariants: -- Idempotent (9 unit tests cover insert/remove/double-apply for every - sub-changeset). -- No re-emission — apply returns `Result<(), ApplyError>`. -- Best-effort routing for contacts: orphan owners are - `tracing::warn!`-ed and skipped. -- Loud on core failures: `cs.core` failures (HD account derivation - cascade) propagate as `ApplyError::CoreApply(String)`. - -Apply-side helpers added: -- `IdentityManager::apply_identity_entry(&IdentityEntry)` — in-place - update if the identity already exists, or fresh insert. Mirrors the - merge policy on `IdentityChangeSet` (revision-gated `identity` blob, - union for dpns/top_ups/key_storage). First-inserted identity becomes - primary if no primary is set. -- `IdentityManager::apply_remove(&Identifier) -> bool`. -- `ManagedIdentity::apply_sent_contact_request`, - `apply_incoming_contact_request`, `apply_removed_sent`, - `apply_removed_incoming`, `apply_established_contact` — raw - inserts/removes that skip the auto-establish fast path (it was - already captured at mutation time). - -Deletions: -- The stale `PlatformWallet::apply` (only handled asset locks, had TODOs - for everything else) is now a thin async wrapper that takes the - WalletManager write lock via `get_wallet_mut_and_info_mut` and - delegates to `PlatformWalletInfo::apply_changeset`. -- `AssetLockManager::restore_from_changeset_blocking` — its only caller - was the old `PlatformWallet::apply`, gone. - -**Open follow-up (cross-cutting, both repos) — `apply_changeset` should -consume the changeset by value.** Today both -`PlatformWalletInfo::apply_changeset` (platform-wallet) and -`ManagedWalletInfo::apply_changeset` (rust-dashcore key-wallet) take -`cs: &…ChangeSet`. Every `insert` then has to clone owned data out of -borrowed data: - -- platform-wallet side: `Identity` blobs, `KeyStorage`, `dpns_names`, - `ContactRequest`s, `EstablishedContact`s, `Transaction`s inside - `AssetLockEntry`. -- key-wallet side (bigger blast radius): every `Utxo` and every - `TransactionRecord` in - `ManagedCoreAccount::apply_changeset` is `.clone()`-ed before - insert (`managed_account/mod.rs:745, 768`). The - `Transaction` clone inside each `TransactionRecord` is the heaviest - item in any sync replay. - -For the typical persister-load case (deserialize → apply once → drop) -all of those clones are pure waste — the persister already produced -owned data, we could move it directly into the wallet maps. - -Fix in both crates: - -1. `key_wallet`: switch - `ManagedWalletInfo::apply_changeset(&mut self, wallet, cs: - WalletChangeSet)`. Inside - `ManagedCoreAccount::apply_changeset(account_type, bucket: - AccountChangeSet)` use `into_iter()` / `drain` on each map. The - monotonic-flag merge on UTXOs (`is_confirmed |= existing`) becomes - a `match` on `entry(*outpoint)`: if occupied, take the existing - flags then `into_mut()` or `insert(merged)`; otherwise insert by - value with no clone. -2. `platform-wallet`: switch - `PlatformWalletInfo::apply_changeset(&mut self, wallet, cs: - PlatformWalletChangeSet)`. Same `into_iter()` / `drain` pattern - for every sub-changeset. `apply_identity_entry` and the - `apply_*_contact_request` helpers grow `_owned` variants that - take the entry / request by value. -3. Single-variant API. No `apply_changeset` + `apply_changeset_owned` - overload — the borrow form should be deleted, not co-existed. - Hidden clones are not OK. - -Order: key-wallet first (it's the heavier path and platform-wallet -calls into it via `cs.core`), then platform-wallet. Both land before -9a-5 because the persister adapter is the one caller that benefits -most from the move. - -### 9a-4 round-trip tests on platform-wallet ✅ - -Landed in `apply.rs` as 10 new tests on top of the 9 synthesized-data -tests from 9a-3. Each round-trip test mutates `info_a` via the new -mutation API (which now returns sub-changesets), wraps the captured -sub-changeset into a `PlatformWalletChangeSet`, applies it to a -sibling `info_b`, and asserts convergence. This verifies the round- -trip contract: emitted changesets are faithful enough to rebuild -state via apply. - -Coverage (sync mutation surface): -- `round_trip_add_identity` — IdentityManager::add_identity -- `round_trip_remove_identity_reselects_primary` — - IdentityManager::remove_identity (verifies the primary re-selection - fixup in apply matches the mutation-side selection) -- `round_trip_set_label` — IdentityManager::set_label -- `round_trip_last_scanned_index_watermark` — - IdentityManager::set_last_scanned_index -- `round_trip_dpns_name_and_top_up` — ManagedIdentity::add_dpns_name - + record_top_up via snapshot_changeset -- `round_trip_block_time_updates` — ManagedIdentity timestamp - updates -- `round_trip_sent_contact_request_no_auto_establish` — plain - insert path on add_sent_contact_request -- `round_trip_auto_establish_contact` — incoming + sent → - auto-establish, verifies both pending sets drained on B and the - established contact rebuilt from the carried `EstablishedContact` -- `round_trip_remove_contact_request` — tombstone replay -- `round_trip_double_apply_is_idempotent` — multi-changeset replay - applied twice on B with no divergence - -`core` path: covered by key-wallet's own apply tests; the platform- -wallet integration with `cs.core` is delegated and doesn't need -duplicate coverage here. - -**Deferred — async / SDK-dependent mutation paths:** -The following mutation methods require an `Sdk`, broadcaster, and -`Notify` to construct the manager, so they can't run as plain unit -tests. Their round-trip coverage will land as integration tests in a -9a-4 follow-up: -- `AssetLockManager::{track_asset_lock, advance_asset_lock_status, - remove_asset_lock}` — apply side already covered by the - synthesized-data tests in `apply.rs` -- `TokenWallet::{watch, unwatch, unwatch_identity, sync}` -- `PlatformAddressWallet::{sync_balances, transfer, withdraw, - fund_from_asset_lock}` - -The synthesized-data tests in `apply.rs` already cover the apply -side for all of these — the gap is verifying the *mutation side* -emits a faithful changeset, which only matters once the persister -adapter is wired in 9a-5 / 9a-6 and the data path is exercised -end-to-end. - -Test count: 19 in apply.rs (9 synthesized + 10 round-trip) + 9 -contact workflow integration tests + 81 other lib tests, all green. - -Commit on platform-wallet `feat/platform-wallet2`. - -### 9a-5 rewrite evo-tool `SqliteWalletPersister` as thin adapter - -**Where:** dash-evo-tool `feat/platform-wallet2`, -`src/changeset/sqlite.rs`. - -**Goal:** replace ~1000 LOC of raw SQL with a ~200 LOC translation -layer that delegates to existing `Database::*` writer methods. - -**Scope:** -- Update imports to the new `PlatformWalletChangeSet` shape. -- Rewrite `flush_one` (thin adapter style): - - `cs.core.chain.synced_height` → update `wallet.last_terminal_block` - via existing or new `Database::set_wallet_terminal_block`. - - `cs.core.balance` — **punt.** Balance is recomputed from UTXOs on - load via `update_balance()`. Persister doesn't need to touch - balance columns. - - `cs.core.per_account[..].utxos_added` → `Database::insert_utxo` - with the native `Utxo` fields (real address + script, not - placeholders). - - `cs.core.per_account[..].utxos_spent` → `Database::drop_utxo`. - - `cs.core.per_account[..].transactions` → collect and call - `Database::replace_wallet_transactions` once per wallet. Map - the native `TransactionRecord` to evo-tool's `WalletTransaction` - shape (timestamp, height, block_hash, net_amount, fee, label, - is_ours, raw_transaction, status). - - `cs.core.per_account[..].highest_used` → write to - `wallet_account_state` table. **Migrate schema** to key on - `(seed_hash, account_type_discriminant, account_index, pool_type)` - → `last_revealed`. - - `cs.core.per_account[..].addresses_used` → update - `wallet_addresses` (mark addresses used, extend derivation - state). - - `cs.identities` → `Database::insert_local_qualified_identity` / - `update_local_qualified_identity`. This works because evo-tool's - `QualifiedIdentity` type wraps the dpp `Identity` that - `IdentityEntry` carries. - - `cs.contacts` → `Database::save_contact_request` / - `save_dashpay_contact` per-entry. - - `cs.platform_addresses` → `Database::set_platform_address_info` - per-address. - - `cs.asset_locks` → `Database::store_asset_lock_transaction` per-entry. - - `cs.token_balances` → `Database::insert_token_identity_balance` - per-entry. -- Rewrite `load`: - - Read from the same tables. Return `PlatformWalletChangeSet` - with `core` populated using native key-wallet types. -- Delete the persister-created `wallet_identity_dpns_names` table and - its writer helpers. DPNS names now live inside the persisted - identity blob via `IdentityEntry.dpns_names: Vec`. -- Migrate `wallet_account_state` schema as above (add columns or - drop-and-recreate via evo-tool's migration system). - -**Commit on evo-tool `feat/platform-wallet2`.** - -### 9a-6 rip out duplicate direct writes from evo-tool - -**Where:** dash-evo-tool, across `src/backend_task/`, `src/context/`, -`src/model/wallet/`. - -**Goal:** every write path that currently goes direct to -`Database::*` for a Category A table (from the catalogue above) -instead mutates `PlatformWalletInfo` and relies on the persister -to catch the emitted changeset. - -**Specific call sites to rewire:** - -1. **Identity write-through** (~25 call sites): - - `context/identity_db.rs::insert_local_qualified_identity` — - flip the order: mutate `platform_wallet.identity_manager.add_identity` - first (which emits a changeset), then feed the changeset to - the persister. The direct `self.db.insert_local_qualified_identity` - call goes away. - - Same for `update_local_qualified_identity`. - - Delete `sync_identity_to_platform_wallet` — the mutation IS - the sync. - - All ~25 backend-task call sites now call the mutation API - (which already returns a changeset) and queue it. - -2. **Asset lock writes** (`backend_task/identity/register_identity.rs`, - `top_up_identity.rs`): - - Currently call `store_asset_lock_transaction` directly. - - Flip to `platform_wallet.asset_locks.record_asset_lock` which - returns an `AssetLockChangeSet`. Queue for the persister. - -3. **Platform address balance updates** - (`backend_task/wallet/fund_platform_address_from_wallet_utxos.rs` etc.): - - Currently call `set_platform_address_info` directly. - - Flip to the platform wallet's platform-address mutation API. - -4. **Token balance updates** (`backend_task/tokens/mint_tokens.rs`, - `burn_tokens.rs`, etc.): - - Currently call `insert_token_identity_balance` directly. - - Flip to `platform_wallet.tokens.update_balance` which emits a - `TokenBalanceChangeSet`. - -5. **Transaction building UTXO reservation** - (`model/wallet/utxos.rs::remove_selected_utxos`): - - Currently drops the UTXO from the DB directly when a tx is - being built, to prevent double-spend. - - Flip to a platform-wallet mutation that marks the UTXO as - "reserved" or drops it from the in-memory set, emitting a - changeset. - -6. **SPV integration** — verify: does the SPV event handler - currently feed `TransactionCheckResult.changeset` to the - persister? If not, wire it. If it does, also stop any parallel - direct writes it might be doing. - -**Lifecycle writes stay direct** (Category C): -- `Database::store_wallet` / `remove_wallet` / `set_wallet_alias` / - `set_wallet_core_wallet_name` — wallet existence and metadata, - not state. -- All settings writes. -- All DashPay profile / payment / address mapping writes - (category D, deferred). -- All contract / contested_name / proof_log / scheduled_votes / - shielded_notes / single_key_wallet writes. - -**Commit on evo-tool `feat/platform-wallet2`.** - -### 9a-7 wire persister queue/flush lifecycle - -**Where:** evo-tool, in the platform wallet load/unload path and -SPV event loop. - -**Goal:** every emitted `PlatformWalletChangeSet` from a mutation -call site ends up in `SqliteWalletPersister` via `store(wallet_id, cs)`. -Flush strategy: - -- Immediate flush on `SyncComplete` SPV events. -- Debounced flush 30s after the last `store()`. -- Explicit flush on wallet unload / app shutdown. -- **Never** flush during cold SPV sync — the batching keeps the - buffer merged and writes happen at checkpoints. - -Replace the current `FlushStrategy::Immediate` default with a -scheduled flush driven from SPV events. - -**Commit on evo-tool `feat/platform-wallet2`.** - -### 9a-8 verify + review pass + commit - -cargo check + test across key-wallet, key-wallet-manager, -key-wallet-ffi, platform-wallet, dash-evo-tool. Everything green. -Review pass on the persister rewrite and the write-path rewiring: -rust-quality, simplicity, pattern reviewers in parallel. - -**9a scope does NOT include:** the Category D gaps (DashPay -profiles, payments, contact address indices). Those stay as -direct writes for now and get picked up in a follow-up phase -when the platform wallet grows the corresponding in-memory fields. - -## Phase 9b — Close the Category D gaps (future) - -After 9a lands, a handful of DashPay/wallet-adjacent tables still -have legitimate direct writes because `PlatformWalletInfo` has no -in-memory representation for them. Phase 9b grows the platform -wallet to cover these tables, then migrates their writes through -the changeset flow exactly like Phase 9a does for the Category A -tables. - -Each sub-phase follows the same 5-step pattern: - -1. Add the in-memory field to `PlatformWalletInfo` or `ManagedIdentity`. -2. Add a sub-changeset to `PlatformWalletChangeSet`. -3. Add mutation methods that return a changeset. -4. Extend `PlatformWalletInfo::apply_changeset` to handle the new sub. -5. Extend evo-tool's `SqliteWalletPersister` adapter to translate - the new sub-changeset to the existing DB tables. -6. Flip evo-tool's direct writes to route through the mutation API. - -### 9b-1 — DashPay profiles - -**Table:** `dashpay_profiles` (avatar_bytes, display_name, -public_message, created_at) - -**In-memory:** add `profile: Option` on -`ManagedIdentity`. - -**Writers to flip:** -- `Database::save_dashpay_profile` (dashpay.rs:223) -- `Database::save_dashpay_profile_avatar_bytes` (dashpay.rs:262) - -### 9b-2 — DashPay payment history - -**Tables:** `dashpay_payments` (owner_identity_id, contact_identity_id, -txid, amount) - -**In-memory:** add `payments: BTreeMap<(Identifier, Txid), PaymentEntry>` -on `ManagedIdentity`, where `PaymentEntry` carries amount, direction, -timestamp, status. - -**Writers to flip:** -- `Database::save_payment` (dashpay.rs:521) -- `Database::update_payment_status` (dashpay.rs:552) - -### 9b-3 — DashPay contact address derivation indices - -**Tables:** `dashpay_contact_address_indices` -(owner_identity_id, contact_identity_id, highest_receive_index, -bloom_registered_count) - -**In-memory:** add per-contact derivation state to -`ManagedIdentity.established_contacts`. Matches the key-wallet -`highest_used` pattern but scoped to BIP44 DashPay contact payment -paths. - -**Writers to flip:** -- `Database::update_highest_receive_index` (dashpay.rs:741) -- `Database::update_bloom_registered_count` (dashpay.rs:769) - -### 9b-4 — DashPay address mapping cache (maybe) - -**Table:** `dashpay_address_mappings` (address, contact_identity_id, -network, path_type) - -**Decision needed:** is this a pure runtime cache (ephemeral, never -persisted → out of scope entirely) or a persistence concern? If -the former, drop the table and recompute at load. If the latter, -route via a new sub-changeset. - -**Writers to flip (if persisted):** -- `Database::save_dashpay_address_mapping` (dashpay.rs:827) -- `Database::delete_dashpay_address_mappings_for_contact` (dashpay.rs:919) - -### 9b scope and ordering - -9b-1 through 9b-4 are **independent** and can land in any order -after 9a is complete. Each sub-phase is a self-contained commit -chain (add field → changeset → mutation → apply → evo-tool -persister adapter → direct-write rip-out). - -Estimated size: each sub-phase is smaller than a Phase 9a step -because the plumbing (changeset module, persister, evo-tool -integration points) already exists. The work is almost entirely -mechanical once the pattern is established by 9a. - -## Phase 10+ — Open questions, not planned - -- **Token metadata cache** (`token` table: ticker, name, decimals, - contract ID). Currently direct-write Category C. Could be - promoted to a changeset-driven field if token metadata becomes - wallet-scoped rather than global. -- **Data contract cache** (`contract` table). Similar question — - probably stays global/app-level, not wallet-scoped. -- **Wallet lifecycle via changesets?** Creating and deleting - wallets (the `wallet` table encrypted seed / alias columns) is - currently direct. Arguable whether that's worth changing — - wallet existence isn't really state, it's identity. Probably - leave alone. - -## Open PRs - -- **dashcore `refactor/drop-add-to-state-flag`** (being extracted by - background agent as of 2026-04-10) — Phase 8 refactor, standalone - PR off `v0.42-dev`. No dependency on the rest of this branch; safe - to land independently. -- **platform `refactor/drop-add-to-state-flag`** (same agent) — - companion PR off `feat/platform-wallet`, depends on the dashcore - PR merging first. - -## Review policy - -After each substantive commit, run three reviewer agents in parallel: - -- `rust-quality-engineer` — Rust correctness, unwraps, idempotency, - edge cases -- `code-simplicity-reviewer` — YAGNI, dead code, over-engineering -- `pattern-recognition-specialist` — consistency across mutation sites, - naming, doc comments - -The Phase 3/4/7/7.5 commits all had a review pass that caught real -bugs (the `update_utxos` state-flag gap, the `mark_utxos_instant_send` -assignment clobber, the `PlatformPayment` replay regression). Don't -skip the review round just because the commit "looks small." - -## Decisions log - -Key design calls and why, so we don't relitigate them: - -- **BDK-style mutate-and-return, not compute-and-apply.** BDK's pattern - is simpler at call sites (no two-phase dance) and avoids the - temptation to hide mutations behind a "pure" compute function that - still reads heaps of state. Mutation methods mutate and return a - delta; no separate `compute_*` methods. -- **Native types in changesets, not flattened entries.** The - persister translates native → storage at its layer. Keeps the - changeset API lossless and matches mutation-site output 1:1. -- **Per-account bucketing in `WalletChangeSet::per_account`, not flat - top-level fields.** Pre-routes at emission time. Kills the - multi-account transaction collision bug (same txid in two accounts - with different per-account views). Also kills ~5 routing helpers. -- **`WalletChangeSet` returned from every mutation, not - `AccountChangeSet`.** Uniform return type — one merge pattern at - every call site. Mutation methods know their own `account_type` and - write into `cs.account_bucket(ty)` directly. -- **`ApplyChangeSet` trait deleted.** Unused outside a single - `WalletManager` wrapper. Concrete - `impl WalletManager` is fine; if - `PlatformWalletInfo` needs a similar wrapper later, it can get its - own concrete impl block. -- **No `peek_*` address generation variants.** YAGNI — nothing in the - codebase needed pure-read address derivation. The old `add_to_state` - flag was dead weight. -- **`transactions: BTreeMap`, not `Vec`.** The - map encoding makes "at most one record per txid per account" a type - invariant; merge dedup is free via `BTreeMap::extend`. -- **UTXO state flags are monotonic on replay.** `is_confirmed` / - `is_instantlocked` OR'd with existing entry, never overwritten. - Prevents stale-replay regressions when a live mutation has already - advanced state. -- **Option A on `PlatformWalletChangeSet`** (this document's current - task): embed `key_wallet::changeset::WalletChangeSet` via a `core` - field, delete the legacy evo-tool-migrated duplicate types. The - platform-wallet types were stand-ins from before key-wallet had a - changeset; carrying both forward would be translation-layer tax - forever. - -## Links - -- [`PLAN.md`](PLAN.md) — the full platform-wallet project plan. - This document is a focused plug-in covering the persistence - redesign that supersedes the "PR-22 ChangeSet-based persistence" - row. -- [`key-wallet/CLAUDE.md`](../../../rust-dashcore/key-wallet/CLAUDE.md) - — key-wallet architectural overview. -- [`dash-evo-tool/CLAUDE.md`](../../../dash-evo-tool/CLAUDE.md) - — evo-tool architecture. Key-relevant section: "Database" (single - `Mutex`, domain-specific writer methods in - `src/database/*.rs`). The `v1.0-dev` branch is the canonical - stable base; `feat/platform-wallet2` has the persister work - being rewritten here. -- [`dash-evo-tool/src/database/initialization.rs`](../../../dash-evo-tool/src/database/initialization.rs) - — authoritative `CREATE TABLE` statements for wallet state. -- [`dash-evo-tool/src/changeset/sqlite.rs`](../../../dash-evo-tool/src/changeset/sqlite.rs) - — current `SqliteWalletPersister`. Being rewritten in Phase 9a-2. diff --git a/packages/rs-platform-wallet/PLAN.md b/packages/rs-platform-wallet/PLAN.md deleted file mode 100644 index a17978eaca2..00000000000 --- a/packages/rs-platform-wallet/PLAN.md +++ /dev/null @@ -1,5247 +0,0 @@ ---- -title: "feat: Platform Wallet — Complete Implementation & Evo Tool Integration" -type: feat -status: active -date: 2026-03-13 -updated: 2026-04-08 ---- - -# feat: Platform Wallet — Complete Implementation & Evo Tool Integration - -## Current Status (2026-04-08) - -### What's done - -**All core implementation is complete.** 22 PRs merged, covering the full platform wallet library and evo-tool integration: -- PRs 1–19 ✅: Full library + evo-tool migration (sub-wallets, signing, asset locks, DashPay, tokens, identity, SPV lifecycle) -- PR-22 ✅: ChangeSet-based persistence - -**Recent locking refactoring (committed, all tests passing):** -- Collapsed 7+ independent `Arc>` into a single `Arc>` -- Sub-wallets (CoreWallet, IdentityWallet, etc.) now hold `Arc>` instead of separate locks -- State getters moved from CoreWallet to PlatformWallet (e.g., `wallet.state().balance()` instead of `wallet.core().state().balance()`) -- CoreWallet cleanup: removed broadcaster field, removed dead methods -- Evo-tool fully migrated to new single-lock API - -**Test results:** -- 76 platform-wallet lib tests: **PASS** -- 347 evo-tool lib tests: **PASS** -- Backend E2E tests (testnet): **PASS** (cleanup_only in ~7s, 2026-04-08) - -### Next steps (immediate) - -**PR-30: Switch to dashcore WalletManager** — see detailed spec below. - -### Remaining PRs (future) - -| PR | Description | Status | -|----|-------------|--------| -| PR-20 | ~~Complete identity/asset lock lifecycle~~ Core API done (one-call methods + IS→CL fallback in IdentityWallet). Leftovers in PR-31. | Done | -| PR-21 | ~~Remove remaining duplication~~ TransactionBuilder already unified. Asset lock changeset restore leftover in PR-31. | Done | -| PR-23 | ~~Merge Wallet + ManagedWalletInfo in key-wallet~~ **Superseded** by PR-30 | Superseded | -| PR-24 | Comprehensive test suite + FFI update + final cleanup | Planned | -| PR-25 | Switch asset lock broadcast from DAPI to SPV | Planned | -| PR-26 | ~~Fix lock ordering deadlock~~ **RESOLVED** by single-lock refactoring | Done | -| PR-27 | ~~Merge SpvRuntime + SpvWalletAdapter~~ **Superseded** by PR-30 | Superseded | -| PR-28 | Full SPV replacement — migrate evo-tool SpvManager to PlatformWalletManager | Planned | -| PR-29 | Asset lock test coverage | Planned | -| PR-30 | Switch to dashcore WalletManager — delete SpvWalletAdapter, use BalanceUpdated events | **Next** | -| PR-31 | Leftovers from PR-20/21: evo-tool identity + asset lock cleanup | Planned | - ---- - -## PR-30: Switch to dashcore WalletManager - -### Goal - -Replace platform-wallet's custom `SpvWalletAdapter` (~330 lines), `SpvSyncState` (~55 lines), and `PlatformWalletInfoWriteGuard` (Drop-based balance update) with dashcore's `WalletManager`. This eliminates duplicated multi-wallet iteration logic, duplicated sync height tracking, and the Drop-based balance workaround. - -### Why - -`SpvWalletAdapter` reimplements exactly what `WalletManager` already does: iterate all wallets for each block/mempool transaction, call `check_core_transaction`, track synced heights, and (in WalletManager's case) emit `BalanceUpdated` events. `DashSpvClient` already accepts `Arc>`, and `WalletManager` implements `WalletInterface`. We can pass it directly. - -### Architecture - -``` -PlatformWalletManager - ├─ wallet_manager: Arc>> - │ └─ wallets: BTreeMap>> - │ - ├─ spv_client: DashSpvClient, ..., SpvEventForwarder> - │ └─ wallet: Arc>> (same Arc as above) - │ └─ handler: SpvEventForwarder (on_wallet_event fires automatically) - │ - ├─ wallets: BTreeMap (handles for consumers) - │ └─ each holds clone of Arc> from wallet_manager - │ - ├─ event_tx: broadcast::Sender - └─ sdk: Arc -``` - -**Lock hierarchy during block processing:** -1. DashSpvClient acquires `Arc>` write lock -2. WalletManager iterates `wallets` map (`&mut self` access) -3. For each wallet: acquires `Arc>` write lock -4. `check_core_transaction` runs → mutates state → persists changeset → releases per-wallet lock -5. WalletManager emits `BalanceUpdated` events via broadcast channel -6. DashSpvClient releases WalletManager write lock - -Sub-wallets (CoreWallet, IdentityWallet) go directly to their `Arc>` — skip the manager lock. - -**Event flow:** -``` -WalletManager.event_sender (broadcast) → spawn_broadcast_monitor task - → SpvEventForwarder.on_wallet_event() → PlatformWalletEvent::Wallet(WalletEvent) - → consumers (evo-tool balance updater, asset lock manager, etc.) -``` - -### dashcore changes (rust-dashcore repo) - -**1. New `ManagedWalletState` struct** — bundles Wallet + ManagedWalletInfo + Persister. -`ManagedWalletInfo` stays unchanged (pure UTXO/balance/account state). - -```rust -pub struct ManagedWalletState { - pub wallet: Wallet, - pub wallet_info: ManagedWalletInfo, - pub persister: P, -} -impl WalletInfoInterface for ManagedWalletState

{ - // All ~25 methods delegate to self.wallet_info -} -``` - -**2. `WalletPersistence` trait** — `store(changeset)`, `flush()`. `NoPersistence` for default/tests. - -**3. `WalletInfoInterface` gains `wallet()` / `wallet_mut()`** — so WalletManager can access -the Wallet through T without knowing the concrete type. - -**4. Remove `wallet: &mut Wallet` param from `check_core_transaction`** — T provides its -own wallet. Extract existing logic into `ManagedWalletInfo::check_core_transaction_with_wallet(&mut self, wallet: &Wallet, ...)` helper. `ManagedWalletState` impl calls helper with `&self.wallet` (disjoint field borrow, no borrow-checker issue). Persists changeset synchronously inside the method. - -**5. WalletManager struct change** — single map with per-wallet locks: -```rust -pub struct WalletManager { - wallets: BTreeMap>>, // was: two separate maps - // synced_height, filter_committed_height, event_sender unchanged -} -``` - -**6. Update all WalletManager methods** — wallet creation inserts `Arc::new(RwLock::new(T::from_wallet(&wallet)))`. `check_transaction_in_all_wallets` acquires per-wallet write locks. `get_receive_address`/`get_change_address` extract xpub before mutable borrow. Accessors rewritten for single map. - -### platform-wallet changes - -**1. `PlatformWalletInfo` implements `WalletInfoInterface`** — delegates to `self.wallet_info`. Persister moves from `PlatformWallet` into `PlatformWalletInfo`. `check_core_transaction` calls `self.wallet_info.check_core_transaction_with_wallet(&self.wallet, ...)` and persists `PlatformWalletChangeSet` synchronously. - -**2. Delete `SpvWalletAdapter`** (~330 lines) — replaced by WalletManager's WalletInterface impl. - -**3. Delete `SpvSyncState`** (~55 lines) — WalletManager tracks heights internally. - -**4. Delete `PlatformWalletInfoWriteGuard`** (~25 lines) — balance atomics updated via `BalanceUpdated` events through `SpvEventForwarder.on_wallet_event()`. - -**5. Update `SpvRuntime`** — `DashSpvClient, ...>`. - -**6. Restructure `PlatformWalletManager`** — holds `wallet_manager: Arc>>`, `wallets: BTreeMap` (handles sharing same Arc), `spv_client`. - -**7. Wire `BalanceUpdated` events** — add `update_from_parts(spendable, unconfirmed, immature, locked)` to `WalletBalance`. Event bridge updates atomics. - -### evo-tool changes - -- Update `SpvEventBridge` to handle `PlatformWalletEvent::Wallet(BalanceUpdated{...})` -- Update E2E test harness for new API surface - -### What gets deleted (~410 lines) - -| File | Lines | -|------|-------| -| `spv/wallet_adapter.rs` | ~330 | -| `spv/sync_state.rs` | ~55 | -| `PlatformWalletInfoWriteGuard` | ~25 | - -### Implementation sequence - -Phase 1 (dashcore): Add wallet()/wallet_mut() to trait → extract check_core_transaction helper → create ManagedWalletState + WalletPersistence → change WalletManager to single Arc> map → update all methods/tests/FFI. - -Phase 2 (platform-wallet): Move persister into PlatformWalletInfo → implement WalletInfoInterface → delete SpvWalletAdapter/SpvSyncState/WriteGuard → update SpvRuntime → restructure PlatformWalletManager → wire events. - -Phase 3 (evo-tool): Update event bridge → update E2E tests. - ---- - -## PR-31: Evo-tool identity + asset lock cleanup (leftovers from PR-20/21) - -### Goal - -Clean up remaining gaps from PR-20 (identity lifecycle) and PR-21 (asset lock duplication). Two concrete issues: - -### 1. Evo-tool uses low-level `_with_signer` instead of one-call identity APIs - -**Problem**: Evo-tool's `RegisterIdentityTask` and `TopUpIdentityTask` call the low-level `register_identity_with_signer()` / `top_up_identity_with_signer()` methods and manually implement IS→CL fallback (~40 lines each). Platform-wallet's `IdentityWallet` already has one-call methods (`register_identity_with_funding`, `top_up_identity_with_funding`, `funded_register_identity`, `funded_top_up_identity`) that handle IS→CL fallback internally. - -**Fix**: Switch evo-tool tasks to use the one-call APIs. Delete manual IS→CL fallback code in: -- `dash-evo-tool/src/backend_task/identity/top_up_identity.rs` (lines ~112-190) -- `dash-evo-tool/src/backend_task/identity/register_identity.rs` (lines ~255-298, ~394-430) - -### 2. Asset lock changeset restore is not implemented - -**Problem**: `PlatformWallet::apply()` calls `self.asset_locks.restore_from_changeset_blocking(asset_lock_cs)` but this method doesn't exist. Asset lock changesets are written to the persister (evo-tool's SQLite) but never loaded back. Evo-tool works around this with `register_with_asset_lock_manager()` bridge code that scans the DB and manually re-registers locks with the manager. - -**Fix**: -- Implement `AssetLockManager::restore_from_changeset_blocking()` in platform-wallet — reconstruct `tracked_asset_locks` from `AssetLockChangeSet` -- Verify `PlatformWallet::apply()` actually calls it correctly on wallet load -- Once changeset restore works, simplify evo-tool's `recover_asset_locks.rs` — the bridge code `register_with_asset_lock_manager()` becomes unnecessary since locks are restored from persistence automatically -- Update UI screens (`by_using_unused_asset_lock.rs`) to read from `AssetLockManager.list_tracked_locks()` instead of querying DB directly - -### Files to modify - -**platform-wallet:** -- `src/wallet/asset_lock/manager.rs` — implement `restore_from_changeset_blocking` -- `src/wallet/platform_wallet.rs` — verify `apply()` works end-to-end - -**evo-tool:** -- `src/backend_task/identity/top_up_identity.rs` — switch to one-call API -- `src/backend_task/identity/register_identity.rs` — switch to one-call API -- `src/backend_task/core/recover_asset_locks.rs` — simplify once changeset restore works -- `src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs` — read from manager -- `src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs` — read from manager - ---- - -## Overview - -**Goal**: Replace `dash-evo-tool`'s self-written wallet and duplicated DashPay crypto with `rs-platform-wallet`, building and integrating iteratively — one vertical slice at a time. - -**Approach**: Each PR implements a feature in `rs-platform-wallet` **and** immediately wires it into `evo-tool`, replacing the corresponding old code. Both repos share a feature branch pair (`feat/platform-wallet` in each), linked via `path` dependency in Cargo.toml. No "build everything first, integrate later" — integration is part of every PR. - -**Branch setup**: -- `platform` repo: `feat/platform-wallet` (feature branch, merges to `v3.1-dev` via PRs) -- `dash-evo-tool` repo: `feat/platform-wallet` (feature branch, merges to `v1.0-dev` via PRs) -- `Cargo.toml` in evo-tool: `platform-wallet = { path = "../../platform/packages/rs-platform-wallet" }` - ---- - -## Architecture (current — single-lock design, post-refactoring) - -``` -key-wallet (rust-dashcore) — reused types -├── Wallet ← mutable key store (mnemonic, xprv, accounts added during sync) -├── ManagedWalletInfo ← mutable UTXO state, accounts, balance, address pools -├── ManagedAccountCollection ← BIP44 + DashPay + PlatformPayment + Identity accounts -├── TransactionRouter ← transaction classification + checking -├── WalletTransactionChecker ← trait for tx matching (impl on ManagedWalletInfo) -├── TransactionContext ← Mempool | InstantSend | InBlock(BlockInfo) | InChainLockedBlock(BlockInfo) -└── BlockInfo ← { height, block_hash, timestamp } (all required) - -rs-platform-wallet -├── PlatformWalletInfo ← SINGLE struct behind Arc> -│ ├── wallet: Wallet -│ ├── wallet_info: ManagedWalletInfo -│ ├── identity_manager: IdentityManager -│ ├── tracked_asset_locks: BTreeMap -│ ├── platform_address_balances: BTreeMap -│ ├── token_watched: BTreeMap> -│ └── token_balances: BTreeMap<(Identifier, Identifier), TokenAmount> -│ -├── PlatformWallet ← cheaply cloneable handle to shared state -│ ├── wallet_id: WalletId -│ ├── sdk: Arc -│ ├── core: CoreWallet ← balance, UTXOs, addresses, tx building -│ ├── identity: IdentityWallet ← register, discover, top-up, withdraw, transfer, DPNS -│ ├── dashpay: DashPayWallet ← send/accept contact requests, sync contacts -│ ├── platform: PlatformAddressWallet ← DIP-17 sync, transfer, withdraw -│ ├── tokens: TokenWallet ← per-identity registry, sync, transfer, mint, burn -│ ├── asset_locks: Arc ← build, broadcast, track, proof lifecycle -│ ├── event_tx: broadcast::Sender -│ ├── persister: WalletPersister -│ └── state: Arc> ← THE SINGLE LOCK (all sub-wallets share this) -│ -│ State access: -│ ├── wallet.state() → RwLockReadGuard (async read) -│ ├── wallet.state_mut() → PlatformWalletInfoWriteGuard (async write, auto-updates balance) -│ └── Sub-wallets also hold state: Arc> -│ -├── Sub-wallets (all hold Arc> + Arc) -│ ├── CoreWallet ← state: Arc> -│ ├── IdentityWallet ← state: Arc> -│ ├── DashPayWallet ← state: Arc> -│ ├── PlatformAddressWallet ← state: Arc> + Signer -│ └── TokenWallet ← state: Arc> -│ -├── PlatformWalletManager ← multi-wallet + SPV coordinator (feature-gated: manager) -│ ├── sdk: Sdk -│ ├── wallets: Arc>> -│ ├── event_tx: broadcast::Sender -│ └── spv: SpvRuntime -│ -├── SpvRuntime (src/spv/runtime.rs) ← SPV lifecycle -│ ├── wallets: Arc>> -│ ├── event_tx: broadcast::Sender -│ ├── synced_height: AtomicU32 -│ ├── monitor_revision: Arc -│ ├── finality_waiters: Mutex>> -│ └── client: RwLock> -│ -├── SpvWalletAdapter (src/spv/wallet_adapter.rs) ← multi-wallet WalletInterface -│ └── Iterates ALL wallets for process_block/process_mempool_transaction -│ -├── SpvEventForwarder (src/spv/event_forwarder.rs) ← EventHandler impl -│ -├── Signing -│ ├── IdentitySigner ← Signer -│ ├── ManagedIdentitySigner ← key_storage + IdentitySigner fallback -│ └── PlatformAddressWallet ← Signer -│ -├── Events -│ ├── PlatformWalletEvent ← Wallet(WalletEvent) | Spv(SpvEvent) -│ └── TransactionStatus ← Unconfirmed | InstantSendLocked | Confirmed | ChainLocked -│ -└── [ShieldedWallet] ← PR-15: feature-gated Orchard/Halo2 - -evo-tool integration (current state): -├── Wallet struct embeds Arc — no duplicate fields -├── SPV: evo-tool's SpvManager still runs (old system), PlatformWalletManager bridges events -├── All UI reads go through wallet.state() (lock-free WalletBalance for hot path) -└── 347 lib tests passing -``` - -**Key design decisions:** -- **Single lock**: All mutable state in one `Arc>` — eliminates deadlocks from PR-26. Sub-wallets share the same Arc. `PlatformWalletInfoWriteGuard` auto-updates `WalletBalance` on drop. -- **No WalletHandle**: `PlatformWallet.clone()` is cheap (few atomic increments). A clone is a shared handle to the same state. -- **State access pattern**: `wallet.state()` for async read, `wallet.state_mut()` for async write. Sub-wallets use `self.state.read().await` / `self.state.write().await` internally. -- **Lock ordering eliminated**: With one lock, there's no ordering problem. The old multi-lock design had confirmed deadlock risks between wallet/wallet_info/tracked locks. -- **SPV dual-system**: Evo-tool still runs its own SpvManager alongside PlatformWalletManager. Full SPV replacement is PR-28. - ---- - -## PR History (completed) - -1. **PR-1** ✅: Project scaffold + `PlatformWallet` + `PlatformWalletManager` + `CoreWallet` + evo-tool bridge -2. **PR-2** ✅: CoreWallet deep integration — `Signer`, per-address data, asset locks, transaction sending -3. **PR-3** ✅: `IdentityWallet` — register, discover, top-up, withdraw, transfer, `IdentitySigner` -4. **PR-4** ✅: `DashPayWallet` — contact requests (simplified API), sync, accept -5. **PR-5** ✅: `PlatformAddressWallet` — DIP-17 sync, send, withdraw + review fixes -6. **PR-6** ✅: SPV lifecycle + TransactionStatus + EventHandler -7. **PR-7** ✅: Identity update + address fund flows + DPNS -8. **PR-8** ✅: Token operations — `TokenWallet` -9. **PR-9** ✅: Evo-tool integration Phase 1+2 — token + identity tasks migrated -10. **PR-10** ✅: ManagedIdentity — KeyStorage, IdentityStatus, DPNS names, 12-key discovery -11. **PR-11** ✅: Asset lock lifecycle + multi-mode funding -12. **PR-12** ✅: DashPay DIP-14/15 — 256-bit key derivation -13. **PR-13** ✅: Evo-tool integration Phase 3 — 20 tasks total migrated -14. **PR-14** ✅: Protocol completeness + evo-tool convergence — 27/42 tasks migrated -15. **PR-15** ✅: Shielded pool (feature-gated) -16. **PR-16** ✅: AssetLockFinalityEvent -17. **PR-17** ✅: Use dashcore asset lock builder -18. **PR-18** ✅: Replace evo-tool Wallet model with CoreWallet (~1,600 lines removed) -19. **PR-19** ✅: Migrate remaining Wallet fields (~2,700 lines removed) -22. **PR-22** ✅: ChangeSet-based persistence -**Uncommitted (on feat/platform-wallet):** Single-lock refactoring (PR-26 scope) — 7+ locks → single RwLock, state getters on PlatformWallet, CoreWallet cleanup - ---- - -## PR-6: SPV lifecycle + TransactionStatus + EventHandler - -### Status after v3.1-dev merge (2026-03-31) - -**Already done** (by merging v3.1-dev with dashcore rev `5db46b4d` and fixing compilation): -- `TransactionContext::InBlock(BlockInfo)` — updated from named fields -- `check_core_transaction(&mut wallet, update_state, update_balance)` — extra params adapted -- `process_mempool_transaction(tx, is_instant_send) -> MempoolTransactionResult` — new signature -- `watched_outpoints()` — implemented via `get_spendable_utxos()` -- `TransactionContext::InstantSend` variant — used in mempool processing - -**Cancelled**: `key-wallet-manager` crate merge into `key-wallet` — decision to keep them as separate crates. All imports remain `use key_wallet_manager::*`. - -### What PR-6 now delivers - -**1. TransactionStatus lifecycle tracking** - -Add `TransactionStatus` enum to `events.rs` and per-transaction status tracking in `CoreWallet`: - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -#[repr(u8)] -pub enum TransactionStatus { - Unconfirmed = 0, // In mempool, no IS lock - InstantSendLocked = 1, // IS-locked, not yet mined - Confirmed = 2, // Mined in a block - ChainLocked = 3, // In a chain-locked block (highest finality) -} -``` - -- Track status per txid in `CoreWallet` (or via `SpvWalletAdapter`) -- Emit `PlatformWalletEvent::Wallet(WalletEvent::TransactionStatusChanged)` on transitions -- `process_instant_send_lock()` on `SpvWalletAdapter`: update status, call `mark_instant_send_utxos()` on WalletInfoInterface -- Pattern from evo-tool: `src/model/wallet/mod.rs` lines 520-577 - -**2. EventHandler implementation** - -Implement `dash_spv::EventHandler` trait on a new `SpvEventForwarder` struct that forwards SPV events to `PlatformWalletEvent` broadcast channel: - -```rust -pub(crate) struct SpvEventForwarder { - event_tx: broadcast::Sender, -} - -impl EventHandler for SpvEventForwarder { - fn on_sync_event(&self, event: &SyncEvent) { /* → PlatformWalletEvent::Spv(SpvEvent::Sync(event)) */ } - fn on_network_event(&self, event: &NetworkEvent) { /* → PlatformWalletEvent::Spv(SpvEvent::Network(event)) */ } - fn on_progress(&self, progress: &SyncProgress) { /* → PlatformWalletEvent::Spv(SpvEvent::Progress(progress)) */ } - fn on_wallet_event(&self, event: &WalletEvent) { /* → PlatformWalletEvent::Wallet(event) */ } - fn on_error(&self, error: &str) { /* → tracing::error! */ } -} -``` - -`EventHandler` trait (from `dash-spv/src/client/event_handler.rs`): -- `on_sync_event(&self, event: &SyncEvent)` — sync lifecycle (headers stored, sync complete) -- `on_network_event(&self, event: &NetworkEvent)` — peer connection changes -- `on_progress(&self, progress: &SyncProgress)` — overall sync progress -- `on_wallet_event(&self, event: &WalletEvent)` — transaction received, balance updated -- `on_error(&self, error: &str)` — fatal errors -- All have default no-op implementations - -**3. Wire SPV lifecycle via `SpvRuntime`** - -SPV lifecycle is managed by `SpvRuntime` (extracted from `PlatformWalletManager`). -`PlatformWalletManager::spv().start(config)` / `spv().stop()` delegates to `SpvRuntime`: - -```rust -// SpvRuntime creates the SpvWalletAdapter (multi-wallet) and SpvEventForwarder -// DashSpvClient - -impl SpvRuntime { - pub async fn start(&self, config: ClientConfig) -> Result<(), PlatformWalletError> { - let adapter = SpvWalletAdapter::new(self.wallets.clone(), self.event_tx.clone(), self.monitor_revision.clone()); - let handler = Arc::new(SpvEventForwarder::new(self.event_tx.clone())); - // ...construct and start DashSpvClient - } -} -``` - -Need to determine concrete types for `N: NetworkManager` and `S: StorageManager` — check what evo-tool uses (likely `PeerNetworkManager` and `DiskStorageManager` from dash-spv). - -**~~4. AssetLockFinalityEvent tracking~~** — deferred to PR-11 (SPV migration) - -Currently `CoreWallet` uses SDK's `wait_for_asset_lock_proof_for_transaction()` which polls DAPI. -The SPV-based approach (listen for IS/CL events via finality channel) requires SPV to be running, -which isn't guaranteed for standalone `PlatformWallet`. Will be implemented when evo-tool's -`SpvManager` is migrated to `SpvRuntime::start()` (via `PlatformWalletManager::spv()`) in PR-11. - -### What was delivered (PR-6 + follow-up) - -| File | Changes | -|------|---------| -| `src/events.rs` | `TransactionStatus` enum, `SpvEvent` (Sync/Network/Progress), `PlatformWalletEvent` (Wallet/Spv) | -| `src/spv/wallet_adapter.rs` | Full `WalletInterface` impl, multi-wallet block/mempool processing, per-tx status tracking | -| `src/spv/event_forwarder.rs` | `EventHandler` impl forwarding SPV sync/network/wallet events to `PlatformWalletEvent` | -| `src/spv/runtime.rs` | `SpvRuntime` — SPV lifecycle, finality waiters, `start(config)`/`stop()` | -| `src/manager.rs` | `PlatformWalletManager` — CRUD + `spv()` accessor | -| `src/wallet/core/wallet.rs` | `transaction_statuses` map, `transaction_status()`, `update_transaction_status()` (monotonic) | -| `src/error.rs` | `SpvAlreadyRunning`, `NoWalletsConfigured`, `SpvError` variants | -| `Cargo.toml` | `dash-spv` dependency under `manager` feature gate | - ---- - -## PR-1 Status: Complete - -### What was delivered - -**Platform-wallet library** (`rs-platform-wallet`): -- `PlatformWallet` — standalone wallet with sub-wallets as stored fields, cheaply cloneable (all Arc fields) -- `CoreWallet` — balance, UTXOs, spendable UTXOs, address generation, monitored addresses, transaction history, immature transactions, synced/birth height, network -- `IdentityWallet`, `DashPayWallet`, `PlatformAddressWallet` — struct stubs sharing `wallet_info` and `wallet` Arcs -- `IdentitySigner` — stub for state transition signing -- `PlatformWalletManager` — multi-wallet coordinator with create/import/remove/list/get, event subscription -- `SpvWalletAdapter` — implements `WalletInterface` for SPV integration -- `IdentityManager` — refactored (no sdk field, added last_scanned_index) -- Events: `PlatformWalletEvent` (Wallet/Spv), `WalletEvent`, `SpvEvent`, `TransactionStatus` -- No `WalletHandle` — `PlatformWallet.clone()` is cheap (~35 atomic ops) -- `Wallet` stored as `Arc>` (mutable — accounts added during contact establishment/sync) -- Clean `mod.rs` files (module defs + re-exports only) -- `Send + Sync` assertions in `tests/thread_safety.rs` - -**Evo-tool integration** (`dash-evo-tool`): -- `PlatformWalletManager` added to `AppContext` with `DebugWrapper` -- `platform_wallets` bridge map (keyed by `WalletSeedHash`) + `WalletIdMapping` (bidirectional) -- Wallet creation/import/unlock registers with bridge via `register_with_platform_wallet_manager()` -- Lock/remove/clear cleans up bridge -- `get_platform_wallet()` / `require_platform_wallet()` helpers -- 7 backend tasks validate via bridge at entry point -- `generate_receive_address` has diagnostic logging comparing old vs new paths -- `transfer_to_addresses` tries `platform_wallets` first with fallback -- Migration guide documented in `platform_wallet_bridge.rs` - -**Dashcore** (`rust-dashcore`): -- `&mut Wallet` → `&Wallet` in `WalletTransactionChecker::check_core_transaction` -- All test callers cleaned up - -**Platform SDK** (separate PRs): -- PR #3375: dashcore rev update + `Network::Dash` → `Network::Mainnet` rename -- PR #3376: Extract fetch helpers to fix HRTB Send inference - ---- - -## PR-2 Status: Complete - -### What was delivered - -**Platform-wallet library** (`rs-platform-wallet`): -- `CoreAddressInfo`, `CoreAccountSummary` types (`wallet/core/types.rs`) -- Per-address methods: `all_address_info()`, `address_info()`, `account_summaries()`, `utxos_by_address()` -- `Signer` on `PlatformAddressWallet` — `blocking_read()` bridge with sequential lock acquisition (no dual-lock window) -- Asset lock tx building: `build_registration_asset_lock_transaction()`, `build_topup_asset_lock_transaction()`, `build_asset_lock_transaction()` — DIP-9 key derivation, greedy UTXO selection, two-pass fee calc, `AssetLockPayload`, P2PKH signing -- `broadcast_transaction()` via DAPI `BroadcastTransactionRequest` -- `send_transaction()` — full payment flow (UTXO select with correct output count, overflow-safe amount sum, build, sign, broadcast) -- `create_registration_asset_lock_proof()`, `create_topup_asset_lock_proof()` — build + broadcast + wait for proof via `Sdk::wait_for_asset_lock_proof_for_transaction()` -- `build_and_broadcast_*` convenience methods -- Error variants: `AssetLockTransaction`, `TransactionBroadcast`, `TransactionBuild`, `AssetLockProofWait` - -**Evo-tool integration** (`dash-evo-tool`): -- 4 signing callsites migrated from old `Wallet` to `platform_wallet.platform()` as `Signer` (transfer_platform_credits, withdraw_from_platform_address, fund_platform_address_from_asset_lock, top_up_identity_from_platform_addresses) -- Asset lock creation tasks use CoreWallet with fallback to legacy (`try_build_registration_via_platform_wallet`, `try_build_topup_via_platform_wallet`) -- Shared `broadcast_and_track_asset_lock` helper eliminates broadcast code duplication -- Address table UI: cached snapshot pattern via `WalletTask::LoadAddressInfo` → `BackendTaskSuccessResult::AddressInfo` → `cached_address_info` in `WalletsBalancesScreen` -- `CoreAddressInfo` re-exported in `platform_wallet_bridge.rs` - -**Review fixes applied:** -- Fee estimation uses actual output count (not hardcoded 2) -- `total_output` sum uses `checked_add` to prevent overflow -- Signer drops `wallet_info` lock before acquiring `wallet` lock (no deadlock window) - -### Next steps - -See PR-3 (IdentityWallet) in the PR Sequence section below. -5. **Payment building**: `send_transaction()` requires coin selection, signing, broadcast via SPV or RPC. -6. **SPV lifecycle**: `start_spv()` / `stop_spv()` are stubs — need network config wiring. - ---- - -## Problem Statement (historical — kept for context) - -**`dash-evo-tool`** maintains its own self-written wallet and duplicates DashPay crypto inline: - -- `src/model/wallet/` — custom wallet struct with `identities`, `utxos`, `platform_address_info` fields -- `backend_task/dashpay/dip14_derivation.rs` — DIP-14 256-bit key derivation -- `backend_task/dashpay/hd_derivation.rs` — DashPay contact xpub path wrapper -- `backend_task/dashpay/encryption.rs` — DIP-15 ECDH + AES-CBC (duplicates `rs-platform-encryption`) - -**`rs-platform-wallet`** is the intended canonical library but is incomplete: - -- No `PlatformWallet` struct — only `PlatformWalletInfo` (the old pattern, being deleted) -- No identity registration, top-up, withdrawal, or credit transfer -- No DIP-14 CKDpriv256/CKDpub256 -- No DashPay payment address derivation or payment sending -- No DIP-17 `AddressProvider` implementation -- No signing facade for state transition submission -- No bincode serialization for `IdentityManager`, `ManagedIdentity`, `ContactRequest`, `EstablishedContact` - -**What already exists and can be reused** (confirmed in codebase): - -- `rs-platform-encryption` crate — `derive_shared_key_ecdh`, `encrypt_extended_public_key`, `decrypt_extended_public_key`, `encrypt_account_label` — already a dependency of `rs-platform-wallet` -- `ContactRequest` and `EstablishedContact` structs — fully implemented -- `ManagedIdentity` with contact request management — fully implemented -- `IdentityManager` — implemented (needs `Arc>` wrapping + `last_scanned_index` field + removal of `sdk` field) -- `platform_wallet_info/contact_requests.rs` — `send_contact_request`, `add_incoming_contact_request`, `add_sent_contact_request` — consolidate into `DashPayWallet` -- `platform_wallet_info/identity_discovery.rs` — `discover_identities` — consolidate into `IdentityWallet::sync()` - ---- - -## Architecture (OUTDATED — see "Architecture (current)" section above) - -> **NOTE**: This section describes the OLD multi-lock design. The current design uses a single -> `Arc>` — see the "Architecture (current)" section at the top. - -``` -key-wallet (rust-dashcore) — reused types -├── Wallet ← mutable key store (mnemonic, xprv, accounts added during sync) -├── ManagedWalletInfo ← mutable UTXO state, accounts, balance, address pools -├── ManagedAccountCollection ← BIP44 + DashPay + PlatformPayment + Identity accounts -├── TransactionRouter ← transaction classification + checking -├── WalletTransactionChecker ← trait for tx matching (impl on ManagedWalletInfo) -├── TransactionContext ← Mempool | InstantSend | InBlock(BlockInfo) | InChainLockedBlock(BlockInfo) -└── BlockInfo ← { height, block_hash, timestamp } (all required) - -rs-platform-wallet -├── PlatformWallet ← cheaply cloneable (~35 atomic ops), all Arc fields -│ ├── wallet_id: WalletId -│ ├── sdk: Sdk ← ref-counted -│ ├── core: CoreWallet ← balance, UTXOs, addresses, tx building, asset locks -│ │ ├── wallet: Arc> -│ │ ├── wallet_info: Arc> -│ │ ├── transaction_statuses: Arc>> -│ │ └── tracked_asset_locks: Arc>> -│ ├── identity: IdentityWallet ← register, discover, top-up, withdraw, transfer, update, DPNS -│ │ ├── wallet, wallet_info, identity_manager: Arc> -│ │ ├── signer_for(identity_id) → ManagedIdentitySigner (key_storage + IdentitySigner fallback) -│ │ ├── update_identity(add_keys, disable_keys) ← IdentityUpdateTransition -│ │ ├── top_up_from_addresses() / transfer_credits_to_addresses() -│ │ ├── register_name() / resolve_name() / search_names() ← DPNS -│ │ ├── register_identity(IdentityFundingMethod) ← multi-mode funding -│ │ └── top_up_identity(TopUpFundingMethod) ← multi-mode top-up -│ ├── dashpay: DashPayWallet ← send/accept contact requests, sync contacts -│ │ ├── wallet, wallet_info, identity_manager: Arc> -│ │ ├── register_contact_payment_addresses() ← gap limit + SPV watch -│ │ ├── match_payment_to_contact() ← incoming payment attribution -│ │ └── DIP-14 256-bit derivation (ckd_priv_256/ckd_pub_256) ← moved to library -│ ├── platform: PlatformAddressWallet ← DIP-17 sync, transfer, withdraw, fund_from_asset_lock -│ │ ├── wallet, wallet_info: Arc> -│ │ ├── balances: Arc>> -│ │ └── implements Signer (blocking_read bridge) -│ ├── tokens: TokenWallet ← per-identity registry, sync, transfer, mint, burn, etc. -│ │ ├── wallet, identity_manager: Arc> -│ │ ├── watched: Arc>>> -│ │ ├── balances: Arc>> -│ │ └── watch/unwatch/sync/transfer/mint/burn/freeze/purchase/claim/set_price -│ └── [shielded: Option] ← feature-gated, Orchard ZK pool (PR-15) -│ -├── PlatformWalletManager ← multi-wallet + SPV coordinator (feature-gated: manager) -│ ├── sdk: Sdk -│ ├── wallets: Arc>> -│ ├── event_tx: broadcast::Sender -│ ├── spv: SpvRuntime ← extracted SPV lifecycle -│ └── sdk() / spv() / add_wallet() / remove_wallet() / get_wallet() / wallet_ids() -│ -├── SpvRuntime (src/spv/runtime.rs) ← SPV lifecycle, extracted from manager -│ ├── wallets: Arc>> -│ ├── event_tx: broadcast::Sender -│ ├── synced_height: AtomicU32 -│ ├── monitor_revision: Arc ← shared with SpvWalletAdapter -│ ├── finality_waiters: Mutex>> -│ ├── client: RwLock> -│ └── start(config) / stop() / synced_height() / notify_wallets_changed() -│ -├── SpvWalletAdapter (src/spv/wallet_adapter.rs) ← multi-wallet WalletInterface -│ ├── wallets: Arc>> ← ALL wallets -│ ├── process_block() iterates ALL wallets -│ ├── process_mempool_transaction() iterates ALL wallets -│ ├── watched_outpoints() unions ALL wallets (for bloom filter) -│ ├── process_instant_send_lock() → per-wallet status tracking -│ └── monitor_revision: Arc (shared with SpvRuntime) -│ -├── SpvEventForwarder (src/spv/event_forwarder.rs) ← EventHandler impl -│ └── forwards SPV sync/network/wallet events → PlatformWalletEvent -│ -├── Signing -│ ├── IdentitySigner ← Signer (ECDSA/BLS/EdDSA, DIP-9 paths) -│ ├── ManagedIdentitySigner ← Signer wrapping key_storage + IdentitySigner fallback -│ └── PlatformAddressWallet ← Signer (ECDSA P2PKH, DIP-17 paths) -│ -├── Events -│ ├── PlatformWalletEvent ← Wallet(WalletEvent) | Spv(SpvEvent) -│ ├── SpvEvent ← Sync(SyncEvent) | Network(NetworkEvent) | Progress(SyncProgress) -│ └── TransactionStatus ← Unconfirmed | InstantSendLocked | Confirmed | ChainLocked (monotonic) -│ -└── [ShieldedWallet] ← PR-15: shield, unshield, transfer, withdraw (Orchard/Halo2) - ├── keys.rs ← OrchardKeySet (SpendingKey → FullViewingKey → OrchardAddress) - ├── store.rs ← ShieldedStore trait, InMemoryShieldedStore - ├── prover.rs ← CachedOrchardProver with cached ProvingKey - ├── sync.rs ← note sync + nullifier sync - ├── operations.rs ← shield, unshield, transfer, withdraw, shield_from_asset_lock - └── note_selection.rs ← select_spendable_notes - -rs-sdk (Dash Platform SDK) — operations used by platform-wallet -├── Identity: PutIdentity, TopUpIdentity, WithdrawFromIdentity, TransferToIdentity -├── Identity update: IdentityUpdateTransition (add/disable keys, nonce-based) -├── Identity from addresses: TopUpIdentityFromAddresses, TransferToAddresses -├── DashPay: create/send_contact_request, fetch sent/received/all requests -├── Platform addresses: TransferAddressFunds, WithdrawAddressFunds, TopUpAddress -├── DPNS: register_dpns_name, resolve_dpns_name_to_identity, search_dpns_names -├── Tokens: transfer, mint, burn, freeze, purchase, claim, balance queries -├── Shielded: ShieldFunds, UnshieldFunds, TransferShielded, WithdrawShielded, ShieldFromAssetLock -├── Documents: PutDocument, TransferDocument, PurchaseDocument (for DashPay internals) -├── Fetch/FetchMany: identity, documents, balances, keys, platform addresses -└── sync_address_balances() with AddressProvider trait -``` - -**Key design decisions:** -- **No WalletHandle — use PlatformWallet.clone()**: All fields are Arc-wrapped, clone is ~35 atomic - ops (nanoseconds). A separate handle type added complexity without meaningful encapsulation. -- **Wallet is mutable** (`Arc>`): Accounts are added during DashPay contact - establishment and sync. The `check_core_transaction` trait takes `&mut Wallet` (write lock) - for transaction checking, as it may update wallet state (gap limit maintenance). -- **Sub-wallets share state via Arc**: All hold `Arc>` and - `Arc>`. SPV writes through the Arc — visible to all clones immediately. -- **Network from sdk.network**: Sub-wallets no longer store a `network` field — they use - `self.sdk.network` to get the network. Eliminates redundant cached state. -- **Lock ordering**: Always acquire `wallet` before `wallet_info` to prevent deadlocks. - Signers use sequential `blocking_read()` (drop first lock before acquiring second). -- **key-wallet-manager stays as separate crate**: Imports use `key_wallet_manager::*`. - The `WalletInterface` trait, `WalletEvent`, `BlockProcessingResult`, `MempoolTransactionResult` - are in `key_wallet_manager`. -- **SpvRuntime extracted from manager**: `SpvRuntime` is a standalone struct in `src/spv/runtime.rs` - that owns the `DashSpvClient`, tracks sync height, and manages finality waiters. Can be used - both with the multi-wallet manager and potentially standalone. Manager delegates via `spv()`. -- **Multi-wallet SPV adapter**: `SpvWalletAdapter` wraps `Arc>>` — processes blocks and mempool transactions against ALL managed wallets, - not a single wallet. `watched_outpoints()` unions outpoints from all wallets for bloom filters. -- **Shared monitor_revision via Arc**: `SpvRuntime` and `SpvWalletAdapter` share a - `monitor_revision` counter. `notify_wallets_changed()` bumps it on wallet add/remove, triggering - bloom filter rebuild in SPV. No manual filter management needed. -- **Manager simplified to CRUD + spv()**: `PlatformWalletManager` has `sdk()`, `spv()`, - `add_wallet()`, `remove_wallet()`, `get_wallet()`, `wallet_ids()`, `subscribe_events()`. No - create/import convenience methods — callers construct `PlatformWallet` directly, then `add_wallet()`. -- **TransactionStatus lifecycle**: Unconfirmed → InstantSendLocked → Confirmed → ChainLocked. - Tracked per transaction in CoreWallet. Events emitted on state changes. -- **PlatformWalletEvent**: Two variants only — `Wallet(WalletEvent)` and `Spv(SpvEvent)`. - `SpvEvent` wraps `Sync(SyncEvent)`, `Network(NetworkEvent)`, `Progress(SyncProgress)` from - dash-spv. `Spv` variant is feature-gated behind `manager`. -- **Feature-gated shielded**: Orchard/Halo2 deps are heavy (~30s ProvingKey). Behind `shielded` - feature. ShieldedWallet is fundamentally different (client-side state, note trial decryption, - commitment tree) so it's a separate sub-wallet, not an extension of PlatformAddressWallet. -- **Private key zeroization**: `Zeroizing<[u8; 32]>` for all derived key material. `blocking_read()` - drops locks before acquiring the next. Signer closures validate key ID parameters. -- **Simplified DashPay API**: `send_contact_request(sender, recipient)` — 2 params. All key indices, - ECDH, derivation resolved internally. `accept_contact_request(request)` — 1 param. -- **Lazy key derivation** (PR-10): `PrivateKeyData::AtWalletDerivationPath` avoids holding raw private - keys in memory for wallet-backed identities. Keys are derived on-demand during signing. -- **Identity status tracking** (PR-10): `IdentityStatus` state machine tracks identity lifecycle - from registration through confirmation. Enables UI to show pending/active/failed states. -- **Asset lock lifecycle** (PR-11): `TrackedAssetLock` tracks locks from broadcast to use. IS→CL - fallback is automatic via `resolve_asset_lock_proof()`. No lost or double-spent locks. -- **Multi-mode funding** (PR-11): `IdentityFundingMethod`/`TopUpFundingMethod` enums let callers - choose between wallet UTXOs, pre-existing proofs, specific UTXOs, or platform addresses. -- **DashPay protocol crypto in library** (PR-12): DIP-14 256-bit derivation, contact payment address - registration with gap limit, account reference calculation — protocol specs, not app logic. -- **Owned vs watched identity split** (PR-14): `ManagedIdentity` (owned, has key_storage, can sign, - identity_index required) vs `WatchedIdentity` (observed, read-only, no keys). Type system enforces - the distinction — no runtime "can I sign?" checks. Loaded-by-DPNS-name identities go to watched. -- **ManagedIdentitySigner resolves from key_storage** (PR-14): Three-step key resolution: (1) clear - bytes from storage, (2) derive from wallet at stored path, (3) fall back to standard IdentitySigner - derivation. Created via `managed_identity.signer(wallet, network)` or - `identity_wallet.signer_for(identity_id)`. - ---- - -## Implementation Plan (OUTDATED struct definitions — see current code) - -> **NOTE**: The struct definitions below show the OLD multi-lock design with separate -> `Arc>`, `Arc>`, etc. The CURRENT design uses -> a single `Arc>` containing all mutable state. Sub-wallets -> now hold `state: Arc>` instead of individual lock fields. -> See the source code for current struct definitions. - -`PlatformWallet` is a standalone wallet type (usable without SPV/manager). Cheaply cloneable -(a few atomic increments). No separate `WalletHandle` — use `PlatformWallet.clone()` directly. -`PlatformWalletManager` is the multi-wallet + SPV coordinator (no `WalletManager` dependency). - -### Struct Definitions - -```rust -// Standalone wallet — owns all state, sub-wallets as stored fields -// Usable directly for Platform-only operations (scripts, tests, no SPV needed) -// Same type is wrapped in per-wallet RwLock when managed by PlatformWalletManager -// NOTE: No `wallet` field on PlatformWallet — sub-wallets hold their own Arc refs -pub struct PlatformWallet { - wallet_id: WalletId, - sdk: Sdk, // cheaply cloneable (ref-counted) - core: CoreWallet, - identity: IdentityWallet, - dashpay: DashPayWallet, - platform: PlatformAddressWallet, - tokens: TokenWallet, -} - -// Sub-wallets — stored fields, share wallet_info via Arc> -// Network is accessed via sdk.network (no cached network field) -pub struct CoreWallet { - sdk: Sdk, - wallet: Arc>, - wallet_info: Arc>, - transaction_statuses: Arc>>, // finality tracking - tracked_asset_locks: Arc>>, // asset lock lifecycle -} - -pub struct IdentityWallet { - sdk: Sdk, - wallet: Arc>, - wallet_info: Arc>, - identity_manager: Arc>, -} - -pub struct DashPayWallet { - sdk: Sdk, - wallet: Arc>, - wallet_info: Arc>, - identity_manager: Arc>, // same instance as IdentityWallet -} - -pub struct PlatformAddressWallet { - sdk: Sdk, - wallet: Arc>, - wallet_info: Arc>, - balances: Arc>>, // balance cache -} - -pub struct TokenWallet { - sdk: Sdk, - wallet: Arc>, - identity_manager: Arc>, - watched: Arc>>>, // identity → tokens - balances: Arc>>, // cache -} - -// Multi-wallet + SPV coordinator (feature-gated: manager) -// Delegates SPV lifecycle to SpvRuntime; simplified CRUD API -pub struct PlatformWalletManager { - sdk: Sdk, - wallets: Arc>>, - event_tx: broadcast::Sender, - spv: SpvRuntime, // extracted SPV lifecycle -} - -// SPV client runtime — owns the DashSpvClient, tracks sync height, -// and manages asset-lock finality proof waiting. -// Extracted from PlatformWalletManager so it can be used standalone. -pub struct SpvRuntime { - wallets: Arc>>, - event_tx: broadcast::Sender, - synced_height: AtomicU32, - monitor_revision: Arc, // shared with SpvWalletAdapter - finality_waiters: Mutex>>, - client: RwLock>, -} - -// Multi-wallet SPV adapter — processes blocks against ALL wallets -pub(crate) struct SpvWalletAdapter { - wallets: Arc>>, - event_tx: broadcast::Sender, - platform_event_tx: broadcast::Sender, - synced_height: AtomicU32, - filter_committed_height: AtomicU32, - monitor_revision: Arc, // shared with SpvRuntime -} - -// IdentityManager is shared between IdentityWallet and DashPayWallet. -// Implements Clone — all fields are cheap to clone (just Arc clones). -// IdentityWallet and DashPayWallet share the same IdentityManager -// instance because PlatformWallet constructs them from the same source at build time. -// Two collections: `managed` for owned identities (can sign), `watched` for observed (read-only). -pub struct IdentityManager { - managed: Arc>>, // owned, has key_storage - watched: Arc>>, // observed, read-only - primary_identity_id: Arc>>, - last_scanned_index: Arc>, // persisted gap scan state - // REMOVED: sdk: Option> — SDK flows through caller struct -} -// Clone is cheap — just Arc clones. IdentityWallet and DashPayWallet hold -// the same Arc pointers — mutations visible to both. - -// ManagedIdentity — an owned identity with key material. Can sign transitions. -// Requires identity_index: u32 (always required, not Optional) — set during -// registration or discovery. Used for DIP-9 key derivation paths. -// (PR-10) Enhanced with KeyStorage, IdentityStatus, DPNS names, wallet association. -// (PR-14) identity_index is always required — type system enforces this. -pub struct ManagedIdentity { - pub identity: Identity, - pub identity_index: u32, // always required (not Optional) - pub key_storage: BTreeMap, // (PR-10) - pub status: IdentityStatus, // (PR-10) state machine - pub dpns_names: Vec, // (PR-10) associated DPNS names - pub wallet_seed_hash: Option<[u8; 32]>, // (PR-10) link to source wallet - pub wallet_index: Option, // (PR-10) HD index in wallet - pub sent_contact_requests: Vec, - pub received_contact_requests: Vec, - pub established_contacts: Vec, -} - -// WatchedIdentity — an observed identity without key material. Read-only, cannot sign. -// Loaded via load_identity_by_dpns_name() or other external lookups. -// No key_storage, no identity_index — just identity data + DPNS names + status. -pub struct WatchedIdentity { - pub identity: Identity, - pub dpns_names: Vec, - pub status: IdentityStatus, -} - -// ManagedIdentitySigner — Signer that resolves keys from a -// ManagedIdentity's key_storage with IdentitySigner fallback. -// Three-step key resolution: -// 1. Clear bytes from key_storage (PrivateKeyData::Clear) -// 2. Derive from wallet at stored path (PrivateKeyData::AtWalletDerivationPath) -// 3. Fall back to standard IdentitySigner derivation (DIP-9 path from identity_index) -// Created via managed_identity.signer(wallet, network) or identity_wallet.signer_for(identity_id). -pub struct ManagedIdentitySigner { - key_storage: BTreeMap, - identity_signer: IdentitySigner, // fallback for keys not in storage -} - -// (PR-10) Private key data — either raw bytes or lazy wallet derivation. -pub enum PrivateKeyData { - Clear(Zeroizing<[u8; 32]>), - AtWalletDerivationPath { - wallet_seed_hash: [u8; 32], - derivation_path: DerivationPath, - }, -} - -// (PR-10) Identity lifecycle state machine. -pub enum IdentityStatus { - Unknown, // Not yet checked against Platform - PendingCreation, // Registration submitted, awaiting confirmation - Active, // Confirmed on Platform - FailedCreation, // Registration failed (can retry) - NotFound, // Was active but no longer on Platform -} - -// (PR-10) DPNS name associated with an identity. -pub struct DpnsNameInfo { - pub label: String, - pub acquired_at: Option, -} - -// (PR-11) Asset lock lifecycle tracking. -pub struct TrackedAssetLock { - pub transaction: Transaction, - pub output_address: Address, - pub amount_duffs: u64, - pub proof: Option, - pub identity_id: Option, - pub status: AssetLockStatus, -} - -pub enum AssetLockStatus { - Broadcast, // TX sent, waiting for proof - InstantLocked, // IS proof received - ChainLocked, // CL proof received (higher finality) - UsedForRegistration, // Linked to an identity - UsedForTopUp, // Linked to an identity top-up -} - -// (PR-11) Multi-mode identity registration funding. -pub enum IdentityFundingMethod { - UseAssetLock { proof: AssetLockProof, private_key: PrivateKey }, - FundWithWallet { amount_duffs: u64 }, - FundWithUtxo { outpoint: OutPoint, txout: TxOut, address: Address }, - FundFromAddresses { inputs: BTreeMap }, -} - -// (PR-11) Multi-mode identity top-up funding. -pub enum TopUpFundingMethod { - UseAssetLock { proof: AssetLockProof, private_key: PrivateKey }, - FundWithWallet { amount_duffs: u64 }, - FundWithUtxo { outpoint: OutPoint, txout: TxOut, address: Address }, -} -``` - -**No dashcore changes required.** Only `key-wallet` crate types are used directly (`Wallet`, -`ManagedWalletInfo`, `ManagedAccountCollection`, `TransactionRouter`, `WalletTransactionChecker`). -`key-wallet-manager` remains a separate crate — imports use `key_wallet_manager::*`. - -**Concurrency model**: Sub-wallets share `Arc>` — this is the synchronization -point between SPV (writes UTXO state) and wallet operations (reads balance, builds transactions). -No outer per-wallet lock needed. The manager's `RwLock` is only for wallet add/remove. - -**No WalletHandle**: `PlatformWallet.clone()` is cheap (~35 atomic ops, all Arc fields). -A separate handle type was removed — it added complexity without meaningful encapsulation. - -**Sub-wallets are stored fields** on `PlatformWallet`: - -```rust -impl PlatformWallet { - pub fn core(&self) -> &CoreWallet { &self.core } - pub fn core_mut(&mut self) -> &mut CoreWallet { &mut self.core } - pub fn identity(&self) -> &IdentityWallet { &self.identity } - pub fn dashpay(&self) -> &DashPayWallet { &self.dashpay } - pub fn platform(&self) -> &PlatformAddressWallet { &self.platform } - pub async fn sync(&self) -> Result -} - -impl PlatformAddressWallet { - pub fn new( - sdk: Sdk, - wallet: Arc>, - wallet_info: Arc>, - ) -> Self { - Self { - sdk, wallet, wallet_info, - balances: Arc::new(RwLock::new(BTreeMap::new())), - } - } -} -``` - -`PlatformWalletManager` API — simplified CRUD + SPV access. Callers construct `PlatformWallet` -directly, then add it to the manager. No create/import convenience methods: - -```rust -impl PlatformWalletManager { - // Construction - pub fn new(sdk: Sdk) -> Self; - - // Accessors - pub fn sdk(&self) -> &Sdk; - pub fn spv(&self) -> &SpvRuntime; - - // Wallet CRUD - pub async fn add_wallet(&self, wallet: PlatformWallet) -> Result; - pub async fn remove_wallet(&self, wallet_id: &WalletId) -> Result; - pub async fn get_wallet(&self, wallet_id: &WalletId) -> Option; - pub async fn wallet_ids(&self) -> Vec; - - // Events — unified stream - pub fn subscribe_events(&self) -> broadcast::Receiver; -} - -impl SpvRuntime { - pub fn new(wallets: Arc>>, - event_tx: broadcast::Sender) -> Self; - pub fn synced_height(&self) -> u32; - pub fn notify_wallets_changed(&self); // bumps monitor_revision - pub async fn start(&self, config: ClientConfig) -> Result<()>; - pub async fn stop(&self) -> Result<()>; - pub async fn register_for_finality(&self, txid: Txid); - pub async fn wait_for_finality(&self, txid: Txid, timeout: Duration) -> Result; -} - -// Unified event enum — two variants only -pub enum PlatformWalletEvent { - Wallet(WalletEvent), // from block processing (TransactionReceived, BalanceUpdated) - #[cfg(feature = "manager")] - Spv(SpvEvent), // from DashSpvClient -} - -// SPV event — groups sync, network, and progress events from dash-spv -#[cfg(feature = "manager")] -pub enum SpvEvent { - Sync(dash_spv::sync::SyncEvent), - Network(dash_spv::network::NetworkEvent), - Progress(dash_spv::sync::SyncProgress), -} -``` - -Call sites — standalone `PlatformWallet`: - -```rust -let wallet = PlatformWallet::from_mnemonic(sdk, network, "word1 ...", "", 1_500_000, options)?; -wallet.identity().register_identity(amount, keys).await?; -wallet.dashpay().send_contact_request(&sender_id, &recipient_id).await?; -wallet.core().balance(); -``` - -Call sites — managed via `PlatformWalletManager` (construct wallet, then add to manager): - -```rust -let wallet = PlatformWallet::from_mnemonic(sdk, "word1 ...", "", 1_500_000, options)?; -let wallet = mgr.add_wallet(wallet).await?; // returns clone -mgr.spv().start(config).await?; // SPV syncs all managed wallets -wallet.identity().register_identity(amount, keys).await?; -wallet.dashpay().sync().await?; -wallet.core().balance(); -``` - -`sync()` on `PlatformWallet` orchestrates Platform-side syncs (SPV runs independently in background): - -```rust -pub async fn sync(&self) -> Result { - self.identity().sync().await?; - self.dashpay().sync().await?; - self.platform().sync_platform_address_balances(None).await?; - Ok(SyncResult::default()) -} -``` - ---- - -### 1.1 Wallet Construction - -> How a `PlatformWallet` is created from key material + Sdk. - -`PlatformWallet` is SPV-free. It needs only key material and an `Sdk`. No SPV config here — SPV -lives in `PlatformWalletManager` (via `SpvRuntime`). There is no `wallet` field on `PlatformWallet` -itself — each sub-wallet holds its own `Arc>` reference. Sub-wallets use -`sdk.network` for the network (no cached `network` field). - -Creation methods mirror `key-wallet`'s `Wallet` constructors, plus `sdk` parameter: - -```rust -impl PlatformWallet { - // Mirrors key-wallet Wallet creation methods + sdk - pub fn from_mnemonic( - sdk: Sdk, network: Network, mnemonic: &str, passphrase: &str, - birth_height: CoreBlockHeight, options: WalletAccountCreationOptions, - ) -> Result; - - pub fn from_xprv( - sdk: Sdk, network: Network, xprv: &str, - options: WalletAccountCreationOptions, - ) -> Result; - - pub fn from_seed( - sdk: Sdk, network: Network, seed: Seed, - options: WalletAccountCreationOptions, - ) -> Result; - - pub fn from_seed_bytes( - sdk: Sdk, network: Network, seed_bytes: &[u8; 64], - options: WalletAccountCreationOptions, - ) -> Result; - - pub fn from_xpub( - sdk: Sdk, network: Network, xpub: &str, can_sign_externally: bool, - ) -> Result; - - pub fn from_external_signable( - sdk: Sdk, network: Network, xpub: &str, - ) -> Result; - - pub fn random( - sdk: Sdk, network: Network, - options: WalletAccountCreationOptions, - ) -> Result<(Self, Mnemonic)>; - - pub fn from_bytes(sdk: Sdk, wallet_bytes: &[u8]) -> Result; -} - -// Standalone usage -let mut wallet = PlatformWallet::from_mnemonic( - sdk, Network::Testnet, "word1 word2 ...", "", - 1_500_000, WalletAccountCreationOptions::Default, -)?; -wallet.identity().register_identity(amount, keys).await?; - -// Multi-wallet with SPV — construct wallet, add to manager -let mgr = PlatformWalletManager::new(sdk.clone()); -let wallet = PlatformWallet::from_mnemonic( - sdk, "word1 word2 ...", "", - 1_500_000, WalletAccountCreationOptions::Default, -)?; -let wallet = mgr.add_wallet(wallet).await?; -mgr.spv().start(spv_config).await?; -``` - -**Internally**: each creation method calls `key-wallet`'s `Wallet::from_mnemonic()` (etc.) to create the -mutable key store (`Arc>`), then `ManagedWalletInfo::from_wallet()` for UTXO state, then -wraps both with `IdentityManager::new()` into a `PlatformWallet`. `PlatformAddressWallet::new()` is -called with a fresh `balances` cache (`Arc>>`). - -**`WalletAccountCreationOptions`**: always required (matches dashcore). Callers pass -`WalletAccountCreationOptions::Default` for standard BIP-44 account 0 + identity + DIP-17 accounts. - -**Birth height**: passed through to `ManagedWalletInfo::with_birth_height()` — used by SPV -to skip earlier blocks when loaded into `PlatformWalletManager`. Defaults to 0 (full sync). - -**`ManagedIdentity` requires `identity_index: u32`** (not Optional) — set during registration or -gap-limit discovery. Used for DIP-9 key derivation paths. Operations that need the index -(e.g., `send_contact_request`) return `IdentityIndexNotSet` if missing. - -#### Files - -- `packages/rs-platform-wallet/src/wallet/platform_wallet.rs` (replaces `platform_wallet_info/mod.rs`) -- `packages/rs-platform-wallet/src/manager.rs` (feature-gated `manager`) - -#### Migration - -The old `platform_wallet_info/` module (currently staged as deleted in git) must be fully removed. -`lib.rs` currently still imports `pub mod platform_wallet_info` — update to `pub mod platform_wallet`. - ---- - -### 1.2 Platform SDK Integration - -> Sdk lives in `PlatformWallet` and each sub-wallet — never in `IdentityManager`. - -**Current state**: SDK is stashed inside `IdentityManager.sdk: Option>` — accessed only by identity -discovery. Every async method that submits state transitions requires the caller to pass `&Sdk` separately. - -**Goal**: `PlatformWallet` holds `sdk: Sdk` as a plain field (cheaply cloneable via internal ref-counting — -confirmed at `rs-sdk/src/sdk.rs:134`). Each sub-wallet receives a clone at construction. All async methods -on sub-structs call `self.sdk` internally. - -#### SDK traits used by platform-wallet - -**Identity operations** (trait methods on `Identity`): -- `PutIdentity` — `put_to_platform_and_wait_for_response(sdk, proof, key, signer, settings)` -- `TopUpIdentity` — `top_up_identity(sdk, proof, key, fee_increase, settings) -> u64` -- `WithdrawFromIdentity` — `withdraw(sdk, address, amount, fee, signing_key, signer, settings) -> u64` - - Note: takes signer **by value** -- `TransferToIdentity` — `transfer_credits(sdk, to_id, amount, signing_key, signer, settings) -> (u64, u64)` - - Note: takes signer **by value** - -**Identity from addresses**: -- `TopUpIdentityFromAddresses` — fund identity from platform addresses -- `TransferToAddresses` — move identity credits to platform addresses - -**Platform address operations**: -- `TransferAddressFunds` — transfer between platform addresses -- `WithdrawAddressFunds` — withdraw platform address credits to Core L1 -- `TopUpAddress` — fund platform address from identity balance - -**Shielded pool** (feature-gated): -- `ShieldFunds`, `UnshieldFunds`, `TransferShielded`, `WithdrawShielded`, `ShieldFromAssetLock` - -**DPNS** (convenience wrappers): -- `register_dpns_name`, `resolve_dpns_name_to_identity` - -**Token transitions**: -- Transfer, mint, burn, freeze, purchase, claim, balance queries - -**Signing** (Signer trait implementations): -- `Signer` — `IdentitySigner` (withdraw/transfer take signer **by value**) -- `Signer` — `PlatformAddressWallet` directly - -**Documents** (for DashPay internals): -- `PutDocument`, `TransferDocument`, `PurchaseDocument` - -**Fetch/FetchMany**: -- Identity, documents, balances, keys, platform addresses -- `sync_address_balances()` with `AddressProvider` trait - -#### Tasks - -- **1.2.1** Add `sdk: Sdk` to `PlatformWallet` and each sub-wallet. Sub-wallets receive a clone at construction. -- **1.2.2** Remove `sdk: Option>` from `IdentityManager` — SDK access flows through the caller struct. - -#### Files - -- `packages/rs-platform-wallet/src/wallet/platform_wallet.rs` -- `packages/rs-platform-wallet/src/wallet/identity/manager.rs` - ---- - -### 1.3 Core Wallet Capabilities - -> Expose UTXO wallet: accounts, addresses, balances, send Dash, SPV sync, asset lock proofs. - -`key-wallet` (`rust-dashcore/key-wallet`) already implements all the building blocks: -`Wallet` (immutable key store), `ManagedWalletInfo` (mutable runtime state), -`TransactionBuilder` (coin selection, fee calc, signing), `AddressPool` (gap limit), -`WalletInfoInterface` + `ManagedAccountOperations` traits. -`dash-spv` handles SPV header sync and BIP157/158 compact filter transaction delivery. - -`CoreWallet` is a stored sub-struct that holds `Arc>` and exposes -these capabilities without leaking key-wallet internals. (`WalletInterface` is implemented -by `SpvWalletAdapter`, not `CoreWallet` — see §1.3.5 and §1.7.) - -**Note on `ManagedAccountCollection` field names** (confirmed from key-wallet source): -- Standard accounts: `standard_bip44_accounts: BTreeMap` (NOT a single `core_accounts` field) -- DashPay receive: `dashpay_receival_accounts: BTreeMap` -- DashPay send: `dashpay_external_accounts: BTreeMap` -- Platform payments: `platform_payment_accounts: BTreeMap` - -#### 1.3.1 — Wallet Initialization - -Accounts are created automatically at wallet construction — callers never call -`add_account` explicitly. `PlatformWallet::new()` passes -`WalletAccountCreationOptions::Default` to `key-wallet`, which derives standard BIP-44 -accounts and populates the initial address pool. This matches how evo-tool initializes -wallets via `import_wallet_from_extended_priv_key`. - -DashPay and DIP-17 platform payment accounts are added lazily on first use -(contact establishment / first platform address request). - -#### 1.3.2 — Address Generation - -```rust -pub fn next_receive_address(&mut self) -> Result - -pub fn next_change_address(&mut self) -> Result - -pub fn monitored_addresses(&self) -> Vec

-// Returns ALL watched addresses: BIP44 core + DashPay receival + (optionally) DIP-17 -// dash-spv uses this to match BIP157/158 compact block filters -``` - -Derives next unused BIP-44 external/change address respecting gap limit (20). -`monitored_addresses()` is the hook for SPV integration — `dash-spv` calls this via -`WalletInterface` to match BIP157/158 compact filters against wallet addresses. - -**Critical**: `monitored_addresses()` must include addresses from **all** account types in -`ManagedAccountCollection`, not just `standard_bip44_accounts`. This is how DashPay receiving addresses -get watched for incoming payments — no separate registration step, no manual bloom filter -management. When `DashPayWallet::sync()` adds a new `DashpayReceivingFunds` account (on contact -accepted), those addresses automatically appear in the next `monitored_addresses()` call. - -#### 1.3.3 — Balance & UTXO Access - -```rust -// Methods on CoreWallet: -pub fn balance(&self) -> WalletCoreBalance -// confirmed, unconfirmed, total in duffs - -pub fn utxos(&self) -> Vec -pub fn spendable_utxos(&self) -> Vec -// filtered: confirmed, non-dust, unlocked - -pub fn transaction_history(&self) -> Vec -pub fn immature_transactions(&self) -> Vec -// coinbase outputs not yet mature (< 100 blocks) -``` - -All delegate to `WalletInfoInterface` on `wallet_info`. - -**Per-address data** (research finding): `ManagedWalletInfo` already tracks richer per-address data -than the evo-tool model via `AddressPool::AddressInfo` (balance, total_received, total_sent, tx_count, -derivation_path, used status, label, metadata). CoreWallet needs methods to surface this: - -```rust -pub async fn all_address_info(&self) -> Vec -pub async fn address_info(&self, address: &Address) -> Option -pub async fn account_summaries(&self) -> Vec -pub async fn utxos_by_address(&self) -> BTreeMap> -pub async fn derivation_path_for_address(&self, address: &Address) -> Option<(DerivationPath, AccountType)> -``` - -Platform credits/nonces are NOT in key-wallet — they come from Platform state queries and -stay in a separate cache (populated by `PlatformAddressWallet::sync()`). - -**UI sync/async bridge**: Cached snapshot pattern — screen holds `Vec`, -background task calls `core_wallet.all_address_info().await`, sends snapshot via `TaskResult`, -screen renders from cache. Matches existing evo-tool `AppAction::BackendTask` / -`display_task_result` pattern. - -#### 1.3.4 — Transaction Send - -key-wallet only **builds** transactions — it has no send method. Broadcasting is a -separate concern (RPC, SPV, or DAPI). `CoreWallet` exposes `TransactionBuilder` directly -rather than a custom request struct — callers compose exactly what they need: - -```rust -// Methods on CoreWallet: -pub async fn send_transaction( - &self, - outputs: Vec<(Address, u64)>, -) -> Result - -// Power-user escape hatches for custom flows (DashPay, asset lock, etc.) -pub fn transaction_builder(&self) -> TransactionBuilder // change_address pre-set -pub fn spendable_utxos_with_keys(&self) -> (Vec, impl Fn(&Utxo) -> Option) -pub async fn broadcast_transaction(&self, tx: Transaction) -> Result -``` - -Common case: - -```rust -let txid = wallet.core.send_transaction(vec![(addr, amount_duffs)]).await?; -``` - -`send_transaction` handles coin selection (greedy UTXO selection with correct output count), -signing (P2PKH), and broadcast internally. Uses `checked_add` for overflow-safe amount sums. -Two-pass fee calculation: first pass estimates with placeholder, second pass with actual size. - -**`broadcast_transaction`**: broadcasts a raw Core transaction via DAPI `BroadcastTransactionRequest`. -This is the primary broadcast path when SPV is not active. - -**Broadcast paths**: -- **DAPI mode**: `broadcast_transaction()` via `BroadcastTransactionRequest` — always available -- **SPV mode**: `DashSpvClient::broadcast_transaction(tx)` → P2P to connected peers - -**`TransactionStatus`** tracks the lifecycle of each transaction: -```rust -pub enum TransactionStatus { - Unconfirmed, - InstantSendLocked, - Confirmed { height: u32 }, - ChainLocked { height: u32 }, -} -``` -Lifecycle: Unconfirmed → InstantSendLocked → Confirmed → ChainLocked. -Tracked per transaction in CoreWallet. Events emitted on state changes. - -#### 1.3.5 — SPV Sync Integration - -`dash-spv` (`DashSpvClient`) is the P2P sync layer. It uses **BIP157/158 compact -block filters** (not Bloom filters). It accepts `Arc>`. -`DashSpvClient` is now parameterized with `EventHandler` (generic `H`) for SPV event forwarding. - -**`SpvWalletAdapter`** implements the full `WalletInterface` trait (from `key_wallet_manager`): -- `process_block()` — iterates wallets, locks each `wallet_info`, calls `check_core_transaction` per tx -- `process_mempool_transaction(tx, is_instant_send: bool)` → `MempoolTransactionResult` -- `watched_outpoints() -> Vec` — for bloom filter construction -- `monitor_revision() -> u64` — bloom filter staleness detection; change triggers reconstruction -- `process_instant_send_lock()` — marks UTXOs as instant-send confirmed -- `monitored_addresses` — collects from all wallets' `ManagedWalletInfo` -- `synced_height` / `update_synced_height` — tracks via `AtomicU32`, updates each wallet - -Note: `check_core_transaction()` has gained an `update_balance: bool` parameter. - -SPV lives in `SpvRuntime` (accessed via `PlatformWalletManager::spv()`), not in `PlatformWallet`. -`PlatformWallet` is SPV-free. - -**Wiring** (`SpvRuntime::start(config)`): - -```rust -// SpvRuntime creates SpvWalletAdapter (multi-wallet) + SpvEventForwarder -let adapter = SpvWalletAdapter::new(wallets.clone(), event_tx.clone(), monitor_revision.clone()); -let handler = Arc::new(SpvEventForwarder::new(event_tx.clone())); -let client = DashSpvClient::new(config, network, storage, adapter, handler).await?; -``` - -**Block processing call chain**: - -``` -DashSpvClient - → SpvWalletAdapter::process_block() // WalletInterface impl - → wallets.read() → iterate wallets - → for each wallet: - → wallet.core.wallet_info.write() // Arc> — inner lock - → check_core_transaction(tx, update_balance) // WalletTransactionChecker (key-wallet) - → ManagedWalletInfo state mutated - → PlatformWalletEvent::Wallet(...) emitted -``` - -**`PlatformWalletEvent`** (unified enum, two variants): -- `Wallet(WalletEvent)` — `TransactionReceived`, `BalanceUpdated` (from block/mempool processing) -- `Spv(SpvEvent)` — `Sync(SyncEvent)`, `Network(NetworkEvent)`, `Progress(SyncProgress)` (feature-gated: `manager`) - -**`SpvEventForwarder`** impl (`EventHandler` trait) forwards SPV events to `PlatformWalletEvent`: -- `on_sync_event`, `on_network_event`, `on_progress`, `on_wallet_event`, `on_error` - -**Event subscription**: -```rust -let rx: broadcast::Receiver = mgr.subscribe_events(); -``` - -**Two event channels**: `WalletInterface::subscribe_events()` returns `WalletEvent` (for SPV). -`PlatformWalletManager::subscribe_events()` (public API) returns `PlatformWalletEvent` which -wraps `WalletEvent` + `SpvEvent`. Internally, the `SpvWalletAdapter` forwards `WalletEvent`s -into the `PlatformWalletEvent` channel. - -**No reorg notification**: `WalletInterface` has no `process_reorg` method — reorgs are handled -only at the `ChainTipManager` level in dash-spv; the wallet is never notified. - -`key-wallet-manager` remains a separate crate — imports use `key_wallet_manager::*`. -`WalletInterface`, `WalletEvent`, `BlockProcessingResult`, `MempoolTransactionResult` are in -`key_wallet_manager`. - -Transaction broadcasting goes through `DashSpvClient::broadcast_transaction(tx)` — P2P -to connected peers (see §1.3.4). `dash-spv` also delivers InstantLock and ChainLock events -needed for asset lock proof creation (§1.3.6). - -#### 1.3.6 — Asset Lock Proof Creation - -Required for identity **registration** and **top-up** (§1.4). - -```rust -pub async fn create_asset_lock_proof( - &self, - amount_duffs: u64, -) -> Result<(AssetLockProof, PrivateKey), CoreWalletError> -``` - -`CoreWallet` method — derives the next DIP-9 funding key internally, sources UTXOs -from `wallet_info`, builds an `AssetLock` special transaction via `TransactionBuilder`, -broadcasts it, waits for the InstantLock via SPV, returns `(AssetLockProof, funding_private_key)`. - -**Two proof types** (both fully implemented in rs-dpp): -- `AssetLockProof::Instant` — wraps InstantLock + full transaction + output index. Primary path. -- `AssetLockProof::Chain` — wraps `core_chain_locked_height` + outpoint. Fallback if InstantLock - is not received within timeout (suggest 60s, matching DashSync iOS behaviour). - -**Important**: The fallback to `AssetLockProof::Chain` requires the referenced block height to be -ChainLocked from Platform's perspective. The wallet must poll block confirmation before using -a Chain proof. - -DIP-9 funding key paths: -- Registration: `m/9'/coin'/5'/1'/identity_index` (non-hardened terminal index) -- Top-up (unbound): `m/9'/coin'/5'/2'/topup_index` (non-hardened terminal) -- Top-up (bound): `m/9'/coin'/5'/2'/registration_index'/topup_index` - -**Note**: `ManagedAccountCollection` has dedicated fields for these: -`identity_registration: Option`, -`identity_topup: BTreeMap`, -`identity_topup_not_bound: Option`. - -**Implementation notes**: -- **DIP-9** (not DIP-13) is the funding key derivation standard. Paths use `m/9'/coin'/5'/...`. -- **Two-pass fee calculation**: first pass estimates with placeholder inputs, second pass with actual - transaction size. Minimum fee: 3000 duffs. Size formula: `10 + inputs*148 + outputs*34 + 60` bytes. -- **Proof wait**: uses `Sdk::wait_for_asset_lock_proof_for_transaction()` (rs-sdk, 232 lines) which - polls Platform for proof availability after broadcast. -- **Reuse**: key-wallet `TransactionBuilder` for UTXO selection (greedy strategy). -- **Port ~300-400 lines**: asset lock tx construction (version-3 `Transaction` with `AssetLockPayload` - special payload, OP_RETURN burn output). -- **Port ~400 lines**: recovery scanning (scan DIP-9 funding paths for unconfirmed locks). -- DIP-9 key derivation reuses `Wallet::derive_extended_private_key()` + identity account paths. - -Additional API for top-up: -```rust -pub async fn create_topup_asset_lock_proof( - &self, - amount_duffs: u64, - identity_index: u32, -) -> Result<(AssetLockProof, PrivateKey), CoreWalletError> -``` - -#### 1.3.7 — Asset Lock Recovery - -```rust -pub async fn recover_asset_locks(&self) -> Result, CoreWalletError> -``` - -Scans known funding key paths for broadcast-but-unconfirmed asset lock transactions -and attempts to recover or rebroadcast them. Mirrors evo-tool's -`CoreTask::RecoverAssetLocks`. - -#### 1.3.8 — Asset Lock Tracking (PR-11) - -`CoreWallet` tracks asset locks from broadcast through to usage. This replaces ad-hoc -tracking in evo-tool and ensures asset locks are not lost or double-spent. - -```rust -// Methods on CoreWallet: -pub fn track_asset_lock(&self, lock: TrackedAssetLock) -pub fn unused_asset_locks(&self) -> Vec<&TrackedAssetLock> // Broadcast or IS/CL-proved, not yet used -pub fn mark_asset_lock_used(&self, txid: &Txid, usage: AssetLockStatus) -``` - -`tracked_asset_locks: Arc>>` holds all asset locks created -by this wallet. Status transitions: `Broadcast → InstantLocked → UsedForRegistration` (or -`→ ChainLocked → UsedForTopUp`). The `resolve_asset_lock_proof()` method (see below) -updates the status as proofs arrive. - -#### 1.3.9 — Asset Lock Proof Resolution with IS→CL Fallback (PR-11) - -When an InstantSend proof is rejected by Platform (`AssetLockInstantLockProofInvalid`), -the wallet automatically falls back to a ChainLock proof: - -```rust -pub async fn resolve_asset_lock_proof( - &self, - txid: &Txid, -) -> Result -``` - -Steps: -1. Try InstantSend proof (primary path — fast, ~2s) -2. If Platform rejects IS proof → query DAPI for tx to check `is_chain_locked` and `height` -3. If chain-locked and Platform has verified that height → build `ChainAssetLockProof` -4. If not chain-locked → return `AssetLockNotChainLocked` error - -This logic is shared by both identity registration and top-up flows. - -#### 1.3.10 — UTXO Retry on Exhaustion (PR-11) - -When building an asset lock TX fails due to insufficient UTXOs: -1. Release wallet lock -2. Refresh UTXOs (if SPV running, trigger rescan; otherwise return error) -3. Retry once - -```rust -pub async fn build_asset_lock_with_retry( - &self, - amount_duffs: u64, -) -> Result<(Transaction, PrivateKey), CoreWalletError> -``` - -#### Files - -- `packages/rs-platform-wallet/src/wallet/core/wallet.rs` (new) -- `packages/rs-platform-wallet/src/wallet/core/asset_lock.rs` (PR-11) — TrackedAssetLock, tracking methods -- Depends on: `key-wallet` (`ManagedWalletInfo`, `TransactionBuilder`, `WalletInfoInterface`, - `ManagedAccountOperations`, `FeeRate`, `SelectionStrategy`) -- Depends on: `key-wallet-manager` — `WalletInterface`, `WalletEvent`, - `BlockProcessingResult`, `MempoolTransactionResult` -- Depends on: `dash-spv` (`broadcast_transaction`, InstantLock/ChainLock events) - ---- - -### 1.4 Identity Management - -> Register, discover, refresh, top-up, withdraw, transfer, update identities. Register DPNS names. - -All methods are on `IdentityWallet` which holds `sdk`, `wallet: Arc>`, and `identity_manager`. -No `wallet: &Wallet` parameter anywhere — key derivation and signing use `self.wallet` directly. -`identity_index` is stored on `ManagedIdentity` as `u32` (always required, not Optional). - -**Managed vs watched routing** (PR-14): -- `sync()` adds discovered identities to `managed` collection (owned, with key_storage) -- `load_identity_by_index()` adds to `managed` collection (owned, with key_storage) -- `load_identity_by_dpns_name()` adds to `watched` collection (observed, read-only, no keys) -- `signer_for(identity_id)` creates `ManagedIdentitySigner` from the managed identity's key_storage - -**ManagedIdentity enrichments** (PR-10): -- `key_storage: BTreeMap` — lazy wallet derivation via `AtWalletDerivationPath`; avoids storing raw private keys in memory for wallet-backed identities -- `status: IdentityStatus` — state machine tracking identity lifecycle (`Unknown → PendingCreation → Active`, with `FailedCreation` and `NotFound` branches) -- `dpns_names: Vec` — DPNS names associated with this identity, populated during `sync()` -- `wallet_seed_hash: Option<[u8; 32]>` — links identity back to source wallet for key re-derivation on recovery -- `wallet_index: Option` — HD index in the wallet, paired with `wallet_seed_hash` - -**SDK method surface** (confirmed from `rs-sdk` source — these are trait methods on `Identity`, not on `Sdk`): -- `Identity::put_to_platform_and_wait_for_response(sdk, asset_lock_proof, private_key, signer, settings)` — `PutIdentity` trait -- `identity.top_up_identity(sdk, asset_lock_proof, private_key, user_fee_increase, settings) -> Result` — `TopUpIdentity` trait -- `identity.withdraw(sdk, address, amount, core_fee_per_byte, signing_key, signer, settings) -> Result` — `WithdrawFromIdentity` trait - - Note: takes signer **by value** -- `identity.transfer_credits(sdk, to_identity_id, amount, signing_key, signer, settings) -> Result<(u64, u64)>` — `TransferToIdentity` trait - - Note: takes signer **by value** - -**Additional SDK traits**: -- `TopUpIdentityFromAddresses` — fund identity from platform addresses -- `TransferToAddresses` — move identity credits to platform addresses -- Key update: no SDK trait — build `IdentityUpdateTransition` via DPP, broadcast with `BroadcastStateTransition` - -#### 1.4.1 — Register New Identity - -**Current** (PR-3): -```rust -pub async fn register_identity( - &mut self, - amount_duffs: u64, - key_types: &[IdentityKeySpec], -) -> Result -``` - -**Enhanced** (PR-11) — multi-mode funding via `IdentityFundingMethod`: -```rust -pub async fn register_identity( - &mut self, - funding: IdentityFundingMethod, - key_types: &[IdentityKeySpec], -) -> Result -``` - -The `IdentityFundingMethod` enum supports four funding paths: -- `FundWithWallet { amount_duffs }` — builds asset lock from wallet UTXOs (with UTXO retry on exhaustion), broadcasts, waits for proof with IS→CL fallback -- `UseAssetLock { proof, private_key }` — uses a pre-existing asset lock proof -- `FundWithUtxo { outpoint, txout, address }` — builds asset lock from a specific UTXO -- `FundFromAddresses { inputs }` — funds from platform addresses (no asset lock needed, uses `put_with_address_funding()`) - -Steps (for `FundWithWallet`): - -1. `core_wallet.build_asset_lock_with_retry(amount)` → `(Transaction, PrivateKey)` (PR-11: with UTXO retry) -2. `core_wallet.broadcast_transaction(tx)` + `core_wallet.track_asset_lock(...)` (PR-11: track from broadcast) -3. `core_wallet.resolve_asset_lock_proof(txid)` → `AssetLockProof` (PR-11: IS→CL fallback) -4. Set `ManagedIdentity.status = PendingCreation` (PR-10) -5. Derive auth keys at DIP-9 paths, build `IdentityPublicKey` entries -6. Store derivation paths in `key_storage` as `AtWalletDerivationPath` (PR-10) -7. Build `Identity` object with keys -8. `identity.put_to_platform_and_wait_for_response(&sdk, proof, &key, &signer, None)` → confirmed `Identity` -9. Set `ManagedIdentity.status = Active` (PR-10), store `wallet_seed_hash` and `wallet_index` (PR-10) -10. Add to `identity_manager` - -SDK traits used: -- `PutIdentity::put_to_platform_and_wait_for_response` — takes `&Identity`, `AssetLockProof`, `&PrivateKey`, `&impl Signer`, returns confirmed `Identity` -- `TopUpIdentity::top_up_identity` — takes `AssetLockProof`, `&PrivateKey`, returns `u64` (new balance). No signer needed. -- `WithdrawFromIdentity::withdraw` — takes `Option
`, amount, signer **by value**, returns `u64` -- `TransferToIdentity::transfer_credits` — takes `Identifier`, amount, signer **by value**, returns `(u64, u64)` - -**DIP-9 key path** (3-component path with `key_type`): The full path is -`m/9'/coin'/5'/0'/key_type'/identity_index'/key_index'` -where `key_type` is: `0'` = ECDSA, `1'` = BLS. The existing `key_derivation.rs` omits the -`key_type'` segment — this must be fixed. The `key_type'` level enables multi-algorithm keys -under the same identity index. - -**`signer_for` factory** on `IdentityWallet`: -```rust -pub fn signer_for( - &self, - identity_id: &Identifier, -) -> Result -``` -Looks up the `ManagedIdentity` from the `managed` collection (errors if identity is only watched), -clones its `key_storage`, and constructs a `ManagedIdentitySigner` with an `IdentitySigner` fallback. -Three-step key resolution: (1) clear bytes from storage, (2) derive from wallet at stored path, -(3) fall back to standard IdentitySigner derivation from `identity_index`. -Also available as `managed_identity.signer(wallet, network)` for direct construction. - -#### 1.4.2 — Identity Discovery (DIP-9 gap-limit scan) - -Implementation exists in the old `platform_wallet_info/identity_discovery.rs`. -Current behaviour (pre-PR-10): - -- Derives ECDSA auth key at `key_index=0` only -- Queries Platform via `Identity::fetch(&sdk, PublicKeyHash(key_hash))` — unique key hash -- `start_index` and `gap_limit` passed by caller — state not persisted -- SDK pulled from `IdentityManager.sdk` (stale pattern) -- Errors during fetch silently treated as misses - -**What was fixed (PR-3):** - -- Moved to `IdentityWallet::sync()`, no parameters -- `last_scanned_index: u32` stored in `IdentityManager` — persisted and resumed -- Gap limit hardcoded to 5 -- `PublicKeyHash` unique lookup — correct for authentication keys -- Fetch errors surfaced properly -- SDK sourced from `self.sdk` on `IdentityWallet` - -**Enhanced discovery (PR-10):** - -- Scan key indices 0..12 per identity index (12-key lookup window, matching evo-tool's `AUTH_KEY_LOOKUP_WINDOW`) -- Support ECDSA_HASH160 matching (not just full pubkey) — handles identities registered with hash-based key types -- Fetch DPNS names for each discovered identity via DPNS contract query (`records.identity == identity_id`) -- Store matched derivation paths in `KeyStorage` as `AtWalletDerivationPath` — enables lazy key derivation without holding raw private keys -- Set `IdentityStatus::Active` for discovered identities -- Store `wallet_seed_hash` and `wallet_index` on discovered identities for recovery - -```rust -pub async fn sync(&self) -> Result, PlatformWalletError> -``` - -Discovered identities are added to the `managed` collection (owned, with key_storage). - -#### 1.4.3 — Refresh Identity - -```rust -pub async fn refresh_identity( - &mut self, - identity_id: &Identifier, -) -> Result<(), PlatformWalletError> -``` - -Fetches latest balance and keys from Platform, updates `ManagedIdentity`. - -#### 1.4.4 — Top Up Identity Credits - -**Current** (PR-3): -```rust -pub async fn top_up_identity( - &mut self, - identity_id: &Identifier, - amount_duffs: u64, -) -> Result // returns new balance -``` - -**Enhanced** (PR-11) — multi-mode funding via `TopUpFundingMethod`: -```rust -pub async fn top_up_identity( - &mut self, - identity_id: &Identifier, - funding: TopUpFundingMethod, -) -> Result // returns new balance -``` - -The `TopUpFundingMethod` enum supports three funding paths: -- `FundWithWallet { amount_duffs }` — builds asset lock from wallet UTXOs (with UTXO retry), broadcasts, waits for proof with IS→CL fallback -- `UseAssetLock { proof, private_key }` — uses a pre-existing asset lock proof -- `FundWithUtxo { outpoint, txout, address }` — builds asset lock from a specific UTXO - -Note: `FundFromAddresses` for top-up uses `top_up_from_addresses()` (already implemented in PR-7). - -Steps (for `FundWithWallet`): - -1. `self.core.build_asset_lock_with_retry(amount_duffs)` → `(Transaction, PrivateKey)` (PR-11: UTXO retry) -2. `self.core.broadcast_transaction(tx)` + `self.core.track_asset_lock(...)` (PR-11: track lifecycle) -3. `self.core.resolve_asset_lock_proof(txid)` → `AssetLockProof` (PR-11: IS→CL fallback) -4. Call `identity.top_up_identity(&self.sdk, asset_lock_proof, private_key, None, None)` — `TopUpIdentity` trait -5. Update `ManagedIdentity` balance - -**Note**: `top_up_identity` takes `private_key: [u8; 32]` — pass the raw bytes of the asset lock funding private key. - -#### 1.4.5 — Withdraw Credits to Core - -```rust -pub async fn withdraw_identity_credits( - &mut self, - identity_id: &Identifier, - to_address: Option
, // None = next wallet receive address from self.core - amount_credits: u64, - core_fee_per_byte: Option, -) -> Result // returns remaining balance -``` - -Calls `identity.withdraw(&self.sdk, address, amount, core_fee_per_byte, signing_key, signer, settings)`. -Signs using `IdentitySigner` (see §1.10). - -#### 1.4.6 — Transfer Credits Between Identities - -```rust -pub async fn transfer_credits( - &mut self, - from_identity_id: &Identifier, - to_identity_id: &Identifier, - amount_credits: u64, -) -> Result -``` - -Calls `identity.transfer_credits(&self.sdk, to_identity_id, amount, signing_key, signer, settings)`. -Returns `(from_balance, to_balance)` — expose the from-balance to caller. - -#### 1.4.7 — Update Identity Keys - -```rust -pub async fn add_key_to_identity( - &mut self, - identity_id: &Identifier, - new_key_spec: IdentityKeySpec, -) -> Result<(), PlatformWalletError> - -pub async fn disable_identity_key( - &mut self, - identity_id: &Identifier, - key_id: u32, -) -> Result<(), PlatformWalletError> -``` - -`add_key_to_identity` builds an `IdentityUpdateTransition` via DPP (not a raw SDK trait) and -broadcasts it with `BroadcastStateTransition`. The new key is derived at the next available -key index under the identity's DIP-9 path. - -#### 1.4.8 — Top Up from Platform Addresses - -```rust -pub async fn top_up_from_addresses( - &mut self, - identity_id: &Identifier, - from_addresses: BTreeMap, -) -> Result // returns new balance -``` - -Uses `TopUpIdentityFromAddresses` SDK trait. Signs each address contribution with -its DIP-17 derived key via `Signer`. - -#### 1.4.9 — Transfer to Platform Addresses - -```rust -pub async fn transfer_to_addresses( - &mut self, - identity_id: &Identifier, - to_addresses: BTreeMap, -) -> Result // returns remaining identity balance -``` - -Uses `TransferToAddresses` SDK trait. - -#### 1.4.10 — DPNS Name Operations - -Convenience wrappers around SDK DPNS methods: - -```rust -pub async fn register_name( - &mut self, - identity_id: &Identifier, - name: &str, -) -> Result // document id - -pub async fn resolve_name( - &self, - name: &str, -) -> Result, PlatformWalletError> // identity id -``` - -`register_name` wraps `sdk.register_dpns_name()`. `resolve_name` wraps -`sdk.resolve_dpns_name_to_identity()`. - -#### 1.4.11 — Load Identity by Index (PR-14) - -Targeted lookup for a single wallet identity index (unlike `sync()` which does a gap scan). - -```rust -/// Derives auth key at identity_index, queries Platform by key hash. -/// If found, adds to IdentityManager's `managed` collection with KeyStorage + DPNS names. -/// Returns None if no identity is registered at this index. -pub async fn load_identity_by_index( - &self, - identity_index: u32, -) -> Result, PlatformWalletError> -``` - -Used when the caller knows the specific index (e.g., wallet recovery, user-selected index). -Adds to `managed` collection (owned, with key_storage derived from wallet). - -#### 1.4.12 — Refresh Identity (PR-14) - -Fetch latest state for a known identity from Platform (balance, keys, revision). - -```rust -/// Re-fetches the identity from Platform and updates the local ManagedIdentity. -/// Unlike sync() which discovers NEW identities, this updates an EXISTING one. -pub async fn refresh_identity( - &self, - identity_id: &Identifier, -) -> Result -``` - -Updates: `identity` field (balance, revision, keys), `status` → Active (if found), -`last_updated_balance_block_time`. - -#### 1.4.13 — Batch DPNS Refresh (PR-14) - -Refresh DPNS names for all managed identities. - -```rust -/// Queries Platform for current DPNS names for each identity in the manager. -/// Updates ManagedIdentity.dpns_names for all identities. -pub async fn refresh_dpns_names(&self) -> Result<(), PlatformWalletError> -``` - -Used on app startup or periodic refresh to keep names current. - -#### 1.4.14 — Load Identity by DPNS Name (PR-14) - -Resolve a DPNS name and load the identity into the manager. - -```rust -/// Resolves name → identity ID, fetches identity from Platform, adds to manager's -/// `watched` collection (read-only, no key material). -/// Returns None if name doesn't resolve. -pub async fn load_identity_by_dpns_name( - &self, - name: &str, -) -> Result, PlatformWalletError> -``` - -Combines `resolve_name()` + `Identity::fetch()` + adds to `watched` collection as `WatchedIdentity` -(observed, read-only, no keys). Cannot sign transitions for watched identities. - -#### Files - -- `packages/rs-platform-wallet/src/wallet/identity/wallet.rs` — IdentityWallet -- `packages/rs-platform-wallet/src/wallet/identity/manager.rs` — IdentityManager (managed + watched) -- `packages/rs-platform-wallet/src/wallet/identity/funding.rs` — IdentityFundingMethod, TopUpFundingMethod -- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/mod.rs` — ManagedIdentity -- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/key_storage.rs` — PrivateKeyData, IdentityStatus, DpnsNameInfo, WatchedIdentity -- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/block_time.rs` — BlockTime -- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/identity_ops.rs` -- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/contact_requests.rs` -- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/contacts.rs` -- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/label.rs` -- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/sync.rs` -- `packages/rs-platform-wallet/src/wallet/signer.rs` — IdentitySigner + ManagedIdentitySigner - ---- - -### 1.5 DashPay — Contacts, Transactions, Sync - -> Full DIP-14/15 implementation: contact requests, encrypted xpub exchange, payment address -> derivation, send/receive Dash between contacts. - -**Existing** (PR-4): `send_contact_request`, `accept_contact_request`, `decrypt_incoming_contact_request`, -`derive_payment_address_for_contact`, `send_dashpay_payment`, `sync()`, profiles, auto-accept proofs. - -**PR-12 adds**: DIP-14 256-bit derivation moved to library, contact payment address registration with -gap limit management, account reference calculation, incoming payment attribution via `match_payment_to_contact()`. - -#### DIP-14 Background - -DashPay uses 256-bit derivation (CKDpriv256/CKDpub256) for contact-specific address spaces: - -``` -m(userA)/9'/5'/15'/0'/(userA_id_256bit)/(userB_id_256bit)/index -``` - -The 256-bit identity ID indices prevent the 31-bit collision attack. `CKDpriv256` is fully -compatible with BIP32 for indices < 2^32; uses `ser_256(i)` (big-endian, 32 bytes) for larger indices. - -**Current state**: Lives in `dash-evo-tool/src/backend_task/dashpay/dip14_derivation.rs`. -Moves to `packages/rs-platform-wallet/src/platform_wallet/dashpay/dip14.rs` (PR-12). -This is protocol-level crypto and belongs in the library, not in the application. - -#### DIP-15 Background - -A contact request document on Platform contains: - -- `encryptedPublicKey` (exactly 96 bytes = IV 16 + ciphertext 80): AES-CBC-256 encrypted xpub - - xpub is 78 bytes in BIP32 wire format → padded to 80 bytes via PKCS7 (2 padding bytes) -- `encryptedAccountLabel` (optional 48-80 bytes): encrypted account name -- `accountReference` (32-bit): `(version<<28) | (HMAC-SHA256(senderKey, xpub)_28bits XOR account_28bits)` -- `senderKeyIndex` / `recipientKeyIndex`: identity key indices used for ECDH -- `$createdAt`, `$createdAtCoreBlockHeight`: required system fields -- **Documents are immutable**: `documentsMutable: false, canBeDeleted: false` — no update/delete API - -ECDH shared key: `SHA256( (y[31]&0x1 | 0x2) || x )` — confirmed correct per DIP-15. -Uses `libsecp256k1_ecdh` with compressed-point SHA256 hash (verify libsecp256k1 >= 0.3.0). - -**The `rs-platform-encryption` crate already implements all DIP-15 crypto** (confirmed in codebase): -- `derive_shared_key_ecdh()`, `encrypt_extended_public_key()`, `decrypt_extended_public_key()`, - `encrypt_account_label()`, `encrypt_aes_256_cbc()`, `decrypt_aes_256_cbc()` -- Already a dependency: `platform-encryption = { path = "../rs-platform-encryption" }` -- **Do NOT duplicate these functions** — reuse `rs-platform-encryption` directly. - -**Recipient key purpose**: The recipient's key must have `Purpose::DECRYPTION` (confirmed from -SDK's `contact_request.rs:229` — the SDK validates `Purpose::DECRYPTION` on the recipient key, NOT `ENCRYPTION`). - -#### 1.5.1 — DIP-14 Key Derivation (dashpay module) (PR-12: moved from evo-tool to library) - -```rust -// packages/rs-platform-wallet/src/platform_wallet/dashpay/dip14.rs (new file) -pub fn ckd_priv_256( - parent: &ExtendedPrivKey, - index: &[u8; 32], // 32-byte big-endian index (must be big-endian — interop requirement) - hardened: bool, -) -> Result - -pub fn ckd_pub_256( - parent: &ExtendedPubKey, - index: &[u8; 32], // non-hardened only -) -> Result - -pub fn derive_dashpay_contact_xpub( - master: &ExtendedPrivKey, - network: Network, - account: u32, - sender_id: &[u8; 32], - recipient_id: &[u8; 32], -) -> Result -// Path: m/9'/coin'/15'/0'/(sender_id_256bit)/(recipient_id_256bit) -// First 4 components hardened, last 2 (identity IDs) non-hardened -``` - -**DIP-14 test vectors** — must implement and pass before merging PR-3: -- Mnemonic: "birth kingdom trash renew flavor utility donkey gasp regular alert pave layer" -- Four vectors provided in DIP-14 Appendix A with full hex outputs - -**Big-endian requirement**: `ser_256(i)` must use big-endian byte order (most-significant byte -first), matching BIP32's `ser_32`. Verify this in `ckd_priv_256` before relying on the output. - -**Backward compatibility**: For indices < 2^32, `CKDpriv256` produces identical results to BIP32. - -#### 1.5.2 — DIP-15 Encryption (reuse `rs-platform-encryption`) - -```rust -// DO NOT re-implement — use existing rs-platform-encryption functions: -use platform_encryption::{ - derive_shared_key_ecdh, // ECDH: SHA256((y[31]&0x1|0x2)||x) - encrypt_extended_public_key, // AES-CBC-256, IV(16) + ciphertext(80) = 96 bytes - decrypt_extended_public_key, // Returns ExtendedPubKey from 96-byte blob - encrypt_account_label, // Optional account label encryption - compute_account_reference, // (version<<28) | (HMAC-SHA256_28bits XOR account_28bits) -}; -``` - -**Critical bug to fix**: The existing `add_incoming_contact_request` in `contact_requests.rs` -calls `ExtendedPubKey::decode(&encrypted_public_key)` on the raw encrypted bytes without first -decrypting them via AES-CBC-256. This must be fixed: decrypt first, then decode. - -The correct flow: -```rust -let shared_key = derive_shared_key_ecdh(&our_privkey, &sender_pubkey); -let xpub = decrypt_extended_public_key(&contact_request.encrypted_public_key, &shared_key)?; -// Now xpub is the 78-byte BIP32 xpub — use it to create DashpayExternalAccount -``` - -#### 1.5.3 — Send Contact Request - -Simplified 2-parameter API — all other parameters resolved internally by the wallet: - -```rust -pub async fn send_contact_request( - &self, - sender_identity_id: &Identifier, - recipient_identity_id: &Identifier, -) -> Result<(), PlatformWalletError> -``` - -Internally resolved: -- **identity_index**: looked up from `ManagedIdentity.identity_index` (u32, required) -- **sender_key_index**: first key with `Purpose::ENCRYPTION` on the sender identity -- **recipient_key_index**: first key with `Purpose::DECRYPTION` on the recipient identity (fetched from Platform) - - ECDH key type validation: both keys must be ECDH-compatible (secp256k1) -- **account_index**: defaults to `0` -- **ECDH**: always performed using `EcdhProvider::SdkSide` (wallet has seed, can derive private key) - -Steps: - -1. Retrieve sender identity and its HD index from `IdentityManager` -2. Fetch recipient identity from Platform -3. Find sender ENCRYPTION key (first match) — validate ECDH key type -4. Find recipient DECRYPTION key (first match) — validate ECDH key type -5. Derive DashPay receiving-account xpub -6. Derive ECDH private key from wallet using `m/9'/coin'/5'/0'/0'/identity_index'/key_id'` -7. Submit via `sdk.send_contact_request()` with `EcdhProvider::SdkSide` -8. Store in `ManagedIdentity.sent_contact_requests` - -**Note**: `contactRequest` documents are immutable — no retry/update API. If submission fails, it's a new request. - -**Note**: `ManagedIdentity.identity_index` is `u32` (required). Operations return `IdentityIndexNotSet` if missing. - -#### 1.5.3a — Accept Contact Request - -Simplified 1-parameter API: - -```rust -pub async fn accept_contact_request( - &self, - contact_request: &ContactRequest, -) -> Result<(), PlatformWalletError> -``` - -Internally: -1. Decrypt the incoming contact request (§1.5.4) -2. Create `DashpayReceivingFunds` account in `ManagedAccountCollection` -3. Store as `EstablishedContact` - -All key indices, ECDH derivation, and account index resolution happen internally. - -#### 1.5.4 — Decrypt Incoming Contact Request - -Fix the existing implementation: - -```rust -pub fn decrypt_incoming_contact_request( - &self, - our_identity_id: &Identifier, - contact_request: &ContactRequest, -) -> Result -``` - -Steps: - -1. Retrieve our DECRYPTION private key at `contact_request.recipient_key_index` -2. Retrieve sender's public key at `contact_request.sender_key_index` -3. Compute ECDH shared key: `derive_shared_key_ecdh(&our_privkey, &sender_pubkey)` -4. **Decrypt first**: `decrypt_extended_public_key(&contact_request.encrypted_public_key, &shared_key)?` -5. Store resulting xpub as `DashpayExternalAccount` in `ManagedAccountCollection` - -#### 1.5.5 — Payment Address Derivation - -```rust -pub fn derive_payment_address_for_contact( - &self, - our_identity_id: &Identifier, - contact_id: &Identifier, - payment_index: u32, -) -> Result -``` - -Non-hardened BIP32 child of the stored `DashpayExternalAccount` xpub at `payment_index`. -Payment gap limit: **10** (per DIP-15: "a gap limit of 10 at this stage"). -Document this as a deliberate choice (20 is more conservative but DIP-15 specifies 10). - -**PR-12 enhancements:** - -Contact payment address registration + gap limit management: -```rust -/// (PR-12) Register payment addresses for all established contacts. -/// Derives up to highest_receive_index + GAP_LIMIT addresses per contact. -/// Returns new addresses that should be added to SPV bloom filter. -pub async fn register_contact_payment_addresses( - &self, -) -> Result, PlatformWalletError> - -/// (PR-12) Process an incoming payment detected at a contact address. -/// Returns contact info if the address matches a known contact relationship. -pub fn match_payment_to_contact( - &self, - address: &Address, -) -> Option<(Identifier, Identifier, u32)> // (owner_id, contact_id, address_index) -``` - -Gap limit = 20 per contact for receiving. When payment arrives at index N, extend -registration to N + 20. `register_contact_payment_addresses()` is called during -`sync()` and after each incoming payment to maintain the gap window. - -Account reference calculation (PR-12): -```rust -/// (PR-12) Calculate account reference per DIP-15. -/// HMAC-SHA256(sender_secret, xpub_bytes) → take 28 MSBs → XOR with account bits. -pub fn calculate_account_reference( - sender_secret_key: &[u8; 32], - contact_xpub: &ExtendedPubKey, - account_index: u32, - version: u32, -) -> u32 -``` - -#### 1.5.6 — Send Payment to Contact - -```rust -pub async fn send_dashpay_payment( - &self, - our_identity_id: &Identifier, - contact_id: &Identifier, - amount_duffs: u64, - fee_per_byte: u32, -) -> Result -``` - -Gets next unused payment index → derives address → coin-selects UTXOs → -builds, signs, broadcasts Core transaction → increments stored payment index. - -#### 1.5.7 — DashPay Sync (`DashPayWallet::sync()`) - -`DashPayWallet::sync()` is the Platform-side half of DashPay sync. It fetches new contact -request documents from DAPI and establishes the corresponding address accounts: - -```rust -pub async fn sync(&self) -> Result -``` - -Uses `sdk.fetch_all_contact_requests_for_identity(identity, limit)` which returns -`(sent_requests, received_requests)` in one call. - -For each known identity, in order: - -1. Call `sdk.fetch_all_contact_requests_for_identity(&identity, None)` → `(sent, received)` -2. For each new incoming request: call `decrypt_incoming_contact_request()` to get the sender's xpub -3. Add a `DashpayReceivingFunds` account (`AccountType::DashpayReceivingFunds { index, user_identity_id, friend_identity_id }`) to `ManagedAccountCollection` — pre-derives gap_limit (20) addresses -4. For mutual contacts (both sent + received exist): ensure `DashpayReceivingFunds` account exists - -**How incoming payments are detected (no manual registration needed):** - -`CoreWallet::monitored_addresses()` returns addresses from ALL account types including -`dashpay_receival_accounts`. After `sync()` adds a new `DashpayReceivingFunds` account, the -next SPV compact filter pass automatically watches those addresses. No separate "register -dashpay addresses" task — the gap limit pool is maintained exactly like BIP44: - -- When SPV delivers a tx matching a DashPay receiving address at index N: - - `CoreWallet::process_transaction()` calls `wallet_info.process_transaction()` - - key-wallet records the tx and marks that address used - - If `N >= pool_size - gap_limit`, the pool is extended by deriving more addresses - - Next `monitored_addresses()` call includes the new addresses — SPV picks them up - -**Gap limits:** - -- Receiving address pool per contact: 20 (same as BIP44 core, matches DIP-15: "watch highest_receive_index + 20 addresses per contact") -- Payment gap limit (sending): 10 (DIP-15 spec) - -#### 1.5.8 — Profile Management - -```rust -pub async fn create_dashpay_profile( - &mut self, - identity_id: &Identifier, - display_name: Option, - bio: Option, - avatar_url: Option, -) -> Result - -pub async fn update_dashpay_profile( - &mut self, - identity_id: &Identifier, - display_name: Option, - bio: Option, - avatar_url: Option, -) -> Result<(), PlatformWalletError> -``` - -#### 1.5.9 — Contact Info Document (Encrypted Private Metadata) - -```rust -pub async fn update_contact_info( - &mut self, - identity_id: &Identifier, - contact_id: &Identifier, - nickname: Option, - accepted_account_reference: Option, -) -> Result<(), PlatformWalletError> -``` - -Submits DashPay `contactInfo` document — only visible to the identity owner. - -#### 1.5.10 — DPNS Name Registration - -DPNS usernames are the lookup mechanism for DashPay contact discovery. - -```rust -pub async fn register_dpns_name( - &mut self, - identity_id: &Identifier, - name: &str, -) -> Result // document id -``` - -#### 1.5.11 — Auto-Accept Proof - -Auto-accept key derivation path: `m/9'/coin'/16'/timestamp'` (hardened timestamp). -Note: feature code `16'` (not `15'`) — distinct from the DashPay receiving fund path. -Proof format: 1-byte key type + 4-byte key index + 1-byte signature size + 32–96 bytes signature. - -```rust -pub fn generate_auto_accept_proof( - &self, - sender_identity_id: &Identifier, - recipient_identity_id: &Identifier, -) -> Result, PlatformWalletError> - -pub fn verify_auto_accept_proof( - &self, - proof: &[u8], - sender_identity: &Identity, - recipient_identity: &Identity, -) -> bool -``` - -#### 1.5.12 — Reject Contact Request (PR-14) - -```rust -/// Reject an incoming contact request by hiding it via contactInfo document. -/// Contact requests are immutable — rejection is done by creating/updating -/// a contactInfo document with display_hidden=true. -pub async fn reject_contact_request( - &self, - identity_id: &Identifier, - contact_identity_id: &Identifier, -) -> Result<(), PlatformWalletError> -``` - -- Document type: `contactInfo` (DashPay contract) -- Sets `display_hidden: true`, other fields empty (nickname: None, note: None, accepted_accounts: []) - -#### 1.5.13 — QR Auto-Accept Proof (PR-14) - -```rust -/// Generate auto-accept proof for QR code sharing. -/// Derivation path: m/9'/coin'/16'/timestamp' -/// Signs: SHA256(sender_id || recipient_id || account_reference) -pub fn generate_auto_accept_proof( - &self, - sender_id: &Identifier, - recipient_id: &Identifier, - account_reference: u32, - timestamp: u32, -) -> Result, PlatformWalletError> - -/// Verify an auto-accept proof from a scanned QR code. -pub fn verify_auto_accept_proof( - proof_bytes: &[u8], - sender_id: &Identifier, - recipient_id: &Identifier, - account_reference: u32, -) -> Result -``` - -- Proof format: key_type(1B) + timestamp(4B BE) + sig_size(1B) + signature(64B) -- Message: SHA256(sender_id(32B) || recipient_id(32B) || account_ref(4B LE)) - -#### 1.5.14 — Pre-Send Validation (PR-14) - -```rust -/// Validate a contact request before sending. -/// Checks sender/recipient key types, purposes, security levels, -/// core height freshness, and account reference range. -pub fn validate_contact_request( - sender_identity: &Identity, - sender_key_index: u32, - recipient_identity: &Identity, - recipient_key_index: u32, - account_reference: u32, - core_height: u32, -) -> ContactRequestValidation - -pub struct ContactRequestValidation { - pub is_valid: bool, - pub errors: Vec, - pub warnings: Vec, -} -``` - -Validation rules: -- Sender key must be ECDSA_SECP256K1, Purpose::ENCRYPTION, not disabled -- Recipient key must exist and be compatible with ECDH -- Core height within +-200 blocks of current -- Account reference within reasonable range - -#### 1.5.15 — Account Label Encryption (PR-14) - -```rust -/// Encrypt an account label for inclusion in contact request. -/// Uses ECDH shared key, CBC-AES-256 with PKCS7 padding. -/// Format: IV(16B) + ciphertext(32-64B). Max label: 62 bytes. -pub fn encrypt_account_label(label: &str, shared_key: &[u8; 32]) -> Result, PlatformWalletError> -pub fn decrypt_account_label(encrypted: &[u8], shared_key: &[u8; 32]) -> Result -``` - -#### 1.5.16 — Payment Address Registration (PR-14) - -```rust -/// Register payment addresses for all established contacts. -/// Per contact: derives addresses up to highest_receive_index + GAP_LIMIT (20). -/// Returns new addresses for SPV bloom filter registration. -pub async fn register_contact_payment_addresses( - &self, -) -> Result - -/// Match an incoming payment to a contact relationship. -pub fn match_payment_to_contact( - &self, - address: &Address, -) -> Option - -pub struct ContactPaymentMatch { - pub owner_id: Identifier, - pub contact_id: Identifier, - pub address_index: u32, -} - -pub struct ContactAddressRegistration { - pub new_addresses: Vec
, - pub contacts_processed: usize, -} -``` - -- Gap limit: 20 per contact -- Derivation path: m/9'/coin'/15'/0'/(our_id)/(contact_id)/index -- Track per-contact: highest_receive_index, registered_count -- When payment at index N arrives, extend to N + 20 - -#### 1.5.17 — Sent Contact Requests Query (PR-14) - -```rust -/// Fetch sent (outgoing) contact requests from Platform. -pub async fn sent_contact_requests( - &self, - identity_id: &Identifier, -) -> Result, PlatformWalletError> -``` - -- Query: `$ownerId == identity_id`, order by `$createdAt` -- Currently only `sync_contact_requests()` fetches incoming; need both directions - -#### Files - -- `packages/rs-platform-wallet/src/wallet/dashpay/wallet.rs` — DashPayWallet struct + methods -- `packages/rs-platform-wallet/src/wallet/dashpay/dip14.rs` — DIP-14/15 crypto, ContactXpubData -- `packages/rs-platform-wallet/src/wallet/dashpay/auto_accept.rs` — QR auto-accept proof -- `packages/rs-platform-wallet/src/wallet/dashpay/validation.rs` — ContactRequestValidation -- `packages/rs-platform-wallet/src/wallet/dashpay/contact_request.rs` — contact request types -- `packages/rs-platform-wallet/src/wallet/dashpay/established_contact.rs` — established contact types -- `packages/rs-platform-wallet/src/wallet/dashpay/crypto.rs` — crypto helpers -- Reuses: `packages/rs-platform-encryption/` (DIP-15 crypto — do NOT duplicate) - ---- - -### 1.6 Platform Addresses (DIP-17) - -> Sync, send, transfer, and withdraw DIP-17 P2PKH credits through `PlatformWallet`. - -**Key finding**: `ManagedAccountCollection` already has `platform_payment_accounts: -BTreeMap`. `ManagedPlatformAccount` (key-wallet) tracks -per-address credit balances + gap-limit address pool. `PlatformWallet` must expose these -and implement the SDK's `AddressProvider` trait. - -Derivation path (DIP-17): `m/9'/coin_type'/17'/account'/key_class'/index` -- `key_class' = 0'` for receive keys; `key_class' = 1'` reserved -- `index` is non-hardened -- Gap limit: 20 (`DIP17_GAP_LIMIT` constant in key-wallet `gap_limit.rs` — confirmed, 20 is the DIP-17 RECOMMENDED value) - -#### 1.6.1 — AddressProvider Implementation - -The rs-sdk's `sync_address_balances()` requires `&mut impl AddressProvider`. - -**`PlatformPaymentAddressProvider`** implements the `AddressProvider` trait (confirmed from -`rs-sdk/src/platform/address_sync/provider.rs`): - -```rust -pub trait AddressProvider: Send { - fn gap_limit(&self) -> AddressIndex; - fn pending_addresses(&self) -> Vec<(AddressIndex, AddressKey)>; // AddressKey = [u8; 32] - fn on_address_found(&mut self, index: AddressIndex, key: &[u8], funds: AddressFunds); - fn on_address_absent(&mut self, index: AddressIndex, key: &[u8]); - fn has_pending(&self) -> bool; - fn highest_found_index(&self) -> Option; - fn current_balances(&self) -> Vec<(AddressIndex, AddressKey, AddressFunds)>; - fn last_sync_height(&self) -> u64; -} -``` - -**Note**: The trait uses a push-based callback API (`on_address_found`/`on_address_absent`), NOT -the `addresses()` / `apply_balance()` pattern described in earlier drafts. Implementors push -address indices into a `pending_addresses` set and handle SDK callbacks as balances arrive. - -`PlatformAddressWallet` implements `AddressProvider` using `platform_payment_accounts` for -state storage. The `AddressKey` ([u8; 32]) is the DIP-17 derived P2PKH address key. - -**Gap limit extension**: The gap limit extends for ANY found address (not just the highest index). -When `on_address_found` is called, the provider extends the pending set to maintain the gap limit -window beyond the newly found address. - -**Balance cache**: `PlatformAddressWallet` maintains `balances: Arc>>` -which is updated on each `on_address_found` callback. This cache is the source of truth for -`platform_credit_balance()` and `platform_address_info()` queries. - -Function: `sync_address_balances(sdk: &Sdk, provider: &mut P, config, last_sync_timestamp)` at `rs-sdk`. - -#### 1.6.2 — Platform Address Sync - -```rust -pub async fn sync_platform_address_balances( - &self, - last_sync_timestamp: Option, -) -> Result -``` - -Calls `sync_address_balances(&self.sdk, self, config, last_sync_timestamp)` where `self` -is the `AddressProvider` implementation. - -#### 1.6.3 — Balance Accessors - -```rust -pub fn platform_credit_balance(&self) -> u64 -// Sum of platform_payment_accounts.values().credit_balance - -pub fn platform_address_info(&self) -> BTreeMap -// (balance_credits, nonce) for each known funded address - -pub fn next_platform_receive_address( - &mut self, - account: u32, - key_class: u32, -) -> Result -``` - -#### 1.6.4 — Send Credits to Platform Address (Top Up Address) - -```rust -pub async fn top_up_platform_address( - &self, - identity_id: &Identifier, - target_address: &PlatformP2PKHAddress, - amount_credits: u64, -) -> Result<(), PlatformWalletError> -``` - -Calls `sdk::TopUpAddress` state transition, funded from the identity's balance. - -#### 1.6.5 — Transfer Between Platform Addresses - -```rust -pub async fn transfer_platform_address_funds( - &self, - from_addresses: BTreeMap, // address -> credits - to_address: &PlatformP2PKHAddress, - fee_strategy: AddressFundsFeeStrategy, -) -> Result<(), PlatformWalletError> -``` - -Calls `sdk::TransferAddressFunds`. Each `from_address` signed with its DIP-17 derived key. - -#### 1.6.6 — Withdraw Platform Address Credits to Core - -```rust -pub async fn withdraw_platform_address_funds( - &self, - from_addresses: BTreeMap, - to_core_address: Option
, // None = new wallet UTXO address - fee_strategy: AddressFundsFeeStrategy, - core_fee_per_byte: u32, -) -> Result<(), PlatformWalletError> -``` - -Calls `sdk::WithdrawAddressFunds::withdraw_address_funds()`. - -#### 1.6.7 — Platform Address Signer - -`Signer` is implemented **directly on `PlatformAddressWallet`** (not a separate -struct). This gives a simpler API where `platform_wallet.platform()` can be passed as signer. - -```rust -impl Signer for PlatformAddressWallet { - fn sign(&self, address: &PlatformAddress, data: &[u8]) -> Result> { - // Sequential lock acquisition: acquire wallet read lock, derive key, drop lock - // No dual-lock window — drops first lock before acquiring second - let key = self.wallet.blocking_read() - .derive_key_for_platform_address(address, self.network)?; - // Sign with ECDSA P2PKH - sign_ecdsa(key, data) - } -} -``` - -**Implementation notes**: -- `Signer::sign()` is sync, wallet is behind `tokio::sync::RwLock`. Uses `blocking_read()` with - sequential lock acquisition — drops `wallet` lock before acquiring any other lock (no deadlock window). -- Network is accessed via `sdk.network` (no cached field). -- 4 evo-tool callsites migrated: `transfer_platform_credits`, `withdraw_from_platform_address`, - `fund_platform_address_from_asset_lock`, `top_up_identity_from_platform_addresses`. - -#### 1.6.8 — Fund from Asset Lock - -```rust -pub async fn fund_from_asset_lock( - &self, - target_address: &PlatformP2PKHAddress, - amount_duffs: u64, -) -> Result<(), PlatformWalletError> -``` - -Builds an asset lock transaction targeting a platform address, broadcasts it, waits for proof, -then uses `TopUpAddress` SDK trait to credit the platform address. - -#### Files - -- `packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs` — PlatformAddressWallet -- `packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs` — PlatformPaymentAddressProvider - ---- - -### 1.7 Mempool Support - -> Transaction lifecycle tracking, SPV mempool processing, bloom filter management. - -**TransactionStatus** tracks the lifecycle of each Core transaction: - -```rust -pub enum TransactionStatus { - Unconfirmed, // broadcast but not yet confirmed - InstantSendLocked, // IS lock received from network - Confirmed { height: u32 }, // included in a block - ChainLocked { height: u32 }, // block is ChainLocked (final) -} -``` - -Lifecycle: `Unconfirmed → InstantSendLocked → Confirmed → ChainLocked`. -Tracked per transaction in CoreWallet. `PlatformWalletEvent::Wallet(WalletEvent)` emitted on transitions. - -**SpvWalletAdapter** implements the full `WalletInterface` (from `key_wallet_manager`): - -```rust -impl WalletInterface for SpvWalletAdapter { - fn process_block(&mut self, block: &Block, height: u32) -> BlockProcessingResult; - - fn process_mempool_transaction( - &mut self, - tx: &Transaction, - is_instant_send: bool, - ) -> MempoolTransactionResult; - - fn watched_outpoints(&self) -> Vec; - // Returns outpoints the bloom filter should watch — for mempool tx matching - - fn monitor_revision(&self) -> u64; - // Bloom filter staleness: when this changes, SPV reconstructs the bloom filter - // Incremented when addresses or watched outpoints change - - fn process_instant_send_lock(&mut self, islock: &InstantSendLock); - // Marks matching UTXOs as instant-send confirmed -} -``` - -**DashSpvClient** is parameterized with `EventHandler`: - -```rust -pub struct DashSpvClient { ... } - -// Constructor: DashSpvClient::new(config, network, storage, wallet, Arc::new(handler)) -``` - -**EventHandler** trait methods: `on_sync_event`, `on_network_event`, `on_progress`, -`on_wallet_event`, `on_error`. The platform-wallet impl forwards these to -`PlatformWalletEvent` variants. - -**SpvRuntime** SPV lifecycle (accessed via `PlatformWalletManager::spv()`): - -```rust -impl SpvRuntime { - pub async fn start(&self, config: ClientConfig) -> Result<()>; - // Creates DashSpvClient - - pub async fn stop(&self) -> Result<()>; - // Stops the client -} -``` - -**Bloom filter reconstruction**: Triggered when `monitor_revision()` changes. This happens -when new addresses are generated (gap limit extension, DashPay account creation) or when -watched outpoints change (new UTXOs received). - -#### Files - -- `packages/rs-platform-wallet/src/spv/wallet_adapter.rs` — SpvWalletAdapter (multi-wallet WalletInterface) -- `packages/rs-platform-wallet/src/spv/event_forwarder.rs` — SpvEventForwarder (EventHandler impl) -- `packages/rs-platform-wallet/src/spv/runtime.rs` — SpvRuntime (SPV lifecycle + finality) -- `packages/rs-platform-wallet/src/events.rs` — PlatformWalletEvent, SpvEvent, TransactionStatus - ---- - -### 1.8 Token Operations - -> `TokenWallet` sub-wallet with per-identity registry-based balance tracking. - -#### Status: Complete (PR-8) - -**Design**: Platform has no "list all tokens for an identity" query — -callers must specify which token IDs to track. `TokenWallet` uses a per-identity -registry: consumers call `watch(identity_id, token_id)` to register interest, -then `sync()` queries Platform for balances of all watched identity+token pairs. -This mirrors evo-tool's `identity_token_balances` DB table pattern. - -```rust -pub struct TokenWallet { - sdk: Sdk, - wallet: Arc>, - identity_manager: Arc>, - watched: Arc>>>, // identity → tokens - balances: Arc>>, // cache -} -``` - -**Registry** (per-identity): - -```rust -wallet.tokens().watch(identity_id, token_id).await; -wallet.tokens().unwatch(&identity_id, &token_id).await; -wallet.tokens().unwatch_identity(&identity_id).await; -wallet.tokens().watched_for(&identity_id).await; // → Vec -wallet.tokens().watched().await; // → Vec<(IdentityId, TokenId)> -``` - -**Sync** (queries Platform, updates cache): - -```rust -wallet.tokens().sync().await?; // fetches per identity × watched tokens -``` - -**Balance queries** (from cache): - -```rust -wallet.tokens().balance(&identity_id, &token_id).await; // → Option -wallet.tokens().balances_for_identity(&identity_id).await; // → Map -wallet.tokens().all_balances().await; // → Map<(IdentityId, TokenId), TokenAmount> -``` - -**User operations** (all take `Arc` + `TokenContractPosition` + identity): - -```rust -wallet.tokens().transfer(contract, pos, &from_id, to_id, amount).await?; -wallet.tokens().purchase(contract, pos, &id, amount, total_price).await?; -wallet.tokens().claim(contract, pos, &id, distribution_type).await?; -``` - -**Admin operations**: - -```rust -wallet.tokens().mint(contract, pos, &id, amount, recipient).await?; -wallet.tokens().burn(contract, pos, &id, amount).await?; -wallet.tokens().freeze(contract, pos, &id, target_id).await?; -wallet.tokens().unfreeze(contract, pos, &id, target_id).await?; -wallet.tokens().set_price(contract, pos, &id, price).await?; -``` - -All operations use SDK builders (`TokenTransferTransitionBuilder`, etc.) internally. -The `resolve_identity_and_signer()` helper resolves identity + HD index + signing key -from the identity manager for each operation. - -**Evo-tool integration** (future PR): Replace direct SDK calls in -`backend_task/tokens/*.rs` with `platform_wallet.tokens().*` calls. The -per-identity watch registry replaces evo-tool's `identity_token_balances` DB table. - -#### Files - -- `packages/rs-platform-wallet/src/wallet/tokens/mod.rs` -- `packages/rs-platform-wallet/src/wallet/tokens/wallet.rs` - ---- - -### 1.9 Shielded Pool - -> Feature-gated (`shielded`) ZK-private transactions using Orchard/Halo2. -> `ShieldedWallet` is generic over storage backend. - -#### Design - -ShieldedWallet is fundamentally different from other sub-wallets: -- Maintains **client-side state** (notes, nullifiers, commitment tree) that cannot be derived from Platform queries -- Requires a **storage backend** for persistence — abstracted via `ShieldedStore` trait -- Requires a **proving key** (~30s cold start, ~5MB memory) for ZK proof generation -- Uses **trial decryption** to discover incoming notes (scan all encrypted notes with viewing key) - -Generic over storage: `ShieldedWallet` — consumers provide in-memory (tests) or SQLite (production) storage. - -#### ShieldedStore trait - -```rust -/// Storage abstraction for shielded wallet state. -/// Consumers implement this for their persistence layer. -pub trait ShieldedStore: Send + Sync { - type Error: std::error::Error + Send + Sync + 'static; - - // --- Notes --- - fn save_note(&mut self, note: &ShieldedNote) -> Result<(), Self::Error>; - fn get_unspent_notes(&self) -> Result, Self::Error>; - fn get_all_notes(&self) -> Result, Self::Error>; - fn mark_spent(&mut self, nullifier: &[u8; 32]) -> Result; - - // --- Commitment tree --- - fn append_commitment(&mut self, cmx: &[u8; 32], retention: Retention) -> Result<(), Self::Error>; - fn checkpoint_tree(&mut self, checkpoint_id: u32) -> Result<(), Self::Error>; - fn witness(&self, position: u64) -> Result; - fn tree_anchor(&self) -> Result<[u8; 32], Self::Error>; - - // --- Sync state --- - fn last_synced_note_index(&self) -> Result; - fn set_last_synced_note_index(&mut self, index: u64) -> Result<(), Self::Error>; - fn nullifier_checkpoint(&self) -> Result, Self::Error>; - fn set_nullifier_checkpoint(&mut self, checkpoint: NullifierSyncCheckpoint) -> Result<(), Self::Error>; -} -``` - -Built-in implementations: -- `InMemoryShieldedStore` — for tests and short-lived wallets (Vec + BTreeMap + in-memory tree) -- No SQLite in the library — evo-tool implements `ShieldedStore` using its existing `database/shielded.rs` - -#### ShieldedNote - -```rust -pub struct ShieldedNote { - pub note: orchard::Note, // Orchard note (value, rseed, rho) - pub position: u64, // Global position in commitment tree - pub cmx: [u8; 32], // Note commitment - pub nullifier: [u8; 32], // For detecting when spent - pub block_height: u64, // Where it appeared - pub is_spent: bool, // Nullifier was seen in global set - pub value: u64, // Credits (convenience, same as note.value()) -} -``` - -#### OrchardKeySet - -```rust -/// ZIP-32 derived Orchard key hierarchy. -/// Derivation path: m/32'/coin_type'/account' (coin_type: 5=Mainnet, 1=Testnet) -pub struct OrchardKeySet { - pub spending_key: SpendingKey, - pub full_viewing_key: FullViewingKey, - pub spend_auth_key: SpendAuthorizingKey, - pub incoming_viewing_key: IncomingViewingKey, - pub outgoing_viewing_key: OutgoingViewingKey, - pub default_address: PaymentAddress, -} - -impl OrchardKeySet { - /// Derive from wallet seed bytes using ZIP-32. - pub fn from_seed(seed: &[u8], network: Network, account: u32) -> Result; - - /// Derive payment address at index. - pub fn address_at(&self, index: u32) -> PaymentAddress; - - /// Prepare incoming viewing key for efficient trial decryption. - pub fn prepared_ivk(&self) -> PreparedIncomingViewingKey; -} -``` - -#### ShieldedWallet - -```rust -pub struct ShieldedWallet { - sdk: Sdk, - keys: OrchardKeySet, - store: Arc>, - network: Network, -} -``` - -**Construction:** -```rust -impl ShieldedWallet { - pub fn new(sdk: Sdk, keys: OrchardKeySet, store: S, network: Network) -> Self; - - /// Derive keys from wallet seed and create shielded wallet. - pub fn from_seed(sdk: Sdk, seed: &[u8], network: Network, account: u32, store: S) -> Result; -} -``` - -**Sync operations:** -```rust -impl ShieldedWallet { - /// Sync notes from Platform — trial decrypts all new encrypted notes. - /// Appends all notes to commitment tree (for witness generation). - /// Stores decrypted notes that belong to us. - /// Returns count of new notes found. - pub async fn sync_notes(&self) -> Result; - - /// Check which owned notes have been spent (nullifier sync). - /// Privacy-preserving: uses trunk/branch tree scan. - /// Marks spent notes in store. - /// Returns count of newly spent notes. - pub async fn check_nullifiers(&self) -> Result; - - /// Full sync: notes + nullifiers + balance update. - pub async fn sync(&self) -> Result; -} - -pub struct SyncNotesResult { - pub new_notes: usize, - pub total_scanned: u64, -} - -pub struct ShieldedSyncSummary { - pub notes_result: SyncNotesResult, - pub newly_spent: usize, - pub balance: u64, -} -``` - -**Balance queries:** -```rust -impl ShieldedWallet { - /// Total unspent shielded balance. - pub async fn balance(&self) -> Result; - - /// Default payment address for receiving shielded funds. - pub fn default_address(&self) -> &PaymentAddress; - - /// Derive address at specific index. - pub fn address_at(&self, index: u32) -> PaymentAddress; -} -``` - -**Operations (5 transition types):** - -Each operation: -1. Selects spendable notes (if spending) -2. Generates Merkle witness paths from commitment tree -3. Builds Orchard bundle via DPP `build_*_transition()` builders -4. Broadcasts via SDK traits (`ShieldFunds`, `UnshieldFunds`, `TransferShielded`, `WithdrawShielded`, `ShieldFromAssetLock`) -5. Marks spent notes in store - -```rust -impl ShieldedWallet { - /// Shield: platform addresses -> shielded pool. - /// Uses Signer for input authorization. - pub async fn shield>( - &self, - inputs: BTreeMap, - amount: u64, - signer: &Signer, - ) -> Result<(), PlatformWalletError>; - - /// Shield from asset lock: Core L1 -> shielded pool. - pub async fn shield_from_asset_lock( - &self, - asset_lock_proof: AssetLockProof, - private_key: &[u8], - amount: u64, - ) -> Result<(), PlatformWalletError>; - - /// Unshield: shielded pool -> platform address. - pub async fn unshield( - &self, - to_address: &PlatformAddress, - amount: u64, - ) -> Result<(), PlatformWalletError>; - - /// Transfer: shielded pool -> shielded pool (private). - pub async fn transfer( - &self, - to_address: &PaymentAddress, - amount: u64, - ) -> Result<(), PlatformWalletError>; - - /// Withdraw: shielded pool -> Core L1 address. - pub async fn withdraw( - &self, - to_address: &Address, - amount: u64, - core_fee_per_byte: u32, - ) -> Result<(), PlatformWalletError>; -} -``` - -**Proving key management:** -```rust -/// Cached proving key — built once (~30s), reused for all proofs. -/// Use `warm_up()` at app startup to avoid blocking first operation. -pub struct CachedOrchardProver { - key: OnceLock, -} - -impl CachedOrchardProver { - pub fn new() -> Self; - pub fn warm_up(&self); // Build key in background - pub fn is_ready(&self) -> bool; -} - -impl OrchardProver for CachedOrchardProver { - fn proving_key(&self) -> &ProvingKey { self.key.get_or_init(ProvingKey::build) } -} -``` - -The `CachedOrchardProver` is held as a static or on `PlatformWalletManager`. All `ShieldedWallet` instances share it. - -**Note selection for spending:** -```rust -/// Select notes to cover the requested amount + fee. -/// Returns selected notes with Merkle witness paths from commitment tree. -fn select_spendable_notes( - store: &S, - amount: u64, - fee: u64, -) -> Result, PlatformWalletError>; -``` - -Greedy selection: sort unspent notes by value descending, accumulate until >= amount + fee. - -#### Integration with PlatformWallet - -`ShieldedWallet` is a **standalone component** — not a field on `PlatformWallet`. This avoids -infecting `PlatformWallet` with the `S: ShieldedStore` type parameter. Consumers create -`ShieldedWallet` separately, providing their own `ShieldedStore` implementation: - -```rust -// Consumer creates ShieldedWallet separately -let shielded = ShieldedWallet::from_seed( - sdk, &seed_bytes, network, 0, InMemoryShieldedStore::new() -)?; -shielded.sync().await?; -shielded.shield(inputs, amount, &platform_signer).await?; -``` - -`ShieldedWallet` shares the `Sdk` with `PlatformWallet` but manages its own state through -the `ShieldedStore` backend. - -#### Files - -- `packages/rs-platform-wallet/src/wallet/shielded/mod.rs` — ShieldedWallet, re-exports -- `packages/rs-platform-wallet/src/wallet/shielded/keys.rs` — OrchardKeySet, ZIP-32 derivation -- `packages/rs-platform-wallet/src/wallet/shielded/store.rs` — ShieldedStore trait, ShieldedNote, InMemoryShieldedStore -- `packages/rs-platform-wallet/src/wallet/shielded/sync.rs` — sync_notes, check_nullifiers, sync -- `packages/rs-platform-wallet/src/wallet/shielded/operations.rs` — shield, unshield, transfer, withdraw, shield_from_asset_lock -- `packages/rs-platform-wallet/src/wallet/shielded/prover.rs` — CachedOrchardProver -- `packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs` — select_spendable_notes - ---- - -### 1.10 State Transition Signing Facade - -> `PlatformWallet` provides `IdentitySigner` so callers never manage key material directly. - -```rust -// platform_wallet/signer.rs -pub struct IdentitySigner { - wallet: Arc>, - identity_index: u32, // required (u32, not Optional) -} - -impl Signer for IdentitySigner { - fn sign(&self, key: &IdentityPublicKey, data: &[u8]) -> Result> { - // Derive private key using 3-component DIP-9 path: - // m/9'/coin'/5'/0'/key_type'/identity_index'/key_index' - // where key_type: 0' = ECDSA, 1' = BLS - let secret = Zeroizing::new( - self.wallet.blocking_read() - .derive_identity_key(self.identity_index, key.id(), key.key_type())? - ); - match key.key_type() { - KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => sign_ecdsa(&secret, data), - KeyType::BLS12_381 => sign_bls(&secret, data), - KeyType::EDDSA_25519_HASH160 => sign_eddsa(&secret, data), - } - } -} -``` - -**Private key zeroization**: All derived key material uses `Zeroizing<[u8; 32]>`. Keys are -zeroed on drop — no plaintext key material persists in memory after signing. - -Factory on `IdentityWallet` — no external `wallet` param, borrows from `self.wallet`: - -```rust -pub fn signer_for_identity( - &self, - identity_id: &Identifier, -) -> Result -// Looks up identity_index from ManagedIdentity (u32, required) -``` - -**`PlatformAddressWallet` as `Signer`**: See §1.6.7. Uses sequential lock -acquisition with `blocking_read()` — no dual-lock window. - -**Important**: `WithdrawFromIdentity::withdraw` and `TransferToIdentity::transfer_credits` take -the signer **by value** (not by reference). Callers must construct a new `IdentitySigner` for -each call, or the signer must implement `Clone`. - -**Implementation notes**: -- Derives keys at `m/9'/coin_type'/5'/0'/key_type'/identity_index'/key_index'` (3-component DIP-9 path) -- Signs based on key type: ECDSA (`secp256k1`), BLS (`bls-signatures`), EdDSA (`ed25519-dalek`) -- Sync/async bridge: `blocking_read()` — safe because SDK calls `sign()` from blocking context -- Replaces evo-tool's `QualifiedIdentity::sign()` long-term - -#### Files - -- `packages/rs-platform-wallet/src/wallet/signer.rs` (extend existing stub) - ---- - -### 1.11 Serialization / Persistence - -> `PlatformWallet` is the single persistence unit — callers (e.g. evo-tool's SQLite) store -> the blob and don't need to know about sub-struct layout. - -```rust -// Top-level backup/restore — covers Wallet + ManagedWalletInfo + IdentityManager + DashPay state -pub fn backup(&self) -> Result, PlatformWalletError> -pub fn restore(data: &[u8]) -> Result -``` - -`Sdk` is excluded from the blob (it's a live connection) — caller re-provides it via -`PlatformWallet::from_bytes(sdk, blob)`. - -`ManagedWalletInfo` and `ManagedAccountCollection` already have `#[cfg(feature="bincode")]` -encode/decode. `ManagedPlatformAccount` and `PlatformP2PKHAddress` already have bincode. -Still missing serialization: - -- `IdentityManager` — add bincode `Encode`/`Decode` (with `Arc>` wrapping, serialize inner values) -- `ManagedIdentity` (Identity + BlockTime + contact maps) — add bincode -- `ContactRequest` — add bincode -- `EstablishedContact` — add bincode - -#### Files - -- `packages/rs-platform-wallet/src/wallet/identity/serialization.rs` (new) -- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/serialization.rs` (new) -- `packages/rs-platform-wallet/src/wallet/dashpay/contact_request.rs` (extend) -- `packages/rs-platform-wallet/src/wallet/dashpay/established_contact.rs` (extend) - ---- - -### 1.12 Sync Architecture - -There are **three distinct sync mechanisms** with different lifecycles: - -#### Core chain sync — push-based, long-running - -`dash-spv` runs as a permanent background task started once at app startup. It pushes -blocks and transactions to `CoreWallet` via `WalletInterface` callbacks — no polling needed: - -```rust -// App startup — spawned once, runs until cancellation -tokio::spawn(async move { - spv_client.run(cancellation_token).await -}); -// dash-spv calls SpvWalletAdapter::process_block() reactively as blocks arrive -``` - -#### Mempool reconciliation — push-based, event-driven - -SPV also delivers mempool transactions via `process_mempool_transaction(tx, is_instant_send)`. -The `TransactionStatus` lifecycle tracks each transaction: - -``` -Unconfirmed → InstantSendLocked → Confirmed { height } → ChainLocked { height } -``` - -- `process_mempool_transaction` is called when SPV receives an unconfirmed tx matching watched addresses -- `process_instant_send_lock` upgrades status from `Unconfirmed` to `InstantSendLocked` -- `process_block` upgrades to `Confirmed` when the tx appears in a block -- ChainLock events upgrade to `ChainLocked` - -`PlatformWalletEvent::Wallet(WalletEvent)` is emitted on each status transition. - -**Bloom filter staleness**: `monitor_revision()` is incremented when addresses or watched outpoints -change. SPV detects the change and reconstructs the bloom filter to include the new addresses. - -#### Platform sync — poll-based, periodic - -Platform state (identities, contacts, credit balances) is fetched via DAPI on a timer. -`PlatformWallet::sync()` is the single entry point: - -```rust -pub async fn sync(&self) -> Result -``` - -Sync order: - -1. `self.identity.sync()` — DIP-9 gap scan for new identities -2. `self.dashpay.sync()` — contact requests for all known identities -3. `self.platform.sync()` — DIP-17 address credit balances via DAPI -4. `self.shielded.sync()` (if feature enabled) — note sync + nullifier sync + tree updates - -**Shielded note sync** (feature-gated): Trial decryption of Orchard output notes using the -`FullViewingKey`. Discovered notes stored in `NoteStore`. Nullifier sync detects spent notes. -Commitment tree updated with each batch of processed notes. - -Designed to run on a timer in the app's background loop: - -```rust -tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(30)); - loop { - interval.tick().await; - if let Err(e) = wallet.sync().await { - tracing::warn!("Platform sync failed: {}", e); - } - } -}); -``` - -Sub-struct `sync()` methods remain individually callable for fine-grained control. -`PlatformWallet` is `Send + Sync` — safe to share across threads via `Arc`. - ---- - -## PR Sequence - -Each PR implements features in `rs-platform-wallet` **and** immediately integrates into `evo-tool`. -Old evo-tool code is deleted in the same PR that introduces the replacement. - ---- - -### PR-1: Project Scaffold + PlatformWallet + PlatformWalletManager + CoreWallet - -**Library** (`rs-platform-wallet`): - -- Clean up `lib.rs`: replace `pub mod platform_wallet_info` with `pub mod platform_wallet` -- `PlatformWallet` struct with stored sub-wallets sharing `Arc>` (§Struct Definitions) -- `PlatformWallet` creation methods mirroring `key-wallet`'s `Wallet` constructors + `sdk` param (§1.1) -- `CoreWallet` with `Arc>`, balance, UTXOs, address generation (§1.3) -- `PlatformWalletManager`: multi-wallet coordinator, `RwLock` for wallet add/remove -- `SpvWalletAdapter` implements `WalletInterface` using `key-wallet` types (`TransactionRouter`, `WalletTransactionChecker`) — no `WalletManager` dependency (§1.3.5, §1.7) -- `PlatformWalletEvent` unified enum: `Wallet(WalletEvent)`, `Spv(SpvEvent)` (two variants only) -- `monitored_addresses()` returns ALL account types including `dashpay_receival_accounts` -- `send_transaction`, `broadcast_transaction`, asset lock proof creation (§1.3.4–1.3.6) -- Asset lock timeout/fallback: 60s InstantLock wait, then ChainLock polling -- `IdentitySigner` stub (§1.10) — needed for identity registration in PR-2 -- `static_assertions::assert_impl_all!(PlatformWallet: Send, Sync)` -- `IdentityManager` refactor: add `last_scanned_index`, remove `sdk` field - -**evo-tool integration**: - -- Add `platform-wallet = { path = "../../platform/packages/rs-platform-wallet" }` to `Cargo.toml` -- Replace `AppContext.wallets` + `SpvManager` with `PlatformWalletManager` -- `wallet_lifecycle.rs`: construct via `PlatformWallet::from_mnemonic()` / `from_xprv()`, wire `sdk` from `AppContext.sdk` -- SPV: `SpvRuntime::start()` (via `PlatformWalletManager::spv()`) replaces manual `SpvManager` setup -- `PlatformWallet.clone()` replaces `WalletSeedHash` as wallet accessor (no WalletHandle) -- Delete `src/model/wallet/` (old custom wallet struct) - -**Database migration** (in this PR): - -- Add version byte to DB wallet record -- If old format: deserialize as old `Wallet`, convert to `PlatformWallet`, re-save -- On first run after migration: `IdentityManager` starts empty — identities re-discovered in PR-2 - -**Done when**: evo-tool builds with `PlatformWalletManager`; SPV sync works via `WalletInterface` impl; `send_transaction` works; PlatformWallet clone provides sync access to sub-wallets. - -**PR-1 status**: ✅ Complete. Scaffold in place, bridge working, 7 backend tasks validating via bridge. - ---- - -### PR-2: CoreWallet Deep Integration - -**Library** (`rs-platform-wallet`): - -- Per-address data methods on CoreWallet (§1.3.3): `all_address_info()`, `account_summaries()`, `utxos_by_address()`, `derivation_path_for_address()` -- `CoreAddressInfo` and `CoreAccountSummary` structs -- `Signer` on `PlatformAddressWallet` (§1.6) with `blocking_read()` bridge -- Asset lock proof creation on CoreWallet (§1.3.6): `create_asset_lock_proof()`, `create_topup_asset_lock_proof()` -- Asset lock recovery (§1.3.7): `recover_asset_locks()` -- Transaction sending: `send_transaction()` on CoreWallet (§1.3.4) -- `PlatformAddressWallet` uses `sdk.network` for network access - -**evo-tool integration**: - -- Migrate 4 signing callsites from old `Wallet` to `platform_wallet.platform()` as `Signer` -- Migrate `generate_receive_address` from diagnostic to primary path -- Add `WalletTask::LoadAddressTable` backend task using `CoreWallet::all_address_info()` -- Update address table UI to render from cached `CoreAddressInfo` snapshot -- Migrate `create_asset_lock` tasks to use `CoreWallet::create_asset_lock_proof()` - -**Done when**: All backend tasks that touch balance/UTXOs/addresses use CoreWallet; signing uses PlatformAddressWallet; asset lock creation works through platform-wallet. - ---- - -### PR-3: IdentityWallet - -**Library** (`rs-platform-wallet`): - -- `IdentityWallet` with `identity_manager`, sdk, wallet Arc (§1.4) -- `register_identity` (with corrected `m/9'/coin'/5'/0'/key_type'/identity_index'/key_index'` path), `sync()`, `refresh_identity` (§1.4.1–1.4.3) -- Identity discovery: gap limit 5, consider AUTH_KEY_LOOKUP_WINDOW = 12 for key index scanning -- `top_up_identity`, `withdraw_identity_credits`, `transfer_credits` (§1.4.4–1.4.6) -- `add_key_to_identity`, `disable_identity_key` (§1.4.7) -- `IdentitySigner` complete (§1.10) -- `IdentityManager` bincode serialization (§1.11 partial) -- DPNS name registration (§1.5.10, belongs to IdentityWallet for SDK access) - -**evo-tool integration**: - -| File | Action | -|------|--------| -| `backend_task/identity/discover_identities.rs` | → `wallet.identity.sync()` | -| `backend_task/identity/register_identity.rs` | → `wallet.identity.register_identity()` | -| `backend_task/identity/top_up_identity.rs` | → `wallet.identity.top_up_identity()` | -| `backend_task/identity/withdraw_from_identity.rs` | → `wallet.identity.withdraw_identity_credits()` | -| `backend_task/identity/transfer.rs` | → `wallet.identity.transfer_credits()` | -| `backend_task/identity/add_key_to_identity.rs` | → `wallet.identity.add_key_to_identity()` | - -All signing replaced with `wallet.identity.signer_for_identity(identity_id)`. - -**Done when**: Identity registration and discovery work in evo-tool via library; old identity task files deleted. - ---- - -### PR-4: DashPayWallet (DIP-14 + DIP-15 + Sync) - -**Library** (`rs-platform-wallet`): - -- DIP-14: `ckd_priv_256`, `ckd_pub_256`, `derive_dashpay_contact_xpub` in `dashpay/dip14.rs` (§1.5.1) - - Big-endian `ser_256(i)` — verify and test before relying on it -- DIP-15: Reuse `rs-platform-encryption` — do NOT duplicate functions (§1.5.2) -- Fix the AES decryption bug: `decrypt_extended_public_key` before `ExtendedPubKey::decode` -- Fix recipient key purpose: use `Purpose::DECRYPTION`, not `ENCRYPTION` -- `DashPayWallet` with `send_contact_request`, `decrypt_incoming_contact_request` (§1.5.3–1.5.4) -- `derive_payment_address_for_contact` (gap limit: 10), `send_dashpay_payment` (§1.5.5–1.5.6) -- `DashPayWallet::sync()` using `sdk.fetch_all_contact_requests_for_identity()` (§1.5.7) -- Profile, contact info, auto-accept proof (§1.5.8–1.5.11) -- `ManagedIdentity` contact maps + `ContactRequest` + `EstablishedContact` bincode (§1.11) - -Test against DIP-14 Appendix A test vectors before merging. -Note: `contactRequest` documents are immutable — do not expose update/delete operations. - -**evo-tool integration**: - -| File | Action | -|------|--------| -| `backend_task/dashpay/dip14_derivation.rs` | Delete (replaced by `platform_wallet/dashpay/dip14.rs`) | -| `backend_task/dashpay/hd_derivation.rs` | Delete | -| `backend_task/dashpay/encryption.rs` | Delete (was duplicating `rs-platform-encryption`) | -| `backend_task/dashpay/contact_requests.rs` | → `wallet.dashpay.send_contact_request()` | -| `backend_task/dashpay/contacts.rs` | → `wallet.dashpay.sync()` | -| `backend_task/dashpay/payments.rs` | → `wallet.dashpay.send_dashpay_payment()` | -| `backend_task/dashpay/incoming_payments.rs` | → `wallet.dashpay.sync()` handles this | -| `backend_task/dashpay/profile.rs` | → `wallet.dashpay.create_dashpay_profile()` | -| `backend_task/dashpay/auto_accept_proof.rs` | → `wallet.dashpay.generate_auto_accept_proof()` | -| `backend_task/dashpay/contact_info.rs` | → `wallet.dashpay.update_contact_info()` | - -**Done when**: DIP-14 vectors pass; contact requests sent/received and decrypted correctly (including AES decryption fix); incoming DashPay payments detected via SPV without manual address registration. - ---- - -### PR-5: PlatformAddressWallet (DIP-17) - -**Library** (`rs-platform-wallet`): - -- `PlatformAddressWallet` with actual `AddressProvider` impl — push-based callbacks (`pending_addresses`, `on_address_found`, `on_address_absent`) (§1.6.1) -- `sync_platform_address_balances`, balance accessors (§1.6.2–1.6.3) -- `top_up_platform_address`, `transfer_platform_address_funds`, `withdraw_platform_address_funds` (§1.6.4–1.6.6) -- `Signer` on `PlatformAddressWallet` directly (§1.6.7) - -**evo-tool integration**: - -- `backend_task/wallet/fetch_platform_address_balances.rs`: replace `WalletAddressProvider::new(&wallet, ...)` with `wallet.platform` as `AddressProvider` -- Replace `wallet.platform_address_info` field access with `wallet.platform.platform_address_info()` - -**Done when**: DIP-17 address balance sync works; top-up, transfer, and withdrawal work in evo-tool. - ---- - -### PR-7 Status: Complete - -### What was delivered - -- `IdentityWallet::update_identity(add_keys, disable_keys)` — `IdentityUpdateTransition` via DPP - (nonce lookup, master key signing, broadcast_and_wait) -- `IdentityWallet::top_up_from_addresses()` — `TopUpIdentityFromAddresses` SDK trait -- `IdentityWallet::transfer_credits_to_addresses()` — `TransferToAddresses` SDK trait -- `IdentityWallet::register_name()` — DPNS username registration via `Sdk::register_dpns_name` -- `IdentityWallet::resolve_name()` — DPNS resolution via `Sdk::resolve_dpns_name` -- `IdentityWallet::search_names()` — DPNS prefix search via `Sdk::search_dpns_names` -- `PlatformAddressWallet::fund_from_asset_lock()` — `TopUpAddress` SDK trait - -All identity fund flows now work: L1→identity, address→identity, identity→address. -Identity keys can be added/disabled. DPNS names can be registered, resolved, and searched. - ---- - -### PR-8 Status: Complete - -### What was delivered - -**TokenWallet** — per-identity registry-based token balance tracking and operations: - -- **Registry**: `watch(identity_id, token_id)` / `unwatch()` / `unwatch_identity()` / `watched_for()` / `watched()` - — per-identity token watch list (mirrors evo-tool's `identity_token_balances` DB pattern) -- **Sync**: `sync()` queries Platform via `FetchMany` for each - identity's watched tokens, updates local `BTreeMap<(IdentityId, TokenId), TokenAmount>` cache -- **Balance queries**: `balance()`, `balances_for_identity()`, `all_balances()` — read from cache -- **User operations**: `transfer()`, `purchase()`, `claim()` — SDK builders + broadcast -- **Admin operations**: `mint()`, `burn()`, `freeze()`, `unfreeze()`, `set_price()` — SDK builders + broadcast -- All operations take `Arc` + `TokenContractPosition` to identify the token - (wallet doesn't store contract metadata, only balances) -- Shared `resolve_identity_and_signer()` helper for all token operations - -**Evo-tool integration** (future PR): Replace direct SDK calls in `backend_task/tokens/*.rs` -with `platform_wallet.tokens().*`. The per-identity watch registry replaces the -`identity_token_balances` SQLite table. - ---- - -### PR-9: Evo-tool integration - -Replace ALL evo-tool backend tasks with platform-wallet calls across every domain: -tokens, identity, dashpay, core wallet, platform addresses. Evo-tool keeps its own -`SpvManager` — SPV migration is PR-11. - -**Migration by domain** (in `dash-evo-tool/src/backend_task/`): - -**Phase 1 — Tokens** (~17 tasks, all trivial SDK wrappers): -| Evo-tool task | Replaced by | -|---------------|-------------| -| `tokens/transfer_tokens.rs` | `wallet.tokens().transfer()` | -| `tokens/mint_tokens.rs` | `wallet.tokens().mint()` | -| `tokens/burn_tokens.rs` | `wallet.tokens().burn()` | -| `tokens/freeze_tokens.rs` | `wallet.tokens().freeze()` | -| `tokens/unfreeze_tokens.rs` | `wallet.tokens().unfreeze()` | -| `tokens/claim_tokens.rs` | `wallet.tokens().claim()` | -| `tokens/purchase_tokens.rs` | `wallet.tokens().purchase()` | -| `tokens/set_token_price.rs` | `wallet.tokens().set_price()` | -| `tokens/query_my_token_balances.rs` | `wallet.tokens().sync()` + `.balance()` | - -**Phase 2 — Simple identity + DPNS** (trivial wrappers): -| Evo-tool task | Replaced by | -|---------------|-------------| -| `identity/withdraw_from_identity.rs` | `wallet.identity().withdraw_credits()` | -| `identity/transfer.rs` | `wallet.identity().transfer_credits()` | -| `identity/refresh_identity.rs` | `wallet.identity().sync()` | -| `identity/add_key_to_identity.rs` | `wallet.identity().update_identity()` | -| `identity/register_dpns_name.rs` | `wallet.identity().register_name()` | -| `identity/load_identity_by_dpns_name.rs` | `wallet.identity().resolve_name()` | - -**Phase 3 — Identity registration + top-up + discovery** (asset lock handling): -| Evo-tool task | Replaced by | -|---------------|-------------| -| `identity/register_identity.rs` | `wallet.identity().register_identity()` (uses `wallet.core()` for asset locks) | -| `identity/top_up_identity.rs` | `wallet.identity().top_up_identity()` | -| `identity/discover_identities.rs` | `wallet.identity().sync()` | -| `identity/load_identity.rs` | Adapter: fetch via SDK + register in `identity_manager` | -| `identity/load_identity_from_wallet.rs` | Adapter: HD derivation + `wallet.identity().sync()` | - -**Phase 4 — DashPay contacts** (encryption via `rs-platform-encryption`): -| Evo-tool task | Replaced by | -|---------------|-------------| -| `dashpay/contact_requests.rs` (send) | `wallet.dashpay().send_contact_request()` | -| `dashpay/contact_requests.rs` (accept) | `wallet.dashpay().accept_contact_request()` | -| `dashpay/contact_requests.rs` (load) | `wallet.dashpay().sync_contact_requests()` | -| `dashpay/contacts.rs` | `wallet.dashpay().established_contacts()` | - -**Phase 5 — Core wallet + platform addresses**: -| Evo-tool task | Replaced by | -|---------------|-------------| -| `core/create_asset_lock.rs` | `wallet.core().build_registration_asset_lock_transaction()` + `.broadcast_transaction()` | -| `core/refresh_wallet_info.rs` | SPV feeds `ManagedWalletInfo` directly (no change needed) | -| Platform address transfer | `wallet.platform().transfer()` | -| Platform address withdraw | `wallet.platform().withdraw()` | -| Platform address fund | `wallet.platform().fund_from_asset_lock()` | -| Signing callsites (4+) | `wallet.platform()` as `Signer` | - -**Bridge architecture** (in `dash-evo-tool/src/context/`): -- `platform_wallet_bridge.rs` exists from PR-1 on `feat/platform-wallet` branch -- Extend bridge: `register_with_platform_wallet_manager()` for all wallet types -- Backend tasks call `require_platform_wallet()` → delegate to platform-wallet -- Evo-tool DB persistence remains — platform-wallet results are persisted by evo-tool after each operation - -**What stays in evo-tool**: -- `SpvManager` — keeps its own `DashSpvClient`, `ConnectionStatus`, debounced reconciliation (→ PR-11) -- Database layer — SQLite persistence for wallet state, identities, tokens, contacts -- UI screens — presentation unchanged, backend calls change -- `QualifiedIdentity` model — adapter maps to/from platform-wallet's `IdentityManager` - -**What gets deleted**: -- Direct SDK calls in backend tasks (replaced by `wallet.*()` calls) -- Duplicate crypto code (`dashpay/encryption.rs`, `dashpay/dip14_derivation.rs`) → use `rs-platform-encryption` -- Duplicate wallet model code in `src/model/wallet/` (partially — full deletion in PR-15) - -**Done when**: All backend tasks delegate to platform-wallet. No direct SDK identity/token/address/dashpay -calls remain in evo-tool backend tasks. SPV and database stay. - -**Done when**: All backend tasks delegate to platform-wallet. No direct SDK identity/token/address -calls remain in evo-tool (except SPV and database). Duplicate wallet code deleted. - ---- - -### PR-10: Enrich ManagedIdentity - -**Goal**: Make `ManagedIdentity` rich enough to replace evo-tool's `QualifiedIdentity` for -wallet-based identities. Any app using platform-wallet should get full identity management -without reimplementing key storage, status tracking, or discovery. - -**1. KeyStorage with lazy wallet derivation** - -Replace flat private key storage with a `PrivateKeyData` enum: - -```rust -pub enum PrivateKeyData { - /// Raw key bytes in memory. - Clear(Zeroizing<[u8; 32]>), - /// Derive on-demand from wallet at this path (key not held in memory). - AtWalletDerivationPath { - wallet_seed_hash: [u8; 32], - derivation_path: DerivationPath, - }, -} -``` - -`ManagedIdentity` gets a `KeyStorage` map: `BTreeMap`. - -When signing, if the key is `AtWalletDerivationPath`, the signer resolves it by finding the -wallet by seed hash, acquiring a read lock, and deriving at the path. This avoids storing -private keys in memory for wallet-backed identities. - -**2. IdentityStatus state machine** - -```rust -pub enum IdentityStatus { - Unknown, // Not yet checked against Platform - PendingCreation, // Registration submitted, awaiting confirmation - Active, // Confirmed on Platform - FailedCreation, // Registration failed (can retry) - NotFound, // Was active but no longer on Platform -} -``` - -Status transitions: `Unknown → PendingCreation → Active` (happy path), -`PendingCreation → FailedCreation → Active` (retry), `Active → NotFound → Active` (reappears). - -**3. DPNS name association** - -```rust -pub struct DpnsNameInfo { - pub label: String, // e.g., "alice" - pub acquired_at: Option, // timestamp -} -``` - -Add `dpns_names: Vec` to `ManagedIdentity`. Populated during `sync()` by querying -DPNS contract for documents with `records.identity == identity_id`. - -**4. Enhanced identity discovery** - -Current `sync()` only checks key_index 0 (primary auth key). Enhance to: -- Scan key indices 0..12 per identity index (12-key lookup window) -- Support ECDSA_HASH160 matching (not just full pubkey) -- Fetch DPNS names for discovered identities -- Store matched derivation paths in `KeyStorage` as `AtWalletDerivationPath` - -**5. Wallet association** - -Add `wallet_seed_hash: Option<[u8; 32]>` and `wallet_index: Option` to `ManagedIdentity`. -These link an identity back to the wallet it was registered from, enabling key re-derivation -on wallet recovery. - -**Files to modify:** -- `src/wallet/identity/managed_identity/mod.rs` — KeyStorage, IdentityStatus, DpnsNameInfo, wallet fields -- `src/wallet/identity/wallet.rs` — enhanced `sync()` with multi-key window + DPNS -- `src/wallet/signer.rs` — support `AtWalletDerivationPath` resolution - -**Done when**: `ManagedIdentity` has rich key storage, status tracking, DPNS names, and wallet -association. Discovery finds identities with any registered key, not just the primary. - ---- - -### PR-11: Asset lock lifecycle + multi-mode funding - -**Goal**: Handle the full asset lock lifecycle and support all identity funding modes. -Any app should be able to register/top-up identities without reimplementing IS→CL fallback -or UTXO management. - -**1. Asset lock tracking** - -```rust -pub struct TrackedAssetLock { - pub transaction: Transaction, - pub output_address: Address, - pub amount_duffs: u64, - pub proof: Option, // None until IS/CL arrives - pub identity_id: Option, // None until used for registration - pub status: AssetLockStatus, -} - -pub enum AssetLockStatus { - Broadcast, // TX sent, waiting for proof - InstantLocked, // IS proof received - ChainLocked, // CL proof received (higher finality) - UsedForRegistration, // Linked to an identity - UsedForTopUp, // Linked to an identity top-up -} -``` - -Add `tracked_asset_locks: Arc>>` to `CoreWallet`. -Methods: `unused_asset_locks()`, `track_asset_lock()`, `mark_used()`. - -**2. IS→CL fallback** - -When Platform rejects an InstantSend proof (`AssetLockInstantLockProofInvalid`): -1. Query DAPI for the TX to check `is_chain_locked` and `height` -2. If chain-locked and Platform has verified that height → retry with `ChainAssetLockProof` -3. If not chain-locked → return `AssetLockExpired` error - -This logic lives in a shared `resolve_asset_lock_proof()` method used by both -registration and top-up. - -**3. Multi-mode identity registration** - -```rust -pub enum IdentityFundingMethod { - /// Use a pre-existing asset lock proof. - UseAssetLock { - proof: AssetLockProof, - private_key: PrivateKey, - }, - /// Build asset lock from wallet UTXOs. - FundWithWallet { - amount_duffs: u64, - }, - /// Use a specific UTXO. - FundWithUtxo { - outpoint: OutPoint, - txout: TxOut, - address: Address, - }, - /// Fund from platform addresses (no asset lock needed). - FundFromAddresses { - inputs: BTreeMap, - }, -} -``` - -`IdentityWallet::register_identity()` updated to accept `IdentityFundingMethod`. -The `FundWithWallet` path builds the asset lock internally, broadcasts, waits for proof -(with IS→CL fallback). `FundFromAddresses` uses `put_with_address_funding()`. - -**4. Multi-mode identity top-up** - -Same pattern with `TopUpFundingMethod` (UseAssetLock, FundWithWallet, FundWithUtxo). -`FundFromAddresses` uses `top_up_from_addresses()` (already implemented in PR-7). - -**5. UTXO retry on exhaustion** - -When building an asset lock TX fails due to insufficient UTXOs: -1. Release wallet lock -2. Refresh UTXOs (if SPV running, trigger rescan; otherwise return error) -3. Retry once - -**Files to create/modify:** -- `src/wallet/core/asset_lock.rs` — new: TrackedAssetLock, AssetLockStatus, tracking methods -- `src/wallet/core/wallet.rs` — add tracked_asset_locks field, resolve_asset_lock_proof() -- `src/wallet/identity/wallet.rs` — multi-mode register_identity(), top_up_identity() -- `src/wallet/identity/funding.rs` — new: IdentityFundingMethod, TopUpFundingMethod enums -- `src/error.rs` — AssetLockExpired, AssetLockNotChainLocked error variants - -**Done when**: Identity registration/top-up works with all 4/3 funding modes. -IS→CL fallback is automatic. Asset locks are tracked from broadcast to use. - ---- - -### PR-12: DashPay completeness - -**Goal**: Move DashPay protocol-level crypto from evo-tool into platform-wallet. -DIP-14 256-bit derivation, contact payment addresses, and account reference calculation -are protocol specifications, not application logic. - -**1. DIP-14 256-bit key derivation** - -Move from evo-tool's `dip14_derivation.rs` into platform-wallet (or `rs-platform-encryption`): - -```rust -/// Child key derivation with 256-bit index (DIP-14). -/// For contact-based derivation paths where identity IDs (32 bytes) are used as indices. -pub fn ckd_priv_256( - parent: &ExtendedPrivKey, - index: &[u8; 32], - hardened: bool, -) -> Result - -pub fn ckd_pub_256( - parent: &ExtendedPubKey, - index: &[u8; 32], -) -> Result -``` - -**2. DashPay xpub derivation** - -```rust -/// Derive the contact-specific extended public key. -/// Path: m/9'/coin'/15'/account'/(sender_id)/(recipient_id) -/// Uses DIP-14 256-bit derivation for the identity ID segments. -pub fn derive_contact_xpub( - wallet: &Wallet, - network: Network, - account_index: u32, - sender_id: &Identifier, - recipient_id: &Identifier, -) -> Result<(ExtendedPubKey, [u8; 4], [u8; 32], [u8; 33]), Error> -// Returns: (xpub, parent_fingerprint, chain_code, compressed_pubkey) -``` - -**3. Account reference calculation (DIP-15)** - -```rust -/// Calculate account reference per DIP-15. -/// HMAC-SHA256(sender_secret, xpub_bytes) → take 28 MSBs → XOR with account bits. -pub fn calculate_account_reference( - sender_secret_key: &[u8; 32], - contact_xpub: &ExtendedPubKey, - account_index: u32, - version: u32, -) -> u32 -``` - -**4. Contact payment address derivation** - -```rust -/// Derive payment receiving address for a contact at a given index. -/// Standard BIP32 from contact xpub: contact_xpub / index -pub fn derive_contact_payment_address( - contact_xpub: &ExtendedPubKey, - index: u32, - network: Network, -) -> Address -``` - -**5. Contact payment address registration + gap limit** - -Add to `DashPayWallet`: - -```rust -/// Register payment addresses for all established contacts. -/// Derives up to highest_receive_index + GAP_LIMIT addresses per contact. -/// Returns new addresses that should be added to SPV bloom filter. -pub async fn register_contact_payment_addresses( - &self, -) -> Result, PlatformWalletError> - -/// Process an incoming payment detected at a contact address. -/// Returns contact info if the address matches a known contact relationship. -pub fn match_payment_to_contact( - &self, - address: &Address, -) -> Option<(Identifier, Identifier, u32)> // (owner_id, contact_id, address_index) -``` - -Gap limit = 20 per contact. When payment arrives at index N, extend registration to N + 20. - -**6. Account label encryption (optional)** - -Move from evo-tool to `DashPayWallet`: -```rust -pub fn encrypt_account_label(label: &str, shared_key: &[u8; 32]) -> Vec -pub fn decrypt_account_label(encrypted: &[u8], shared_key: &[u8; 32]) -> Result -``` - -**Files to create/modify:** -- `src/wallet/dashpay/dip14.rs` — new: ckd_priv_256, ckd_pub_256 -- `src/wallet/dashpay/contacts.rs` — new: derive_contact_xpub, account_reference, payment addresses -- `src/wallet/dashpay/wallet.rs` — add register_contact_payment_addresses(), match_payment_to_contact() -- `src/wallet/dashpay/payments.rs` — new: contact payment tracking, gap limit management - -**Done when**: All DashPay crypto operations (DIP-14 derivation, ECDH, xpub encryption, -account reference, payment address derivation) are in platform-wallet. An app can build -full DashPay contact + payment flows without reimplementing protocol-level crypto. - ---- - -### PR-13: Evo-tool integration Phase 3 - -### PR-13 Status: Complete - -**What was delivered:** - -Phase 3 identity migration (using enriched library from PR-10/11/12): -- `register_identity.rs` → `identity_wallet.register_identity_with_signer()` (with platform-wallet fallback) -- `top_up_identity.rs` → `identity_wallet.top_up_identity_with_signer()` (with platform-wallet fallback) -- `discover_identities.rs` → `identity_wallet.sync()` with QualifiedIdentity adapter (legacy fallback) - -Remaining token tasks (4): -- `destroy_frozen_funds.rs` → `token_wallet.destroy_frozen_funds_with_signer()` -- `pause_tokens.rs` → `token_wallet.pause_with_signer()` -- `resume_tokens.rs` → `token_wallet.resume_with_signer()` -- `update_token_config.rs` → `token_wallet.update_config_with_signer()` - -Platform-wallet additions: -- `register_identity_with_signer()` — register with external Identity + Signer -- `top_up_identity_with_signer()` — top up with external Identity + proof -- `identity_manager()` — read access for inspecting managed identities after sync -- 4 new TokenWallet methods + `_with_signer` variants (destroy, pause, resume, update_config) - -**Migration tally (all phases):** - -| Domain | Migrated | Total | Remaining | Details | -|--------|----------|-------|-----------|---------| -| **Tokens** | 13 | 13 | — | All complete | -| **Identity** | 11 | 13 | 2 | See details below | -| **DashPay** | 2 | 9 | 7 | See details below | -| **Core** | 1 | 7 | 6 | See details below | -| **Total** | **27** | **42** | **15** | | - -**Tokens — 13/13 migrated:** -- ✅ `transfer_tokens.rs` → `token_wallet.transfer_with_signer()` -- ✅ `mint_tokens.rs` → `token_wallet.mint_with_signer()` -- ✅ `burn_tokens.rs` → `token_wallet.burn_with_signer()` -- ✅ `freeze_tokens.rs` → `token_wallet.freeze_with_signer()` -- ✅ `unfreeze_tokens.rs` → `token_wallet.unfreeze_with_signer()` -- ✅ `claim_tokens.rs` → `token_wallet.claim_with_signer()` -- ✅ `purchase_tokens.rs` → `token_wallet.purchase_with_signer()` -- ✅ `set_token_price.rs` → `token_wallet.set_price_with_signer()` -- ✅ `destroy_frozen_funds.rs` → `token_wallet.destroy_frozen_funds_with_signer()` -- ✅ `pause_tokens.rs` → `token_wallet.pause_with_signer()` -- ✅ `resume_tokens.rs` → `token_wallet.resume_with_signer()` -- ✅ `update_token_config.rs` → `token_wallet.update_config_with_signer()` -- ✅ `query_my_token_balances.rs` → `token_wallet.watch()` + `.sync()` + `.balance()` - -**Identity — 11/13 migrated:** -- ✅ `withdraw_from_identity.rs` → `identity_wallet.withdraw_credits_with_signer()` -- ✅ `transfer.rs` → `identity_wallet.transfer_credits_with_signer()` -- ✅ `add_key_to_identity.rs` → `identity_wallet.update_identity_with_signer()` -- ✅ `register_dpns_name.rs` → `identity_wallet.register_name_with_signer()` -- ✅ `register_identity.rs` → `identity_wallet.register_identity_with_signer()` (with fallback) -- ✅ `top_up_identity.rs` → `identity_wallet.top_up_identity_with_signer()` (with fallback) -- ✅ `discover_identities.rs` → `identity_wallet.sync()` (with legacy fallback) -- ✅ `refresh_identity.rs` → `identity_wallet.refresh_identity_with_signer()` (with fallback) -- ✅ `load_identity_from_wallet.rs` → `identity_wallet.load_identity_by_index()` (with legacy fallback) -- ✅ `load_identity_by_dpns_name.rs` → `sdk.resolve_dpns_name()` + platform wallet watched identity -- ✅ `refresh_loaded_identities_dpns_names.rs` → `sdk.get_dpns_usernames_by_identity()` -- ❌ `load_identity.rs` — UI-driven manual import (user pastes ID, masternode types, manual key input). Genuinely app-level. -- ❌ Support files (`encryption.rs`, `dip14_derivation.rs`, `hd_derivation.rs`) — crypto utilities still used by non-migrated DashPay tasks - -**DashPay — 2/9 migrated:** -- ✅ `contact_requests.rs` (send) → `platform_wallet.dashpay().send_contact_request()` -- ✅ `contact_requests.rs` (accept) → `platform_wallet.dashpay().send_contact_request()` (reciprocal) -- ❌ `contact_requests.rs` (load) — UI expects raw `Vec<(Identifier, Document)>`, platform-wallet returns `Vec` (different shape) -- ❌ `contact_requests.rs` (reject) — platform-wallet only does local removal, evo-tool persists rejection to Platform via contactInfo document -- ❌ `contacts.rs` — UI-specific contact list management -- ❌ `incoming_payments.rs` — SPV payment address registration, gap limit tracking -- ❌ `auto_accept_handler.rs` — evo-tool orchestration of auto-accept batching -- ❌ Support files (`encryption.rs`, `dip14_derivation.rs`, `hd_derivation.rs`, `validation.rs`) — still used by non-migrated tasks - -**Core — 1/7 migrated:** -- ✅ `create_asset_lock.rs` — partial (uses `CoreWallet.build_asset_lock_transaction()` with fallback) -- ❌ `refresh_wallet_info.rs` — UTXO refresh from RPC/SPV, tightly coupled to evo-tool's SpvManager -- ❌ `refresh_single_key_wallet_info.rs` — single-key wallet refresh -- ❌ `send_single_key_wallet_payment.rs` — Core transaction from single-key wallet -- ❌ `recover_asset_locks.rs` — unused asset lock recovery from DB -- ❌ `start_dash_qt.rs` — subprocess launcher (not platform-related) -- ❌ `mod.rs` core task dispatch — orchestration logic - ---- - -### PR-14: Protocol completeness — DashPay + Identity - -**Goal**: Complete protocol-level support so any app can build full DashPay contact + -payment flows AND full identity management without reimplementing protocol logic. - -**DashPayWallet additions:** -- `reject_contact_request()` — contactInfo document with display_hidden=true -- `generate_auto_accept_proof()` / `verify_auto_accept_proof()` — DIP-15 QR auto-accept -- `validate_contact_request()` — pre-send key/height/reference validation -- `encrypt_account_label()` / `decrypt_account_label()` — CBC-AES-256 with ECDH key -- `register_contact_payment_addresses()` — bulk address derivation + gap limit tracking -- `match_payment_to_contact()` — address → (owner, contact, index) lookup -- `sent_contact_requests()` — query outgoing requests from Platform -- `send_contact_request_with_signer()` / `accept_contact_request_with_signer()` — external signer variants - -**IdentityWallet additions:** - -```rust -/// Load a single identity by wallet index (not gap scan — targeted lookup). -/// Derives auth key at identity_index, queries Platform, adds to manager. -pub async fn load_identity_by_index( - &self, - identity_index: u32, -) -> Result, PlatformWalletError> -``` - -```rust -/// Refresh a known identity's state from Platform (balance, keys, revision). -/// Unlike sync() which discovers new identities, this updates an existing one. -pub async fn refresh_identity( - &self, - identity_id: &Identifier, -) -> Result -``` - -```rust -/// Refresh DPNS names for all managed identities. -/// Queries Platform for current names, updates ManagedIdentity.dpns_names. -pub async fn refresh_dpns_names(&self) -> Result<(), PlatformWalletError> -``` - -```rust -/// Load an identity by DPNS name resolution + fetch. -/// Combines resolve_name() + fetch identity + add to manager. -pub async fn load_identity_by_dpns_name( - &self, - name: &str, -) -> Result, PlatformWalletError> -``` - -**Files to create/modify:** -- `src/wallet/dashpay/auto_accept.rs` — new: proof generation + verification -- `src/wallet/dashpay/validation.rs` — new: pre-send validation -- `src/wallet/dashpay/payments.rs` — new: payment address registration + matching -- `src/wallet/dashpay/wallet.rs` — reject, sent_requests, label encryption, _with_signer methods -- `src/wallet/identity/wallet.rs` — load_identity_by_index, refresh_identity, refresh_dpns_names, load_identity_by_dpns_name - -**Evo-tool migration** (same PR or follow-up): -- `load_identity_from_wallet.rs` → `wallet.identity().load_identity_by_index()` -- `refresh_identity.rs` → `wallet.identity().refresh_identity()` -- `refresh_loaded_identities_dpns_names.rs` → `wallet.identity().refresh_dpns_names()` -- `load_identity_by_dpns_name.rs` → `wallet.identity().load_identity_by_dpns_name()` -- DashPay tasks → `wallet.dashpay().*_with_signer()` methods - -**Done when**: Full DashPay + identity protocol coverage. Only `load_identity.rs` (manual import -with masternode types) remains evo-tool-specific. - ---- - -### PR-15: Shielded pool (feature-gated `shielded`) - -**Goal**: Implement `ShieldedWallet` — a standalone, storage-generic shielded -transaction component using Orchard/Halo2 ZK proofs. All code behind `#[cfg(feature = "shielded")]`. - -**Key design decision**: Storage is abstracted via the `ShieldedStore` trait. The library provides -`InMemoryShieldedStore` for tests; consumers (evo-tool) bring their own persistence (SQLite). -This keeps the library dependency-light and testable without database infrastructure. - -**Architectural note**: `ShieldedWallet` is **not** a field on `PlatformWallet`. It is a standalone -component that consumers create separately with their own `ShieldedStore` implementation. This -avoids infecting `PlatformWallet` with the `S: ShieldedStore` generic parameter. `ShieldedWallet` -shares the `Sdk` with `PlatformWallet` but manages its own state. - -**Library** (`rs-platform-wallet`): - -New `wallet/shielded/` module behind `#[cfg(feature = "shielded")]`: - -- `mod.rs` — `ShieldedWallet` struct (`Sdk`, `OrchardKeySet`, `Arc>`, `Network`), - constructors (`new`, `from_seed`), re-exports -- `keys.rs` — `OrchardKeySet` (ZIP-32 key hierarchy: `SpendingKey`, `FullViewingKey`, - `SpendAuthorizingKey`, `IncomingViewingKey`, `OutgoingViewingKey`, `PaymentAddress`), - derivation from seed, address generation, `PreparedIncomingViewingKey` for trial decryption -- `store.rs` — `ShieldedStore` trait (note CRUD, commitment tree ops, sync state checkpoints), - `ShieldedNote` struct, `InMemoryShieldedStore` (Vec + BTreeMap + in-memory tree) -- `sync.rs` — `sync_notes()` (trial decryption of encrypted notes, commitment tree append), - `check_nullifiers()` (privacy-preserving trunk/branch scan), `sync()` (full orchestration), - result types (`SyncNotesResult`, `ShieldedSyncSummary`) -- `operations.rs` — 5 transition types, each using DPP `build_*_transition()` builders and - broadcasting via SDK traits (`ShieldFunds`, `UnshieldFunds`, `TransferShielded`, - `WithdrawShielded`, `ShieldFromAssetLock`): - - `shield()` — platform addresses to shielded pool (needs `Signer`) - - `shield_from_asset_lock()` — Core L1 to shielded pool via asset lock proof - - `unshield()` — shielded pool to platform address - - `transfer()` — shielded pool to shielded pool (private, to `PaymentAddress`) - - `withdraw()` — shielded pool to Core L1 address -- `prover.rs` — `CachedOrchardProver` (`OnceLock`, `warm_up()` for background - init, implements `OrchardProver` trait), shared across all `ShieldedWallet` instances -- `note_selection.rs` — `select_spendable_notes()` (greedy: sort by value descending, - accumulate until >= amount + fee, returns notes with Merkle witness paths) - -**Files**: -- `packages/rs-platform-wallet/src/wallet/shielded/mod.rs` -- `packages/rs-platform-wallet/src/wallet/shielded/keys.rs` -- `packages/rs-platform-wallet/src/wallet/shielded/store.rs` -- `packages/rs-platform-wallet/src/wallet/shielded/sync.rs` -- `packages/rs-platform-wallet/src/wallet/shielded/operations.rs` -- `packages/rs-platform-wallet/src/wallet/shielded/prover.rs` -- `packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs` - -**Done when**: -- `ShieldedStore` trait compiles with `InMemoryShieldedStore` passing unit tests -- `OrchardKeySet::from_seed()` derives correct keys (verified against reference vectors) -- `sync_notes()` trial-decrypts test notes and populates store -- `check_nullifiers()` detects spent notes and marks them -- All 5 operations build valid Orchard bundles via DPP builders and broadcast via SDK traits -- `CachedOrchardProver` initializes and generates valid proofs -- Note selection covers amount + fee or returns insufficient-funds error -- Full round-trip test: shield, sync, check balance, transfer, unshield - ---- - -### PR-16: AssetLockFinalityEvent - -**Scope change**: Originally planned to replace evo-tool's SpvManager with -PlatformWalletManager. After research, SpvManager has ~1,500 lines of app-specific -orchestration (ConnectionStatus push updates, 300ms debounced reconciliation, wallet-to-DB -sync, peer count tracking, quorum lookups, RPC/SPV mode switching) that is NOT protocol-level. - -**Decision**: Keep evo-tool's SpvManager. It coexists with platform-wallet — both share -the same `ManagedWalletInfo` via `Arc>`. Only add the protocol-level finality -tracking to platform-wallet. - -**What to implement:** - -```rust -impl SpvRuntime { - /// Register a transaction to wait for finality (InstantLock or ChainLock). - /// Call BEFORE broadcasting the transaction. - pub async fn register_for_finality(&self, txid: Txid); - - /// Wait for a finality proof for a previously registered transaction. - /// Returns the proof once an InstantLock or ChainLock is received. - /// Timeout: configurable (default 5 minutes). - pub async fn wait_for_finality( - &self, - txid: Txid, - timeout: Duration, - ) -> Result; -} -``` - -Internal state: -- `finality_waiters: Mutex>>` on SpvRuntime -- `SpvEventForwarder` forwards `InstantLockReceived` / `ChainLockReceived` events -- Add a listener that updates `finality_waiters` when matching events arrive -- `wait_for_finality()` polls the map with sleep intervals (like evo-tool's pattern) - -Critical invariant: call `register_for_finality()` BEFORE broadcasting to prevent -race where proof arrives before registration. - -**Files to modify:** -- `src/spv/runtime.rs` — finality_waiters field + register/wait methods -- `src/spv/event_forwarder.rs` — forward finality events to waiter map -- `src/error.rs` — add FinalityTimeout variant - -**Done when**: `wait_for_finality(txid)` returns an AssetLockProof when IS/CL event -arrives via SPV. CoreWallet's register_identity/top_up can optionally use this instead -of DAPI polling. - ---- - -### PR-17: Comprehensive test suite - -**Infrastructure**: -- `tests/common/mod.rs` — shared helpers: `create_test_wallet()`, `create_funded_wallet()`, `inject_utxos()` -- `dash-sdk` with `mocks` feature in `[dev-dependencies]` -- Known test mnemonic (`"abandon abandon..."`) -- E2E feature flag `#[cfg(feature = "e2e-tests")]` - -**Unit tests** (~70 ported from evo-tool + new): -- Balance calculation (10 tests), UTXO selection (8), platform address info (4) -- Derivation paths (13), address derivation (6), seed lifecycle (2) -- Asset lock fee calc (9), wallet transactions (3) -- DIP-14 derivation (5), seed encryption (2) -- IdentityManager, ManagedIdentity, ContactRequest, EstablishedContact (existing 35 + new) - -**Integration tests** (mock SDK): -- Wallet construction (10+ tests), manager CRUD (10+) -- IdentitySigner signing (8+), PlatformAddressSigner (5+) -- CoreWallet async queries (12+), asset lock building (8+) -- Identity registration/sync/topup/withdraw flow (mocked Platform) -- DashPay contact request flow (mocked) -- Platform address sync/transfer/withdraw (mocked) -- Token watch/sync/transfer/mint/burn (mocked) - -**E2E tests** (live network, feature-gated): -- SPV sync + wallet balance (BackendTestContext pattern from evo-tool PR #778) -- Send/receive funds round-trip -- Identity registration + discovery -- Contact request send + accept between two wallets -- Platform address operations -- Token operations - ---- - -### PR-18: Replace evo-tool Wallet model with CoreWallet (COMPLETED) - -**Completed work:** - -Platform-wallet: -- `Arc` — cloned PlatformWallet handles share balance atomics -- `blocking_wallet_info()` — sync read access for egui UI code -- CoreWallet convenience wrappers removed (done in earlier PRs) - -Evo-tool: -- Embedded `Option` inside evo-tool `Wallet` struct — set on unlock, cleared on lock -- All UI balance reads migrated to lock-free `WalletBalance` via `wallet.platform_wallet` -- All UI UTXO/address reads migrated to `blocking_wallet_info()` + `CoreAddressInfo` -- Removed `platform_wallets` bridge map from AppContext — all lookups go through `wallet.platform_wallet` -- Removed 6 duplicate fields from Wallet: `confirmed_balance`, `unconfirmed_balance`, `total_balance`, `spv_balance_known`, `address_balances`, `address_total_received` -- Balance methods (`confirmed_balance_duffs()`, `total_balance_duffs()`, etc.) delegate to PlatformWallet -- New `address_balance()` method reads per-address balance from CoreAddressInfo -- `funding_common` reads UTXOs from PlatformWallet's `get_spendable_utxos()` - -Additional completed work (same PR): -- Migrated RPC send payment to `platform_wallet.core().send_transaction()` -- Migrated all asset lock building (create_asset_lock, register_identity, top_up_identity, fund_platform_address, shielded bundle) to `platform_wallet.core().build_asset_lock_transaction()` -- Removed all fallback paths (try PlatformWallet → fall back to old Wallet) -- Removed ~600 lines of dead asset lock building code from asset_lock_transaction.rs -- Removed build_standard_payment_transaction, build_multi_recipient_payment_transaction (~270 lines) -- Removed reload_utxos (~120 lines), utxos_by_address, max_balance -- Made broadcast_and_commit_asset_lock take Option (None for PlatformWallet paths) -- Removed 22 obsolete tests (UTXO selection, balance fallbacks, utxos_by_address) -- Total: ~1,625 lines removed - -**Remaining Wallet fields (PR-19 scope):** -- `utxos` — SPV reconciliation writes, _for_utxo asset lock paths, transaction_processing -- `known_addresses` — address derivation (receive_address, change_address), key lookup, bootstrap -- `watched_addresses` — address metadata, account summaries, UI display -- `transactions` — transaction history display - ---- - -### PR-19: Migrate remaining Wallet fields - -**Goal**: Remove `utxos`, `known_addresses`, `watched_addresses`, `transactions` from evo-tool's Wallet by migrating all remaining callers to PlatformWallet. - -**Completed:** -- `register_contact_account()` on DashPayWallet — creates DashpayReceivingFunds managed accounts in ManagedWalletInfo when contacts are established -- Called automatically from `send_contact_request()` -- key-wallet already has: `ManagedAccountCollection::insert()` for DashpayReceivingFunds, `ManagedCoreAccount::from_account()` for creating managed wrappers with address pools - -#### How DashPay interacts with the core wallet (DIP-14/15) - -When a contact is established (mutual contact requests on Platform): - -1. **Send request** (`DashPayWallet::send_contact_request()`): - - Derives DashPay receiving-account xpub: `m/9'/coin'/15'/0'/(sender_id)/(recipient_id)` using DIP-14 256-bit derivation - - Encrypts xpub with ECDH (recipient's decryption key) - - Submits contactRequest document to Platform - - **Now also**: creates `DashpayReceivingFunds` account in `ManagedWalletInfo` so SPV monitors incoming payment addresses - -2. **Accept request** (`DashPayWallet::accept_contact_request()`): - - Sends reciprocal request (calls `send_contact_request()`) - - Auto-establish logic in ManagedIdentity detects both requests → creates `EstablishedContact` - -3. **Address monitoring** (now automatic via ManagedWalletInfo): - - `ManagedCoreAccount` for `DashpayReceivingFunds` has address pools with gap limit - - SPV adapter iterates all accounts via `monitored_addresses()` → includes contact addresses - - When incoming payment arrives, `check_core_transaction()` matches against address pools - - Gap limit automatically derives more addresses as used addresses are consumed - -4. **Previously (evo-tool manual flow, being removed)**: - - `register_dashpay_addresses_for_identity()` manually derived addresses from seed - - Inserted into `known_addresses` and `watched_addresses` BTreeMaps - - Maintained `dashpay_contact_address_indices` DB table for gap limit tracking - - Required explicit `RegisterDashPayAddresses` backend task trigger - -#### Address types and their account mapping - -| Address type | DIP | Derivation path | key-wallet account | Status | -|---|---|---|---|---| -| BIP44 receive/change | BIP44 | `m/44'/coin'/acct'/0or1/i` | `standard_bip44_accounts` | In ManagedWalletInfo ✓ | -| Identity registration | DIP-9 | `m/9'/coin'/5'/1'/i` | `identity_registration` | In ManagedWalletInfo ✓ | -| Identity top-up | DIP-9 | `m/9'/coin'/5'/2'/i` | `identity_topup` | In ManagedWalletInfo ✓ | -| DashPay receive | DIP-15 | `m/9'/coin'/15'/0'/(self)/(friend)/i` | `dashpay_receival_accounts` | **Now registered** ✓ | -| DashPay send (watch) | DIP-15 | contact xpub + index | `dashpay_external_accounts` | TODO | -| Platform payment | DIP-17 | `m/9'/coin'/17'/acct'/class'/i` | `platform_payment_accounts` | In ManagedWalletInfo ✓ | -| CoinJoin | - | `m/9'/coin'/cointype'/i` | `coinjoin_accounts` | In ManagedWalletInfo ✓ | -| Provider keys | - | various | `provider_*_keys` | In ManagedWalletInfo ✓ | - -#### Remaining migration steps - -**All phases COMPLETE.** 10/10 duplicate fields removed from evo-tool's Wallet struct. - -Summary of completed work: -- [x] DashPay contact accounts registered in both key-wallet Wallet + ManagedWalletInfo -- [x] Address derivation delegated to PlatformWallet (blocking_next_receive/change_address) -- [x] Bootstrap skipped when PlatformWallet available (locked wallets show nothing — privacy) -- [x] All UI/backend reads migrated to CoreAddressInfo / WalletBalance / blocking_wallet_info -- [x] All asset lock building migrated to CoreWallet::build_asset_lock_transaction -- [x] RPC send payment migrated to CoreWallet::send_transaction -- [x] Removed fields: confirmed_balance, unconfirmed_balance, total_balance, spv_balance_known, address_balances, address_total_received, utxos, known_addresses, watched_addresses, transactions -- [x] Removed ~600 lines of asset lock building, ~400 lines of bootstrap, ~270 lines of tx building -- [x] Arc in PlatformWallet, Arc in manager and evo-tool Wallet -- [x] WalletBalance reverted from Arc to plain (shared via Arc) -- [x] Removed platform_wallets bridge map from AppContext - -**Remaining in Wallet struct** (app-level metadata, NOT duplicates): -- `platform_wallet: Option>` — canonical wallet -- `wallet_seed` — encrypted seed for persistence -- `uses_password`, `master_bip44_ecdsa_extended_public_key` — auth -- `unused_asset_locks` — asset lock tracking -- `alias`, `identities`, `is_main` — app metadata -- `platform_address_info` — platform credits (could migrate to PlatformAddressWallet) -- `core_wallet_name` — RPC config - -**Remaining code that still references old patterns** (functional, not dead): -- `_for_utxo` asset lock paths (register_identity, top_up_identity) — need CoreWallet API -- `remove_selected_utxos` in utxos.rs — DB persistence for _for_utxo paths -- `update_address_balance`/`update_address_total_received` — DB persistence -- `platform_addresses`/`platform_receive_address` — reads watched_addresses but from platform_address_info -- DB tables (wallet_addresses, utxos, wallet_transactions) — kept for future serialization PR - -**Total: ~2,700 lines removed from evo-tool.** - ---- - -### PR-20: Complete Identity/Asset Lock Lifecycle - -**Goal**: Platform-wallet provides one-call APIs for identity registration and -top-up. Apps never touch asset locks, finality tracking, or proof construction. - -#### Current problem - -Identity registration is split across repos: -1. **Evo-tool** builds asset lock, broadcasts, tracks finality via SPV, waits for proof -2. **Platform-wallet** has the identity state transition but expects pre-built proof -3. **Platform-wallet SPV runtime** has `register_for_finality()`/`wait_for_finality()` but they're NEVER CALLED -4. **Platform-wallet** has `broadcast_and_wait_for_asset_lock_proof()` that uses DAPI streaming instead of SPV - -This means: -- Every app must reimplement asset lock orchestration (200+ lines) -- SPV finality infrastructure exists but is unused -- DAPI streaming approach is fragile (5min hardcoded timeout) -- `TrackedAssetLock.status` never updates beyond `Broadcast` - -#### Layered design - -**CoreWallet** — owns asset lock TX lifecycle (Core chain concerns): -```rust -/// Asset lock status on the Core chain. -/// Tracked until used, then removed from tracked set. -pub enum AssetLockStatus { - Built, - Broadcast, - InstantSendLocked, - ChainLocked, -} - -/// A tracked asset lock — Core wallet knows about the TX, its status, -/// and how to re-derive the private key. Private keys stay in -/// key-wallet's Wallet, re-derived from funding_type + identity_index. -pub struct TrackedAssetLock { - pub txid: Txid, - pub funding_type: AssetLockFundingType, - pub identity_index: u32, - pub amount: u64, - pub status: AssetLockStatus, -} - -impl CoreWallet { - /// Build asset lock TX (existing). - pub async fn build_asset_lock_transaction(...) -> Result<...> - - /// Build + broadcast + wait for SPV proof. Returns when IS-lock or - /// ChainLock is received. Tracks lifecycle internally. - pub async fn create_funded_asset_lock_proof( - &self, - amount_duffs: u64, - funding_type: AssetLockFundingType, - identity_index: u32, - spv_runtime: Option<&SpvRuntime>, - ) -> Result<(AssetLockProof, PrivateKey, Txid), PlatformWalletError> - - /// List unused (funded but not consumed) asset locks. - pub fn unused_asset_locks(&self) -> Vec<&TrackedAssetLock> - - /// Scan Core chain for asset lock TXs not yet used. - pub async fn recover_unused_asset_locks(&self) -> Vec - - /// Remove a lock from tracking (called after successful use). - pub fn remove_asset_lock(&self, txid: &Txid) -} -``` - -**IdentityWallet** — orchestrates identity operations using CoreWallet: -```rust -/// How to fund an identity operation. -pub enum IdentityFunding { - /// Build asset lock from wallet UTXOs (most common). - FromWalletBalance { amount_duffs: u64 }, - /// Use credits from a Platform address (DIP-17). - FromPlatformAddress { address: PlatformAddress, amount_credits: Credits, nonce: u32 }, - /// Use an existing unused asset lock (recovery from previous attempt). - FromExistingAssetLock { txid: Txid }, - /// Use a specific UTXO (QR-funded flow). - FromUtxo { outpoint: OutPoint, tx_out: TxOut, address: Address }, -} - -impl IdentityWallet { - /// Register identity — complete flow, one call. - pub async fn register_identity( - &self, - funding: IdentityFunding, - keys: IdentityKeys, - identity_index: u32, - ) -> Result - - /// Top up identity — complete flow, one call. - pub async fn top_up_identity( - &self, - identity_id: Identifier, - funding: IdentityFunding, - identity_index: u32, - ) -> Result -} -``` - -Note: `FromExistingAssetLock` just takes `txid` — CoreWallet already -tracks the lock, has the proof, and can re-derive the private key. -No key material in IdentityFunding. - -#### Key design decisions - -**1. CoreWallet owns asset lock lifecycle**: Asset locks are Core chain -transactions used by multiple Platform features (identities, platform -addresses, shielded). CoreWallet tracks their status (Built → Broadcast -→ IS-locked → ChainLocked). When consumed by any Platform operation, -the lock is removed from tracking. - -**2. Private keys stay in key-wallet**: `TrackedAssetLock` stores -`funding_type` + `identity_index` — enough to re-derive the private key -from the wallet seed when needed. No key material stored in tracking state. - -**3. Transaction status from key-wallet**: Core TX confirmation status -(unconfirmed, IS-locked, confirmed, chainlocked) is already tracked by -key-wallet's `TransactionRecord.context`. `AssetLockStatus` mirrors this -for asset-lock-specific tracking until the lock is consumed. - -**4. Remove when used, not track usage**: Once an asset lock is consumed -(identity registered, address funded, etc.), CoreWallet removes it from -the tracked set. No `UsedForRegistration` state — that's the consumer's -concern, not the Core wallet's. - -**5. SPV finality (not DAPI streaming)**: Proof detection uses SPV's -`wait_for_finality()` which listens for InstantSend and ChainLock events -natively. No DAPI subscription streams. - -**6. Recovery**: `recover_unused_asset_locks()` scans for funded-but-unused -locks on Core chain and adds them to tracking with appropriate status. - -#### Implementation steps - -**Steps 1, 4, 5 — DONE:** -- ✅ `TrackedAssetLock` + `AssetLockStatus` types (no private keys, remove when consumed) -- ✅ `AssetLockManager` extracted, shared across sub-wallets via `Arc` -- ✅ `IdentityWallet` uses `self.asset_locks` directly (no CoreWallet parameter) -- ✅ `funded_register_identity` / `funded_top_up_identity` call `remove_asset_lock` after use -- ✅ Evo-tool callers updated to `platform_wallet.asset_locks()` - -**Step 2 — AssetLockManager subscribes to SPV events for finality:** -- Add `event_tx: broadcast::Sender` to `AssetLockManager` -- Pass it from `PlatformWallet::from_wallet_and_info()` (same channel SPV adapter uses) -- Replace `wait_for_proof_via_dapi()` with event-driven SPV waiting: - ```rust - async fn wait_for_proof(&self, txid: &Txid, timeout: Duration) -> Result { - let mut rx = self.event_tx.subscribe(); - let deadline = Instant::now() + timeout; - loop { - tokio::select! { - event = rx.recv() => { - match event { - Ok(PlatformWalletEvent::Spv(SpvEvent::Sync( - SyncEvent::InstantLockReceived { instant_lock, .. } - ))) if instant_lock.txid == *txid => { - // Build InstantAssetLockProof from instant_lock - return Ok(proof); - } - Ok(PlatformWalletEvent::Spv(SpvEvent::Sync( - SyncEvent::ChainLockReceived { .. } - ))) => { - // Check if our tx is in a chain-locked block - // Build ChainAssetLockProof - } - _ => continue, - } - } - _ = tokio::time::sleep_until(deadline) => { - return Err(PlatformWalletError::FinalityTimeout(*txid)); - } - } - } - } - ``` -- Update `create_funded_asset_lock_proof()` to use this instead of DAPI streaming -- Delete `wait_for_proof_via_dapi()` and `SpvRuntime::wait_for_finality()` (replaced) - -**Step 3 — Asset lock recovery in AssetLockManager:** -- `recover_unused_asset_locks()` scans Core chain for funded-but-unused locks -- Move logic from evo-tool's `recover_asset_locks.rs` -- Recovered locks enter tracking at InstantSendLocked or ChainLocked status - -**Step 6 — Simplify evo-tool:** -- Remove `transactions_waiting_for_finality` from AppContext -- Remove `spv_setup_finality_listener()` / `handle_spv_finality_event()` / `received_asset_lock_finality()` -- Remove `wait_for_asset_lock_proof()` polling -- Remove `broadcast_and_commit_asset_lock()` -- Remove `Wallet.unused_asset_locks` field (tracked by AssetLockManager) -- Remove `recover_asset_locks.rs` (moved to AssetLockManager) -- Simplify `create_asset_lock.rs` to call `asset_locks().create_funded_asset_lock_proof()` - ---- - -### PR-21: Remove Remaining Duplication - -**Goal**: Clean up remaining duplicated code identified in the duplication audit. - -- Replace CoreWallet's `send_transaction()` manual UTXO selection with key-wallet's `TransactionBuilder` -- Remove dead `derive_account_xpub()` (already simplified to use AccountType) -- Remove blocking address derivation path construction duplication -- Clean up any remaining evo-tool code that duplicates platform-wallet - ---- - -### PR-23: Merge Wallet + ManagedWalletInfo (dashcore) - -Merge `Wallet` and `ManagedWalletInfo` in `key-wallet` — both are mutable and always used -together. Single `Arc>` containing all state. - -**Why**: The original split assumed `Wallet` was immutable (key store) while `ManagedWalletInfo` -was mutable (UTXO state). In practice, `Wallet` is also mutable — accounts are added during -DashPay contact establishment and sync. Having them behind separate `RwLock`s creates: -1. Lock ordering risk (must always acquire wallet before wallet_info) -2. Read starvation during block processing (SPV holds write locks on both for entire block) -3. Non-atomic updates when operations touch both structs (crash = inconsistent state) - -**Investigation needed**: read starvation mitigation (per-tx lock release vs snapshot/MVCC vs -accept latency), atomic multi-struct update strategy (merge vs journaling vs eventual consistency). - ---- - -### PR-22: ChangeSet-based Persistence (inspired by BDK) - -**Goal**: Atomic state updates + persistence via a layered ChangeSet pattern. -Every mutation produces a delta that is applied atomically to in-memory state -and persisted atomically to storage. Two layers: key-wallet owns core wallet -deltas, platform-wallet composes them with platform-specific deltas. - -#### Architecture: Two-Layer ChangeSets - -``` -key-wallet (dashcore) platform-wallet -┌─────────────────────┐ ┌──────────────────────────────┐ -│ WalletChangeSet │ │ PlatformWalletChangeSet │ -│ ├─ utxos │ composed into │ ├─ wallet: WalletChangeSet │ -│ ├─ transactions │ ───────────────>│ ├─ identities │ -│ ├─ accounts │ │ ├─ contacts │ -│ └─ balance │ │ ├─ platform_addresses │ -└─────────────────────┘ │ ├─ shielded │ - │ └─ asset_locks │ - └──────────────────────────────┘ -``` - -**Flow for every operation:** -``` -1. Operation executes (e.g., process_block, send_contact_request) -2. key-wallet mutation returns WalletChangeSet (UTXO/tx/account deltas) -3. platform-wallet wraps it + adds platform deltas → PlatformWalletChangeSet -4. apply() to in-memory state (single write lock, all or nothing) -5. stage() into accumulated changeset -6. persist() to storage (single DB transaction, all or nothing) -``` - -**Key insight**: Each layer owns its own deltas. key-wallet knows exactly what -UTXOs/transactions/addresses changed — it produces `WalletChangeSet` natively. -Platform-wallet composes it with identity/contact/platform state and persists -the whole `PlatformWalletChangeSet` atomically. - -#### Layer 1: key-wallet `WalletChangeSet` (dashcore crate) - -Lives in `rust-dashcore/key-wallet/src/changeset/`. Captures ALL core -wallet mutations from a single operation: - -```rust -// key-wallet/src/changeset/changeset.rs - -/// Delta of core wallet state from a single operation. -pub struct WalletChangeSet { - /// Chain sync state (new block height + hash). - pub chain: Option, - /// UTXO changes (added from received outputs, spent from consumed inputs). - pub utxos: Option, - /// Transaction changes (new transactions, confirmation/IS-lock status updates). - pub transactions: Option, - /// Account changes (new accounts, address pool expansion, used address marking). - pub accounts: Option, - /// Aggregate balance change (recomputed from UTXO delta). - pub balance: Option, -} - -pub struct ChainChangeSet { - pub height: Option, - pub block_hash: Option, -} - -pub struct UtxoChangeSet { - /// UTXOs created by received transaction outputs. - pub added: BTreeMap, - /// UTXOs consumed by spent transaction inputs. - pub spent: BTreeSet, - /// UTXOs whose InstantSend lock status changed. - pub instant_locked: BTreeSet, -} - -pub struct TransactionChangeSet { - /// New or updated transaction records. - pub records: BTreeMap, -} - -pub struct AccountChangeSet { - /// New accounts added (DashPay contacts, new identity accounts). - pub new_accounts: Vec, - /// Address pool indices advanced (account key → new last_revealed index). - pub last_revealed: BTreeMap, - /// Addresses marked as used. - pub addresses_used: Vec<(AccountKey, Address)>, -} - -pub struct BalanceChangeSet { - pub spendable: i64, // delta, not absolute - pub unconfirmed: i64, - pub immature: i64, - pub locked: i64, -} -``` - -**Produced by**: `check_core_transaction()`, `record_transaction()`, -`confirm_transaction()`, `mark_utxos_instant_send()`, `maintain_gap_limit()`. -Each mutation method returns a `WalletChangeSet` instead of (or alongside) -mutating in place. - -#### Layer 2: platform-wallet `PlatformWalletChangeSet` - -Lives in `rs-platform-wallet/src/persistence/`. Composes key-wallet's -changeset with platform-specific deltas: - -```rust -// platform-wallet/src/persistence/changeset.rs - -/// Full delta of platform wallet state from a single operation. -pub struct PlatformWalletChangeSet { - /// Core wallet changes (UTXOs, transactions, accounts, balance). - /// Produced by key-wallet operations. - pub wallet: Option, - /// Identity changes (registered, updated, key changes, DPNS names). - pub identities: Option, - /// DashPay contact changes (requests sent/received, contacts established). - pub contacts: Option, - /// Platform address changes (DIP-17 balance/nonce from Platform proofs). - pub platform_addresses: Option, - /// Shielded state changes (commitment tree, nullifiers). - pub shielded: Option, - /// Asset lock lifecycle changes (created, broadcast, confirmed, used). - pub asset_locks: Option, -} -``` -``` - -#### The Merge Trait - -```rust -/// Combine two changesets. Used to batch multiple operations before persisting. -pub trait Merge: Default { - fn merge(&mut self, other: Self); - fn is_empty(&self) -> bool; -} -``` - -Merge semantics per sub-changeset: -- **UTXOs**: union of added, union of spent (idempotent — adding same UTXO twice is no-op) -- **Transactions**: insert or update (later status wins: chainlocked > confirmed > IS-locked > unconfirmed) -- **Identities**: monotonic revision (keep higher), append new keys -- **Contacts**: state machine ordering (pending < accepted < blocked) -- **Chain**: keep higher block height, insert new headers -- **Accounts**: append new addresses to pools, advance gap limit indices -- **Platform addresses**: keep higher nonce, update balance (last write wins from Platform proofs) - -#### The Persistence Trait - -```rust -/// Storage backend abstraction. Implementors choose their own storage -/// (SQLite, file, memory, remote). The trait guarantees atomic persistence. -pub trait WalletPersistence { - type Error: std::error::Error; - - /// Load the aggregated state from storage. - /// Returns a single ChangeSet representing the full stored state - /// (equivalent to merging all previously persisted changesets). - fn initialize(&mut self) -> Result; - - /// Persist a delta atomically. Either all sub-changesets are stored - /// or none are. Implementations MUST guarantee atomicity (e.g., - /// SQLite transaction, atomic file write). - fn persist(&mut self, changeset: &WalletChangeSet) -> Result<(), Self::Error>; -} -``` - -#### How Operations Produce ChangeSets - -Every mutation on PlatformWallet returns a `WalletChangeSet`: - -```rust -impl PlatformWallet { - /// Process a new block from SPV. - /// Computes changes (read-only), then applies atomically. - pub fn process_block(&self, block: &Block, height: u32) -> WalletChangeSet { - let mut changeset = WalletChangeSet::default(); - - // 1. Update chain state - changeset.chain = Some(ChainChangeSet { height, block_hash: block.header.hash() }); - - // 2. Check each transaction against all accounts - for tx in &block.txdata { - let tx_changes = self.check_transaction(tx, height); - changeset.merge(tx_changes); - } - - // 3. Return delta — caller applies + persists - changeset - } - - /// Send a contact request (DashPay). - /// Returns changes to identities + contacts + accounts. - pub async fn send_contact_request(&self, ...) -> Result { - let mut changeset = WalletChangeSet::default(); - - // 1. Create contact request document on Platform - let request = self.dashpay().submit_request(...).await?; - - // 2. Record sent request - changeset.contacts = Some(ContactChangeSet::request_sent(our_id, their_id, request)); - - // 3. Register DashPay receiving account - let account_changes = self.register_contact_account(our_id, their_id)?; - changeset.accounts = Some(account_changes); - - Ok(changeset) - } -} -``` - -#### The Staged ChangeSet Pattern - -PlatformWallet accumulates changesets in a `stage` field until the caller -explicitly persists: - -```rust -pub struct PlatformWallet { - // ... existing fields ... - - /// Accumulated changesets not yet persisted. - stage: RwLock, -} - -impl PlatformWallet { - /// Apply a changeset to in-memory state and stage for persistence. - pub fn apply_and_stage(&self, changeset: WalletChangeSet) { - // Apply to in-memory structs - self.apply(changeset.clone()); - // Merge into staged changes - self.stage.write().merge(changeset); - } - - /// Persist all staged changes and clear the stage. - pub fn persist(&self, persister: &mut impl WalletPersistence) -> Result<(), Error> { - let staged = self.stage.write().take(); - if let Some(changeset) = staged { - persister.persist(&changeset)?; - } - Ok(()) - } -} -``` - -#### In-Memory Atomicity - -Two approaches (choose one): - -**Option A — Single struct behind one RwLock (PR-21):** -Merge Wallet + ManagedWalletInfo + IdentityManager into one struct. The `apply()` -method takes `&mut self` — only one writer at a time, all changes atomic by Rust's -ownership rules. No lock ordering issues. - -**Option B — Compute-then-apply (current multi-lock architecture):** -The changeset is computed without holding any write locks (read-only analysis). -Then `apply()` acquires all write locks in a fixed order, applies all changes, -releases all locks. If any lock acquisition fails, no changes are applied. - -Option A is simpler and recommended. Option B works as a stepping stone. - -#### Storage Atomicity - -**SQLite implementation:** -```rust -impl WalletPersistence for SqlitePersister { - fn persist(&mut self, changeset: &WalletChangeSet) -> Result<(), Error> { - let tx = self.conn.transaction()?; // BEGIN TRANSACTION - - if let Some(chain) = &changeset.chain { - persist_chain(&tx, chain)?; - } - if let Some(utxos) = &changeset.utxos { - persist_utxos(&tx, utxos)?; - } - if let Some(txs) = &changeset.transactions { - persist_transactions(&tx, txs)?; - } - if let Some(ids) = &changeset.identities { - persist_identities(&tx, ids)?; - } - if let Some(contacts) = &changeset.contacts { - persist_contacts(&tx, contacts)?; - } - // ... all sub-changesets ... - - tx.commit()?; // COMMIT — all or nothing - Ok(()) - } -} -``` - -**File store implementation (for testing/dev):** -Append-only binary log. Each `persist()` appends one serialized changeset. -`initialize()` reads all entries, merges via `Merge` trait. Simple, no SQLite -dependency. - -#### Recovery - -If the app crashes: -- **After apply, before persist**: In-memory state is ahead of storage. On restart, - `initialize()` loads last persisted state. SPV re-syncs from the stored chain height, - re-producing the missing changesets. Platform state is re-fetched. -- **During persist (SQLite)**: Transaction rolls back. Storage is at the previous state. - Same recovery as above. -- **After persist**: Both in sync. No recovery needed. - -The gap between in-memory and storage is always bounded by the time since last `persist()`. -Calling `persist()` after every block or every user action keeps the gap small. - -#### Layered Responsibilities - -``` -key-wallet (dashcore): - - WalletChangeSet types + Merge trait - - compute_*() methods — read-only, return changeset - - apply() — mutate state from changeset - - NO persister, NO stage, NO persistence awareness - -platform-wallet: - - PlatformWalletChangeSet (wraps key-wallet + platform deltas) - - Optional persister field (configurable) - - Calls key-wallet compute_*() → gets WalletChangeSet - - Wraps in PlatformWalletChangeSet → queues on persister - - Persister owns the pending buffer + flush strategy - - apply() delegates to ManagedWalletInfo + IdentityManager -``` - -key-wallet stays pure — compute + apply. If used standalone -(without platform-wallet), no persistence overhead. - -#### Persister Architecture - -The persister lives on PlatformWallet. It owns the pending buffer -and decides when to flush. The wallet queues and forgets: - -```rust -pub struct PlatformWallet { - // ... wallet fields ... - persister: Option>>, -} - -impl PlatformWallet { - // Queue changeset — persister decides when to flush - fn queue_persist(&self, changeset: PlatformWalletChangeSet) { - if let Some(persister) = &self.persister { - persister.lock().queue(changeset); - } - // No persister = no-op, no accumulation, no memory growth - } - - fn set_persister(&mut self, persister: impl PlatformWalletPersistence) { - self.persister = Some(Arc::new(Mutex::new(persister))); - } -} -``` - -The persister owns flush strategy: -```rust -pub trait PlatformWalletPersistence { - type Error: std::error::Error; - - /// Queue a changeset. Persister merges into pending buffer. - /// May flush immediately or defer based on strategy. - fn queue(&mut self, changeset: PlatformWalletChangeSet); - - /// Force flush all pending changes to storage. - fn flush(&mut self) -> Result<(), Self::Error>; - - /// Load all persisted state as one changeset (for startup). - fn initialize(&mut self) -> Result; -} - -pub struct SqliteWalletPersister { - db: Arc, - seed_hash: [u8; 32], - network: String, - pending: PlatformWalletChangeSet, // accumulates here - strategy: FlushStrategy, -} - -pub enum FlushStrategy { - /// Flush after every queue() call - Immediate, - /// Flush every N queued changesets - EveryN(usize), - /// Never auto-flush — caller must call flush() explicitly - Manual, -} - -impl PlatformWalletPersistence for SqliteWalletPersister { - fn queue(&mut self, changeset: PlatformWalletChangeSet) { - self.pending.merge(changeset); - match self.strategy { - FlushStrategy::Immediate => { let _ = self.flush(); } - FlushStrategy::EveryN(n) => { self.count += 1; if self.count >= n { let _ = self.flush(); } } - FlushStrategy::Manual => {} // caller decides - } - } - - fn flush(&mut self) -> Result<(), Self::Error> { - if let Some(changeset) = self.pending.take() { - // Single SQLite transaction — all or nothing - let tx = self.conn.transaction()?; - self.persist_changeset(&tx, &changeset)?; - tx.commit()?; - } - Ok(()) - } -} -``` - -**No persister = no memory growth.** The `queue_persist()` call is a -no-op when persister is None. No stage field accumulating forever. - -#### Compute-Then-Apply Architecture - -Every mutation follows the same pattern: - -**Internal** (`compute_*`) — read-only, return changeset, don't mutate: -```rust -// key-wallet: pure computation -fn compute_record_transaction(&self, tx, context) -> WalletChangeSet { ... } -fn compute_mark_address_used(&self, address) -> AccountChangeSet { ... } -fn compute_maintain_gap_limit(&self, xpub) -> AccountChangeSet { ... } -fn compute_update_balance(&self) -> BalanceChangeSet { ... } -``` - -**Public** (existing names) — aggregate + apply, return result. -key-wallet returns changeset to caller for persistence: -```rust -// key-wallet public method -pub fn check_core_transaction(&mut self, tx, ctx) -> (TransactionCheckResult, WalletChangeSet) { - // 1. Compute all changes (read-only) - let changeset = self.compute_transaction_changeset(tx, ctx); - - // 2. Apply atomically (single &mut self) - self.apply(&changeset); - - // 3. Return changeset to caller (for persistence) - (result, changeset) -} - -// platform-wallet SPV adapter wraps + queues -let (result, kw_changeset) = wallet_info.check_core_transaction(tx, ctx); -let platform_cs = PlatformWalletChangeSet { wallet: Some(kw_changeset), .. }; -platform_wallet.queue_persist(platform_cs); // persister handles the rest -``` - -**`apply()` method** on ManagedWalletInfo: -```rust -impl ManagedWalletInfo { - pub fn apply(&mut self, changeset: &WalletChangeSet) { - if let Some(utxos) = &changeset.utxos { - for (outpoint, entry) in &utxos.added { self.add_utxo(outpoint, entry); } - for outpoint in &utxos.spent { self.remove_utxo(outpoint); } - } - if let Some(txs) = &changeset.transactions { - for (txid, record) in &txs.records { self.insert_transaction(txid, record); } - } - if let Some(accounts) = &changeset.accounts { - for (idx, revealed) in &accounts.last_revealed { self.advance_pool(idx, revealed); } - for (idx, addr) in &accounts.addresses_used { self.mark_used(idx, addr); } - } - if let Some(balance) = &changeset.balance { - self.apply_balance_delta(balance); - } - } -} -``` - -Same for PlatformWallet — delegates to sub-stores: -```rust -impl PlatformWallet { - pub fn apply(&self, changeset: &PlatformWalletChangeSet) { - if let Some(wallet_cs) = &changeset.wallet { - self.core().blocking_wallet_info_mut().apply(wallet_cs); - } - if let Some(contacts) = &changeset.contacts { /* IdentityManager */ } - if let Some(identities) = &changeset.identities { /* IdentityManager */ } - if let Some(platform_addrs) = &changeset.platform_addresses { /* metadata */ } - } -} -``` - -`initialize()` uses `apply()` — same code path as runtime: -```rust -let changeset = persister.initialize()?; -platform_wallet.apply(&changeset); -``` - -**Consistency guarantees:** -- Compute phase fails → no state change, consistent -- Apply panics → Rust poisons the lock, no partial state visible -- Between apply and queue_persist → in-memory ahead of storage, re-sync fixes -- No persister → no accumulation, no memory growth - -#### Implementation Steps (compute-then-apply refactor) - -**Step 9 — Add `apply()` to ManagedWalletInfo (dashcore):** - -Implement `apply(&mut self, changeset: &WalletChangeSet)` that applies -each sub-changeset to the wallet state. Used by both runtime mutations -and `initialize()` startup loading — same code path guarantees consistency. - -**Step 10 — Split mutation methods into compute + apply (dashcore):** - -For each mutation method in `ManagedCoreAccount` and `WalletTransactionChecker`, -extract read-only analysis into `compute_*` (returns changeset). The existing -public method becomes: compute + apply + return (result, changeset). - -Methods to split: -- `record_transaction` → `compute_record_transaction` + apply -- `confirm_transaction` → `compute_confirm_transaction` + apply -- `mark_utxos_instant_send` → `compute_instant_send_lock` + apply -- `mark_address_used` → `compute_mark_address_used` + apply -- `maintain_gap_limit` → `compute_gap_limit_expansion` + apply -- `update_balance` → `compute_balance_update` + apply - -`check_core_transaction` aggregates all compute_* results, applies once, -returns (result, changeset) to caller. - -**Step 11 — Persister on PlatformWallet (platform-wallet):** - -- Remove `stage: StdRwLock` field -- Add `persister: Option>>` -- Add `queue_persist()` method — no-op without persister -- Add `set_persister()` method -- Update `PlatformWalletPersistence` trait: `queue()` + `flush()` + `initialize()` -- Add `FlushStrategy` enum (Immediate, EveryN, Manual) -- Add `apply()` on PlatformWallet delegating to ManagedWalletInfo + IdentityManager - -**Step 12 — Update SqliteWalletPersister (evo-tool):** - -- Implement new `PlatformWalletPersistence` trait (queue + flush + initialize) -- Add `pending: PlatformWalletChangeSet` buffer -- Add `strategy: FlushStrategy` field -- Move existing `persist()` logic into `flush()` -- Wire: user actions use `FlushStrategy::Immediate`, - SPV sync uses `FlushStrategy::Manual` with periodic flush timer - -**Step 13 — Wire initialize() through apply() (evo-tool):** - -On startup: -```rust -let changeset = persister.initialize()?; -platform_wallet.apply(&changeset); -``` - -Replace scattered DB loading. Remove `persist_platform_wallet()` helper. -Persister is set on PlatformWallet at creation time. - -#### Migration Strategy - -The implementation touches 3 repos in order: - -1. **dashcore** (key-wallet): Steps 1-2 done, Steps 9-10 next. -2. **platform** (platform-wallet): Steps 3-5 done, Step 11 next. -3. **evo-tool**: Steps 6-8 done, Steps 12-13 next. - -Each step compiles independently. No intermediate fallback code. - -#### What Stays in evo-tool's DB (app-level, NOT wallet state) - -- Encrypted wallet seed (identity, not state) -- Wallet alias, is_main, uses_password (app preferences) -- DashPay contact UI metadata (display name, avatar, last seen) -- Settings, feature flags, proof logs -- Shielded commitment tree (via ShieldedStore trait — already persistent) - -#### What Moves to WalletPersistence - -- UTXOs, transactions, balances (currently in wallet_addresses, utxos, wallet_transactions tables) -- Identity state (registered, keys, DPNS names) -- Contact request state (sent, received, established) -- Platform address balances/nonces -- Asset lock lifecycle state -- Chain sync progress (height, block hashes) - -#### Atomicity Guarantees - -**In-memory**: Each mutation method in key-wallet mutates AND returns a delta. -The mutation is atomic (single `&mut self`). The delta is a faithful record. - -**Cross-struct**: Platform operations (contacts, identities) produce a -`PlatformWalletChangeSet` that bundles ALL related deltas — e.g., -`send_contact_request` produces `ContactChangeSet` + `AccountChangeSet` -(for the new DashPay account) in ONE changeset. Applied and persisted together. - -**Storage**: `PlatformWalletPersistence::persist()` wraps all sub-changeset -writes in a single DB transaction. All or nothing. - -**Recovery**: If crash after in-memory apply but before persist, restart -loads last persisted state via `initialize()`. SPV re-syncs from stored -chain height, reproducing the missing changesets. - -**Done when**: -- Every key-wallet mutation returns a `WalletChangeSet` -- Every platform-wallet operation returns a `PlatformWalletChangeSet` -- No direct DB writes outside the changeset path -- Recovery works correctly after crash at any point -- Audit confirms no atomicity gaps (all cross-struct changes bundled) -- SingleKeyWallet migrated to changeset path (currently uses direct DB writes — separate code path) - -#### Implementation Plan - -**Step 1 — key-wallet `WalletChangeSet` (dashcore repo):** - -Create `rust-dashcore/key-wallet/src/changeset/` module: - -``` -key-wallet/src/changeset/ -├── mod.rs -├── changeset.rs // WalletChangeSet + sub-changesets -├── merge.rs // Merge trait -└── traits.rs // WalletPersistence trait (generic) -``` - -Define `Merge` trait, `WalletChangeSet`, all sub-changesets, and -`WalletPersistence` trait. key-wallet types use dashcore primitives -(`OutPoint`, `Txid`, `Transaction`, `BlockHash`, `Address`). - -`check_core_transaction()` currently returns `TransactionCheckResult` -and mutates `ManagedWalletInfo` in place. Change it to ALSO return a -`WalletChangeSet` capturing what was mutated: -- `record_transaction()` → populate `transactions` + `utxos.added` -- `confirm_transaction()` → populate `transactions` status update -- `mark_utxos_instant_send()` → populate `utxos.instant_locked` -- `mark_address_used()` → populate `accounts.addresses_used` -- `maintain_gap_limit()` → populate `accounts.last_revealed` -- `update_balance()` → populate `balance` - -Each of these methods currently returns void. Change each to return -a sub-changeset that the caller merges into the operation's -`WalletChangeSet`. - -This is the **core refactor** — every mutation in key-wallet produces -a delta. The mutation still happens (in-memory state updated), but -the delta is also captured and returned to the caller. - -**Step 2 — Refactor key-wallet mutations to return changesets (dashcore repo):** - -For each mutation method in `ManagedCoreAccount` and `WalletTransactionChecker`: - -```rust -// Before (mutates in place, returns nothing): -pub fn record_transaction(&mut self, tx: &Transaction, ...) -> TransactionRecord { ... } - -// After (mutates in place AND returns delta): -pub fn record_transaction(&mut self, tx: &Transaction, ...) -> (TransactionRecord, WalletChangeSet) { ... } -``` - -Methods to change: -- `ManagedCoreAccount::record_transaction()` → return tx + UTXO deltas -- `ManagedCoreAccount::confirm_transaction()` → return status update delta -- `ManagedCoreAccount::mark_utxos_instant_send()` → return IS-lock delta -- `ManagedCoreAccount::mark_address_used()` → return address-used delta -- `AddressPool::maintain_gap_limit()` → return new-addresses delta -- `WalletTransactionChecker::check_core_transaction()` → aggregate all deltas from above -- `WalletTransactionChecker::update_balance()` → return balance delta - -The `TransactionCheckResult` gains a `changeset: WalletChangeSet` field -that aggregates all sub-deltas from the operation. - -**Step 3 — Rename platform-wallet changeset to PlatformWalletChangeSet:** - -- Rename existing `WalletChangeSet` → `PlatformWalletChangeSet` -- Add `wallet: Option` field -- Update `Merge` impl to merge the `wallet` sub-changeset -- Update `stage_changeset()` / `persist()` to use `PlatformWalletChangeSet` -- Update SPV adapter to wrap key-wallet's changeset into platform changeset - -**Step 4 — SPV adapter uses key-wallet changesets natively:** - -Currently the SPV adapter reconstructs changesets from `TransactionCheckResult`. -After Step 2, it just takes the `result.changeset` field and wraps it: - -```rust -let result = wi.check_core_transaction(tx, context, &mut w, true, true).await; -if result.state_modified { - let platform_changeset = PlatformWalletChangeSet { - wallet: Some(result.changeset), - ..Default::default() - }; - wallet.stage_changeset(platform_changeset); -} -``` - -No more manual TransactionEntry construction in the adapter. - -**Step 5 — Contact/identity operations produce complete changesets:** - -Each platform-wallet operation that mutates state returns a -`PlatformWalletChangeSet`: - -```rust -// send_contact_request returns the complete delta: -pub async fn send_contact_request(&self, ...) -> Result { - let mut changeset = PlatformWalletChangeSet::default(); - - // 1. Submit to Platform (external, no local state change) - let result = self.sdk.send_contact_request(input, ...).await?; - - // 2. Record sent request → ContactChangeSet - changeset.contacts = Some(ContactChangeSet { sent_requests: ... }); - - // 3. Register account → AccountChangeSet (via key-wallet WalletChangeSet) - let account_changeset = self.register_contact_account_changeset(...)?; - changeset.wallet = Some(account_changeset); - - // 4. Store in IdentityManager → IdentityChangeSet - changeset.identities = Some(IdentityChangeSet { ... }); - - Ok(changeset) -} -``` - -Caller calls `apply_and_stage(changeset)` then `persist()`. - -**Step 6 — Update SqlitePersister for PlatformWalletChangeSet:** - -The existing `SqliteWalletPersister` is updated to: -- Persist `changeset.wallet` (key-wallet deltas: UTXOs, transactions, accounts) -- Persist `changeset.identities` (identity state) -- Persist `changeset.contacts` (contact requests, established) -- Persist `changeset.platform_addresses` (DIP-17 balances) -- Persist `changeset.asset_locks` (asset lock lifecycle) -- All in one SQLite transaction - -**Step 7 — Remove old direct DB writes:** - -- Remove `update_address_balance()`, `update_address_total_received()` direct calls -- Remove `replace_wallet_transactions()` direct calls -- Remove `insert_utxo()` / `drop_utxo()` direct calls -- Remove `reconcile_spv_wallets()` balance/UTXO writes (replaced by changeset flow) -- All persistence goes through `persist()` → `SqliteWalletPersister` - -**Step 8 — Implement `initialize()` for startup:** - -`SqliteWalletPersister::initialize()` loads all persisted state from DB -tables and returns a single `PlatformWalletChangeSet` representing the -full stored state. Platform-wallet applies it to rebuild in-memory state. - -This replaces the current scattered DB loading in `get_wallets()`, -`load_wallet_transactions()`, etc. - -#### File Structure - -``` -rust-dashcore/key-wallet/src/ -├── persistence/ -│ ├── mod.rs -│ ├── changeset.rs // WalletChangeSet + UTXO/Tx/Account/Balance sub-changesets -│ ├── merge.rs // Merge trait + impls for BTreeMap, BTreeSet, Option, Vec -│ └── traits.rs // WalletPersistence trait (storage-agnostic) -└── ... - -packages/rs-platform-wallet/src/ -├── persistence/ -│ ├── mod.rs -│ ├── changeset.rs // PlatformWalletChangeSet (wraps key-wallet + platform deltas) -│ ├── merge.rs // Merge trait (re-export from key-wallet + platform impls) -│ └── traits.rs // PlatformWalletPersistence trait (extends key-wallet) -└── ... - -dash-evo-tool/src/ -├── persistence/ -│ ├── mod.rs -│ └── sqlite.rs // SqlitePersister implementing PlatformWalletPersistence -└── ... -``` - ---- - -## Address Type Coverage Summary - -| Address type | DIP | Derivation path | key-wallet collection field | Plan section | -|---|---|---|---|---| -| Core UTXO receive | BIP44 | `m/44'/coin'/acct'/0/i` | `standard_bip44_accounts` | §1.3.2 | -| Core UTXO change | BIP44 | `m/44'/coin'/acct'/1/i` | `standard_bip44_accounts` | §1.3.2 | -| Identity reg. funding | DIP-9 | `m/9'/coin'/5'/1'/i` (non-hardened i) | `identity_registration` | §1.4.1 | -| Identity top-up funding | DIP-9 | `m/9'/coin'/5'/2'/i` (non-hardened i) | `identity_topup_not_bound` | §1.4.4 | -| Identity auth keys | DIP-9 | `m/9'/coin'/5'/0'/key_type'/id'/key'` | — | §1.4.1 | -| Auto-accept proof key | DIP-15 | `m/9'/coin'/16'/timestamp'` | — | §1.5.11 | -| DashPay receive from contact | DIP-15 | `m/9'/coin'/15'/0'/(self)/(friend)/i` | `dashpay_receival_accounts` | §1.5.3 | -| DashPay send to contact | DIP-15 | contact xpub + index | `dashpay_external_accounts` | §1.5.4 | -| Platform P2PKH (credits) | DIP-17 | `m/9'/coin'/17'/acct'/class'/i` | `platform_payment_accounts` | §1.6 | - ---- - -## Risk Analysis - -| Risk | Mitigation | -|---|---| -| `IdentityManager` fields not yet `Arc>`-wrapped | Refactor in PR-1; add `last_scanned_index` field; confirm tests pass | -| `AddressProvider` API mismatch — actual trait uses push-based callbacks, not `apply_balance()` | Use confirmed trait definition from `rs-sdk/src/platform/address_sync/provider.rs`; implement `pending_addresses`/`on_address_found`/`on_address_absent` | -| AES decryption bug in `add_incoming_contact_request` | Fix in PR-3 — `decrypt_extended_public_key` before `ExtendedPubKey::decode`; add unit test proving plaintext roundtrip | -| DIP-9 auth key path missing `key_type'` segment | Fix in PR-2 — use full path `m/9'/coin'/5'/0'/key_type'/identity_index'/key_index'`; note: existing deployed wallets may have used the old path (key_type' omitted = effectively key_type'=0') — document deviation | -| DIP-14 `ser_256(i)` endianness | Add unit test against DIP-14 Appendix A vectors before any contact request is submitted | -| BLS key derivation semantics | Use raw 32-byte seed from BIP32 derivation as BLS secret key (not scalar addition mod bls12381 group order) — matches DashSync iOS | -| DB migration corrupts existing wallets | Version byte in DB; fallback read → convert; test against real DB fixture | -| Asset lock proof: InstantLock timeout | Implement 60s timeout before falling back to ChainLock polling — confirm ChainLocked height is known to Platform before using Chain proof | -| `PlatformWallet` not `Send+Sync` | Add `static_assertions::assert_impl_all!(PlatformWallet: Send, Sync)` | -| `Arc>` write starvation under concurrent SPV + Platform sync | SPV writes are short (tx update); Platform sync holds read lock briefly for balance reads — test under load | -| **Wallet + ManagedWalletInfo separation** — both are mutable (Wallet: accounts added during contact establishment; MWI: UTXOs/balances during sync). Original design assumed Wallet was immutable but it isn't. Two separate `RwLock`s create lock ordering risk and prevent atomic state updates. | Investigate merging in PR-6. Consider single struct behind one `RwLock`. | -| **Read starvation during block processing** — SPV `process_block()` holds write lock on both Wallet and ManagedWalletInfo for the entire block. During this time, CoreWallet read methods (`balance()`, `utxos()`, `all_address_info()`) are blocked. UI shows stale data until the block is fully processed. | Consider: (a) process transactions individually (release lock between txs), (b) use snapshot/MVCC pattern (clone state, process, swap), (c) accept the latency for now (blocks process in ms). | -| **Non-atomic state updates across structs** — Wallet, ManagedWalletInfo, and IdentityManager are separate structs behind separate locks. Operations that touch multiple (e.g., adding a DashPay account to Wallet + updating MWI addresses + updating IdentityManager contacts) cannot be atomic. A crash mid-operation leaves inconsistent state. | Investigate: (a) merge structs (PR-6), (b) WAL/journaling for multi-struct updates, (c) accept eventual consistency with recovery on restart. | -| `contactRequest` documents are immutable | Do not expose update/delete API; note in `send_contact_request` docs that retries create new documents | -| **`blocking_read()` deadlock risk in Signer::sign()** | DPP's `Signer` trait has sync `sign()` method but we use `tokio::sync::RwLock`. `blocking_read()` will deadlock if wallet write lock is held by same task. Document constraint: never call `sign()` while holding wallet write lock. Consider `std::sync::RwLock` for wallet in future. | -| **Signer code duplication** (IdentitySigner vs ManagedIdentitySigner) | Both have identical `sign()`/`sign_create_witness()`/`can_sign_with()` bodies. Extract shared `sign_with_key_bytes()` helper. Low priority — no correctness impact. | -| **ShieldedWallet spending ops incomplete** | `unshield()`, `transfer()`, `withdraw()` return runtime error — MerklePath witness deserialization not implemented. Output-only ops (`shield`, `shield_from_asset_lock`) work. Fix when integrating with evo-tool's SQLite `ClientPersistentCommitmentTree`. | -| **`rs-platform-wallet-ffi` broken type paths** | FFI crate references old type paths (`platform_wallet_info`, `identity_manager`, `managed_identity`) that were refactored. Fix in PR-19 by updating FFI imports to match new module structure. | -| **Auto-accept `account_reference` behavior change** | Platform-wallet uses `account_index` (0) as `account_reference`, not DIP-15 calculated value. Documented in evo-tool code. QR codes are session-scoped so old codes expire anyway. | - ---- - -## Sources & References - -### DIPs - -- [DIP-0013: Identities in HD Wallets](https://github.com/dashpay/dips/blob/master/dip-0013.md) — auth, registration, top-up funding paths -- [DIP-0014: Extended Key Derivation (256-bit)](https://github.com/dashpay/dips/blob/master/dip-0014.md) — CKDpriv256/CKDpub256 spec and test vectors -- [DIP-0015: DashPay](https://github.com/dashpay/dips/blob/master/dip-0015.md) — contact request structure, ECDH, AES-CBC encryption, account reference, DashPay payment paths -- [DIP-0017: Dash Platform P2PKH Addresses](https://github.com/dashpay/dips/blob/master/dip-0017.md) — platform payment addresses at `m/9'/coin'/17'/account'/key_class'/index` - ---- - -## TODO - -- [x] **`manager` feature gates `PlatformWalletManager`** — DONE: manager module gated at lib.rs level. -- [ ] **Revisit events** — Remove fallback `WalletEvent` enum (only exists for `not(manager)` — is there a real use case without manager?). Remove duplicate `TransactionStatusChanged` from `PlatformWalletEvent` (already in `WalletEvent`). Review whether `TransactionStatus` enum is still needed or should use `TransactionContext` from dashcore. -- [ ] **Fix `rs-platform-wallet-ffi` broken type paths** — FFI crate references old module paths (`platform_wallet_info`, `identity_manager`, `managed_identity`) that were refactored. Update imports to match new module structure. -- [ ] **Signer code duplication** — `IdentitySigner` and `ManagedIdentitySigner` have identical `sign()`/`sign_create_witness()`/`can_sign_with()` bodies. Extract shared `sign_with_key_bytes()` helper. -- [ ] **ShieldedWallet spending ops** — `unshield()`, `transfer()`, `withdraw()` return runtime error. Need `MerklePath` witness resolution from `ShieldedStore`. Fix when integrating with evo-tool's SQLite `ClientPersistentCommitmentTree`. -- [ ] **Finality proof data** — `wait_for_finality()` returns `AssetLockProof::default()`. SPV `SyncEvent::InstantLockReceived` carries the actual `InstantLock` — use it to build proper proof. -- [ ] **Restore git rev dependency** — workspace Cargo.toml currently uses local path deps for dashcore. Restore `git = "..." rev = "..."` once cargo git cache issue is resolved. -- [ ] **`blocking_read()` deadlock risk** — `Signer::sign()` uses `blocking_read()` on tokio `RwLock`. Document constraint or consider `std::sync::RwLock` for wallet. -- [ ] **Expose wallet_info lock accessor** — CoreWallet getters each acquire the lock individually and clone data (e.g. `utxos()` clones entire BTreeSet). Add `pub async fn wallet_info() -> RwLockReadGuard` for callers who need multiple reads in one lock. Stop cloning in getters — return references via the guard. Not urgent: no current caller chains multiple getters. - ---- - -### Key Repositories - -| Repo | Disk Path | Notes | -| ---- | --------- | ----- | -| `rs-platform-wallet` | `packages/rs-platform-wallet/` | Target library (this plan) | -| `rs-platform-encryption` | `packages/rs-platform-encryption/` | DIP-15 crypto — already a dependency, do not duplicate | -| `rs-platform-wallet-ffi` | `packages/rs-platform-wallet-ffi/` | FFI layer — update exports in PR-5 | -| `key-wallet` | `../rust-dashcore/key-wallet/` | UTXO wallet, key derivation, TransactionBuilder, `WalletInterface` (manager feature) | -| `dash-spv` | `../rust-dashcore/dash-spv/` | SPV client, BIP157/158 sync, push-based | -| `rs-sdk` | `packages/rs-sdk/` | DAPI client (`Sdk`, `SdkBuilder`, `AddressProvider`) | -| `dash-evo-tool` | `../dash-evo-tool/` | Integration target | - -### Platform Wallet (current — being replaced) - -- [packages/rs-platform-wallet/src/platform_wallet_info/identity_discovery.rs](packages/rs-platform-wallet/src/platform_wallet_info/identity_discovery.rs) — consolidate into `IdentityWallet::sync()` -- [packages/rs-platform-wallet/src/platform_wallet_info/contact_requests.rs](packages/rs-platform-wallet/src/platform_wallet_info/contact_requests.rs) — consolidate into `DashPayWallet`; fix AES decryption bug -- [packages/rs-platform-wallet/src/platform_wallet_info/key_derivation.rs](packages/rs-platform-wallet/src/platform_wallet_info/key_derivation.rs) — fix `key_type'` path segment -- [packages/rs-platform-wallet/src/wallet/identity/managed_identity/mod.rs](packages/rs-platform-wallet/src/wallet/identity/managed_identity/mod.rs) -- [packages/rs-platform-wallet/src/wallet/dashpay/contact_request.rs](packages/rs-platform-wallet/src/wallet/dashpay/contact_request.rs) -- [packages/rs-platform-wallet/src/wallet/dashpay/established_contact.rs](packages/rs-platform-wallet/src/wallet/dashpay/established_contact.rs) - -### SDK Transitions Used - -**Identity**: -- `PutIdentity` trait — `packages/rs-sdk/src/platform/transition/put_identity.rs` -- `TopUpIdentity` trait — `packages/rs-sdk/src/platform/transition/top_up_identity.rs` -- `WithdrawFromIdentity` trait — `packages/rs-sdk/src/platform/transition/withdraw_from_identity.rs` -- `TransferToIdentity` trait — `packages/rs-sdk/src/platform/transition/transfer.rs` -- `TopUpIdentityFromAddresses` — fund identity from platform addresses -- `TransferToAddresses` — move identity credits to platform addresses - -**Platform addresses**: -- `TransferAddressFunds` — transfer between platform addresses -- `WithdrawAddressFunds` — withdraw platform address credits to Core L1 -- `TopUpAddress` — fund platform address from identity balance -- `AddressProvider` trait — `packages/rs-sdk/src/platform/address_sync/provider.rs` - -**DashPay**: -- Contact requests — `packages/rs-sdk/src/platform/dashpay/contact_request.rs` - -**DPNS**: -- `register_dpns_name`, `resolve_dpns_name_to_identity` - -**Shielded** (feature-gated): -- `ShieldFunds`, `UnshieldFunds`, `TransferShielded`, `WithdrawShielded`, `ShieldFromAssetLock` - -**Token transitions**: -- Transfer, mint, burn, freeze, purchase, claim, balance queries - -**Signing**: -- `Signer` — by value for withdraw/transfer -- `Signer` — implemented on `PlatformAddressWallet` - -### Evo Tool (to be replaced) - -- `dash-evo-tool/src/model/wallet/mod.rs` — current `Wallet` struct (will be deleted in PR-1) -- `dash-evo-tool/src/app.rs` — `AppContext.wallets: RwLock>>>` -- `dash-evo-tool/src/backend_task/dashpay/dip14_derivation.rs` -- `dash-evo-tool/src/backend_task/dashpay/hd_derivation.rs` -- `dash-evo-tool/src/backend_task/dashpay/encryption.rs` -- `dash-evo-tool/src/backend_task/identity/discover_identities.rs` — `AUTH_KEY_LOOKUP_WINDOW = 12` -- `dash-evo-tool/src/backend_task/wallet/fetch_platform_address_balances.rs` diff --git a/packages/rs-platform-wallet/docs/DASHPAY_MIGRATION_PLAN.md b/packages/rs-platform-wallet/docs/DASHPAY_MIGRATION_PLAN.md deleted file mode 100644 index 4acc2f675dc..00000000000 --- a/packages/rs-platform-wallet/docs/DASHPAY_MIGRATION_PLAN.md +++ /dev/null @@ -1,257 +0,0 @@ -# DashPay Migration Plan: Evo-Tool → Platform-Wallet - -## Goal - -Move ALL DashPay logic from evo-tool into platform-wallet's `DashPayWallet`. -Evo-tool becomes a thin UI layer that triggers operations and displays results. -No DashPay state flows from evo-tool into platform-wallet. - -## Current State - -### What DashPayWallet already owns (platform-wallet) -- Contact request send (ECDH key exchange, encrypted xpub, broadcast) -- Contact request sync (fetch received requests from Platform, auto-establish) -- Contact request accept/reject -- Contact account registration (DIP-14 address derivation, key-wallet account) -- Incoming payment address matching (DashpayReceivingFunds account pools) -- DIP-14/15 xpub derivation, account reference calculation -- Auto-accept proof generation/verification -- Encryption/decryption helpers (AES-256-CBC account labels) - -### What evo-tool still owns (needs to move) - -#### Profile Operations -| Operation | Evo-tool file | SDK calls | Moves to | -|-----------|--------------|-----------|----------| -| Load own profile | profile.rs:25-132 | Document::fetch_many (profile by $ownerId) | DashPayWallet::sync() | -| Create profile | profile.rs:134-374 (create path) | sdk.document_create + DocumentCreateTransitionBuilder | DashPayWallet::create_profile() | -| Update profile | profile.rs:134-374 (update path) | sdk.document_replace + DocumentReplaceTransitionBuilder | DashPayWallet::update_profile() | -| Fetch contact profile | profile.rs:432-459 | Document::fetch_many | DashPayWallet::sync() or on-demand | -| Search profiles | profile.rs:461-566 | DPNS query + profile fetch per identity | DashPayWallet::search_profiles() | -| Avatar processing | profile.rs:199-226 | SHA-256 hash + DHash fingerprint | Move to DashPayWallet (pure compute) | - -#### Payment Operations -| Operation | Evo-tool file | SDK calls | Moves to | -|-----------|--------------|-----------|----------| -| Send payment to contact | payments.rs | derive_contact_payment_address + CoreWallet send | DashPayWallet::send_payment() | -| Record sent payment | payments.rs:334-340 via cache_payment | None (local) | Inside send_payment() | -| Record received payment | transaction_processing.rs:81-90 | None (local) | Inside match_incoming_dashpay_address() | -| Load payment history | payments.rs (local DB query) | None | Stays in evo-tool (UI reads from persister DB) | - -#### Contact Operations (partially moved) -| Operation | Evo-tool file | Status | -|-----------|--------------|--------| -| Send contact request | contact_requests.rs | Already delegates to DashPayWallet | -| Accept contact request | contact_requests.rs | Already delegates to DashPayWallet | -| Reject contact request | contact_requests.rs | Already delegates to DashPayWallet | -| Load contacts (enriched) | contacts.rs | Decrypt contactInfo, fetch profiles — needs DashPayWallet | -| Load contact requests | contact_requests.rs | Query Platform — needs DashPayWallet | -| ContactInfo create/update | contact_info.rs | Encrypt + broadcast — needs DashPayWallet | - -## DashPay Contract (DIP-15) - -Three document types: - -### `profile` (one per identity, mutable) -- `displayName` (string, max 25) -- `publicMessage` (string, max 140) -- `avatarUrl` (uri, max 2048) -- `avatarHash` (32-byte SHA-256) -- `avatarFingerprint` (8-byte dHash) -- Indexed by `$ownerId` (unique) -- Requires `$createdAt`, `$updatedAt` - -### `contactRequest` (immutable, cannot be deleted) -- `toUserId` (32-byte Identifier) -- `encryptedPublicKey` (96 bytes: 16-byte IV + AES-256-CBC encrypted xpub) -- `senderKeyIndex`, `recipientKeyIndex` (u32) -- `accountReference` (u32, DIP-15 formula) -- `encryptedAccountLabel` (optional, 48-80 bytes) -- `autoAcceptProof` (optional, 38-102 bytes) -- Requires identity encryption/decryption bounded keys - -### `contactInfo` (private metadata about contacts) -- `encToUserId` (32 bytes, encrypted) -- `rootEncryptionKeyIndex`, `derivationEncryptionKeyIndex` -- `privateData` (CBOR-encoded encrypted: aliasName, note, displayHidden, accounts) - -Contract loaded via: `dpp::system_data_contracts::load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest())` - -## SPV Integration - -Payment address derivation path (DIP-15): -`m/9'/coin'/15'/account'/(sender_id)/(recipient_id)` → BIP32 non-hardened `/index` → P2PKH - -Flow: SPV block → key-wallet `check_core_transaction` advances address pools → -`received_transaction_finality()` matches output via `try_match_incoming_dashpay_address()` → -record PaymentEntry on ManagedIdentity. - -Key insight: key-wallet already manages DashPay address pools via `DashpayReceivingFunds` -account type. SPV detection is pre-computed during `register_contact_account()`. - -## New DashPayWallet Public API - -### Sync (replaces evo-tool's load/fetch operations) -```rust -/// Comprehensive DashPay sync: contact requests + profiles for all -/// managed identities. Call on wallet open and periodic refresh. -pub async fn sync(&self) -> Result -``` -Internally: -1. Call existing `sync_contact_requests()` for each identity -2. Fetch profile documents for each identity (query by $ownerId) -3. Parse into DashPayProfile, cache on ManagedIdentity via set_dashpay_profile() -4. Fetch contact profiles for established contacts -5. Return summary (new contacts, profile updates, etc.) - -### Data Models - -```rust -/// Input for profile create/update. Only caller-provided fields. -/// Platform-wallet computes avatar_hash + avatar_fingerprint from -/// avatar_bytes internally, then drops the bytes. -pub struct ProfileUpdate { - pub display_name: Option, - pub public_message: Option, - pub avatar_url: Option, - /// Raw image bytes pre-downloaded by the app layer (evo-tool). - /// Platform-wallet computes SHA-256 hash + DHash fingerprint from - /// these, includes them in the document, then drops the bytes. - /// `None` = no avatar / remove avatar. - pub avatar_bytes: Option>, -} - -/// Persisted/displayed profile. Output of sync/create/update. -/// No raw bytes — only the computed hashes survive after processing. -pub struct DashPayProfile { - pub display_name: Option, - pub bio: Option, - pub avatar_url: Option, - pub avatar_hash: Option<[u8; 32]>, // SHA-256 of image bytes - pub avatar_fingerprint: Option<[u8; 8]>, // DHash perceptual hash - pub public_message: Option, -} -``` - -NOTE: `avatar_bytes` is removed from `DashPayProfile` — it was transient -(only needed during document creation) and shouldn't be persisted or -carried in memory. Evo-tool's `dashpay_profiles` table column -`avatar_bytes` can be kept for local UI caching but the profile model -doesn't carry it. - -### Profile Mutations (replace evo-tool's create/update) -```rust -/// Create a new DashPay profile on Platform. -/// Computes avatar hash/fingerprint from input.avatar_bytes if present. -/// Builds DocumentCreateTransition, broadcasts, caches result internally. -pub async fn create_profile( - &self, - identity_id: &Identifier, - input: ProfileUpdate, -) -> Result - -/// Update an existing DashPay profile on Platform. -/// Fetches current revision, bumps, broadcasts DocumentReplaceTransition. -/// Computes avatar hash/fingerprint from input.avatar_bytes if present. -pub async fn update_profile( - &self, - identity_id: &Identifier, - input: ProfileUpdate, -) -> Result -``` - -### Search (replace evo-tool's DPNS + profile search) -```rust -/// Search for DashPay profiles by DPNS username prefix. -/// Queries DPNS contract, then fetches profiles for matching identities. -pub async fn search_profiles( - &self, - prefix: &str, -) -> Result)>, PlatformWalletError> -``` -Needs DPNS contract: `load_system_data_contract(SystemDataContract::DPNS, ...)` - -### Payments (replace evo-tool's send + receive paths) -```rust -/// Send a Core payment to a DashPay contact. -/// Resolves contact's receiving address from DIP-14 derivation, -/// builds and broadcasts Core tx via CoreWallet, records PaymentEntry. -pub async fn send_payment( - &self, - from_identity_id: &Identifier, - to_contact_id: &Identifier, - amount_duffs: u64, - memo: Option, -) -> Result -``` - -For received payments: extend `match_incoming_dashpay_address` variants -to also call `record_dashpay_payment()` internally when a match is found. -The caller passes `(txid, value)` and the method handles everything. - -### ContactInfo (replace evo-tool's contact_info.rs) -```rust -/// Create or update encrypted contactInfo document on Platform. -pub async fn update_contact_info( - &self, - identity_id: &Identifier, - contact_id: &Identifier, - alias: Option, - note: Option, - hidden: bool, -) -> Result<(), PlatformWalletError> -``` - -### Load Contacts (replace evo-tool's contacts.rs enriched loading) -```rust -/// Load all established contacts with their profiles and DPNS names. -/// Decrypts contactInfo, fetches profiles for each contact. -pub async fn load_contacts( - &self, - identity_id: &Identifier, -) -> Result, PlatformWalletError> -``` - -## Migration Sequence - -### Phase 1: Profile Sync + Mutations (~200 LOC) -1. Add `fetch_profiles_for_identities()` internal helper to DashPayWallet -2. Add `sync()` that combines contact request sync + profile sync -3. Add `create_profile()` and `update_profile()` with avatar processing -4. Evo-tool profile.rs: delegate to DashPayWallet, remove Platform queries -5. Delete cache_profile from platform_wallet_cache.rs - -### Phase 2: Payment Recording (~100 LOC) -1. Extend `try_match_incoming_dashpay_address` to accept txid+value and record internally -2. Add `send_payment()` to DashPayWallet -3. Evo-tool: payments.rs delegates send, transaction_processing.rs simplified -4. Delete cache_payment + cache_payment_with_pw_blocking - -### Phase 3: Contact Enrichment + ContactInfo (~200 LOC) -1. Add `load_contacts()` with profile + DPNS enrichment -2. Add `update_contact_info()` for encrypted contact metadata -3. Add `search_profiles()` with DPNS integration -4. Evo-tool contacts.rs + contact_info.rs: delegate entirely - -### Phase 4: Cleanup (~-400 LOC from evo-tool) -1. Delete `platform_wallet_cache.rs` -2. Simplify profile.rs, payments.rs, contacts.rs to thin delegation -3. Make `set_dashpay_profile` and `record_dashpay_payment` on ManagedIdentity `pub(crate)` -4. Remove DashPay contract from AppContext (platform-wallet loads it internally) - -## Open Questions - -1. **Avatar processing**: RESOLVED. Evo-tool (app layer) downloads avatar bytes via HTTP - and passes them in `DashPayProfile.avatar_bytes`. Platform-wallet computes SHA-256 hash + - DHash fingerprint from those bytes — it knows DIP-15 requires them. Hash/fingerprint - functions move from evo-tool's `avatar_processing.rs` to platform-wallet. HTTP fetch - (`fetch_image_bytes`) stays in evo-tool. No reqwest dependency in platform-wallet. - -2. **State transition options**: evo-tool passes `app_context.state_transition_options()` for - fee multiplier etc. Platform-wallet needs equivalent configuration. - -3. **Load payment history**: Currently reads from evo-tool's persister DB directly. - This should stay in evo-tool (UI reads persisted data) — no Platform query needed. - -4. **DPNS contract for search**: search_profiles needs both DashPay + DPNS contracts. - Both available via `load_system_data_contract`. diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 574715eed6b..7d19ffe7372 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -18,6 +18,14 @@ //! rows it implies and freeze forever. The unbounded //! persistence channel can never `Lagged`, so that freeze cannot occur. //! +//! # Why persistence lives outside the manager +//! +//! Upstream `WalletManager` deliberately has no per-wallet locks and no +//! built-in persistence. An earlier attempt that put per-wallet +//! `Arc>` locks and a persistence trait inside the manager made +//! SPV sync about 7x slower from lock contention, so persistence is this +//! external consumer instead. Do not move it back into the manager. +//! //! # Why a single consumer, not per-wallet //! //! The persistence channel carries every event for every wallet. Each diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 04cfd7f0d03..e1538a26263 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -213,9 +213,9 @@ impl PersistenceError { /// (an evo-tool wrapper type around `dpp::Identity`) is currently written /// directly by `Database::insert_local_qualified_identity` and /// `Database::update_local_qualified_identity`, called from backend tasks. -/// Moving this blob into the persister is planned as a future commit -/// (evo-tool task #130 / Phase 9c). Until then, the persister does not -/// write or read the `identity.data` column. +/// Moving this blob into the persister is planned as a future evo-tool +/// change. Until then, the persister does not write or read the +/// `identity.data` column. /// - **Platform addresses** and **token balances**: these are dropped on /// flush; backend tasks own their persistence. /// diff --git a/packages/rs-platform-wallet/src/manager/identity_sync.rs b/packages/rs-platform-wallet/src/manager/identity_sync.rs index 54d2fd81af8..855cefa055b 100644 --- a/packages/rs-platform-wallet/src/manager/identity_sync.rs +++ b/packages/rs-platform-wallet/src/manager/identity_sync.rs @@ -9,6 +9,12 @@ //! [`unregister_identity`](Self::unregister_identity), //! [`update_watched_tokens`](Self::update_watched_tokens)). //! +//! The watch list is required, not an optimization: Platform's token +//! balance and info queries take explicit token ids +//! (`GetIdentityTokenBalancesRequestV0.token_ids`), and no query lists the +//! tokens an identity holds. The manager can only sync tokens a caller +//! registered through `update_watched_tokens`. +//! //! Each pass walks every registered identity, snapshots its watched //! token list, then sequentially: //! @@ -19,9 +25,7 @@ //! batches or identities) — see crate-level note on SDK `!Send` //! futures. //! 2. Builds a [`TokenBalanceChangeSet`] from the batch result and -//! forwards it to the persister. The wallet-side write path -//! (`TokenWallet::sync` mutating `PlatformWalletInfo.token_balances` -//! directly) is unrelated and untouched. +//! forwards it to the persister. //! 3. Updates the manager's own per-identity cache row in lockstep, //! so callers reading [`state_for_identity`](Self::state_for_identity) //! after [`sync_now`](Self::sync_now) returns see fresh values. @@ -617,8 +621,7 @@ where // Type-annotate the call site explicitly: `fetch_many` // is generic over the response type, and the inference // chain through `RetrievedObjects` doesn't pick a unique - // implementor without a hint. Same pattern used by - // `TokenWallet::sync`. + // implementor without a hint. let fetched: Result = TokenAmount::fetch_many(self.sdk.as_ref(), query).await; match fetched { diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 10780172b4a..0f4ec4a8c33 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -1,7 +1,7 @@ //! Apply a [`PlatformWalletChangeSet`] onto a [`PlatformWalletInfo`] //! during restore. //! -//! Inverse of the mutation methods that emit changesets in Phase 9a-2: +//! Inverse of the mutation methods that emit changesets: //! given a persisted [`PlatformWalletChangeSet`], it replays each //! sub-changeset onto the in-memory state so the wallet converges to //! the same state the original mutations produced. @@ -18,14 +18,13 @@ //! isn't present in the wallet are logged with `tracing::warn!` and //! skipped — orphans usually mean a stale persisted entry from //! before the owner identity was removed. -//! - **Loud on core failures.** If `key_wallet`'s core apply fails (HD -//! account derivation cascade), the platform apply fails too. Core -//! state must land before any platform-specific bucket runs. +//! - **Core is not replayed.** `cs.core` is dropped. `key_wallet` keeps +//! core state current at runtime, and boot restores it from the +//! persister's `ClientStartState`, not from changeset replay. //! //! # Ordering //! -//! 1. `cs.core` — runs first via `ManagedWalletInfo::apply_changeset`. -//! Wallet account state must exist before balance recompute. +//! 1. `cs.core` — dropped (see the invariant above). //! 2. `cs.identities` — insert/update entries, then `removed`, then //! primary identity fixup. //! 3. `cs.contacts` — sent/incoming inserts, tombstone removes, @@ -42,7 +41,7 @@ //! callback. There is nothing on `PlatformWalletInfo` to apply //! them onto. //! 7. `update_balance()` — recompute the cached `WalletBalance` from -//! the now-restored UTXO set; the returned changeset is discarded. +//! the current UTXO set; the returned changeset is discarded. use key_wallet::wallet::Wallet; @@ -64,6 +63,9 @@ pub enum ApplyError { /// /// Stored as `String` to keep the platform-wallet public API /// decoupled from `key_wallet`'s error enum. + /// + /// Not constructed today: `apply_changeset` drops `cs.core` instead of + /// replaying it (see the module docs). #[error("core wallet apply failed: {0}")] CoreApply(String), @@ -799,7 +801,7 @@ mod tests { } // ---------------------------------------------------------------------- - // Round-trip tests (Phase 9a-4) + // Round-trip tests // // The shape of every test is identical: // 1. Build two empty `PlatformWalletInfo`s — A is the wallet that gets @@ -813,14 +815,14 @@ mod tests { // // These verify the round-trip contract: changesets emitted by mutations // are faithful enough that apply rebuilds the same in-memory state. This - // is what makes the persister adapter (Phase 9a-5) safe — it can + // is what makes the persister adapter safe — it can // serialize the captured changeset and deserialize it back into a // sibling wallet on restart with no information loss. // - // Out of scope: AssetLockManager / TokenWallet / PlatformAddressWallet + // Out of scope: AssetLockManager / token sync / PlatformAddressWallet // mutations are async and require an `Sdk` + broadcaster + Notify, so // they can't run as plain unit tests. Their round-trip coverage will - // come from integration tests (Phase 9a-4 follow-up). The + // come from integration tests (not written yet). The // synthesized-data tests above already cover the apply side; the gap // is verifying the *mutation side* emits a faithful changeset. // ---------------------------------------------------------------------- @@ -1289,7 +1291,7 @@ mod tests { } // ---------------------------------------------------------------------- - // Reviewer test gaps (Phase 9a-3 followup) + // Reviewer test gaps // ---------------------------------------------------------------------- /// Reviewer #6a: removing the sole identity drops it cleanly. diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs index ed20b65f11e..0f93f00bff8 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/auto_accept.rs @@ -41,8 +41,11 @@ use crate::error::PlatformWalletError; /// DIP-15 mandates only that the proof's 4-byte timestamp *is* an expiry (and the /// hardened derivation index); it does not prescribe a value. We pick a short /// default because the QR carries a usable (bearer) private key and auto-accept -/// is always-on (no off-switch) — see the security notes in -/// `docs/dashpay/QR_AUTO_ACCEPT_SPEC.md` §6. +/// is always-on (no off-switch). The key in the QR is intrinsic to DIP-15 (the +/// scanner must sign, and the owner cannot pre-sign without the scanner's id); it +/// only authorizes auto-accept, so a leaked QR can at worst produce unwanted +/// contact requests (removable with ignore) until it expires, and the short expiry +/// is the mitigation. pub const AUTO_ACCEPT_TTL_SECS: u32 = 3600; /// `key type` byte for an ECDSA_SECP256K1 auto-accept key/proof (DIP-15). diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rs index 9145403be8f..bd914b93606 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/dip14.rs @@ -251,6 +251,12 @@ pub fn derive_contact_payment_addresses( /// /// "We recommend a gap limit of 10 at this stage, which means to load 10 /// addresses past the last used address." +/// +/// The wallet does not use this value for contact pools. key-wallet builds +/// the `DashpayReceivingFunds` and `DashpayExternalAccount` address pools +/// with a gap of 20, a deliberate, more conservative choice. Do not shrink +/// those pools to 10 to match the DIP: that narrows the scan window for +/// payments past a run of unused addresses. pub const DEFAULT_CONTACT_GAP_LIMIT: u32 = 10; // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs index 6060013024a..efa2a7b090e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs @@ -27,7 +27,10 @@ //! order-independent (the two legacy wallets differ in param order). //! - `islock` is optional: a missing param **and** the literal string `"null"` //! (which Android emits for chainlock-confirmed invites) both mean "no instant -//! lock" — the claim reconstructs a ChainLock proof instead. +//! lock" — the claim reconstructs a ChainLock proof instead. The hex decodes +//! as the deterministic InstantSend lock; a lock in the older +//! non-deterministic format does not decode, so the claim fails rather than +//! guessing (no live wallet emits that format). //! - `assetlocktx` is kept as the raw hex string; the claim tries it as-given //! then byte-reversed on a fetch miss, mirroring the legacy endianness retry. //! @@ -40,6 +43,28 @@ //! force a large allocation; the WIF is decoded + compression-checked at parse, //! and its network is validated against the wallet at claim (a wrong-network WIF //! is a valid key on the wrong chain, caught before the funding fetch). +//! +//! What the design does and does not protect against: +//! - Every theft reduces to "who holds the link". Platform checks the claim's +//! signature against the key of the asset-lock output, and the new identity's +//! id is derived from the funding outpoint, so someone who watches a claim in +//! flight but lacks the voucher key cannot redirect it, and two racing claims +//! target the same identity (exactly one commits). +//! - The inviter can always re-derive the voucher key, so after handing over a +//! link it can still claim or reclaim the voucher first. Nothing is stolen +//! (the funds were the inviter's), but the invitee is denied onboarding with +//! no sign of why. For the same reason, reclaiming a leaked link is a race the +//! inviter can lose. +//! - Nothing signs the link, so whoever controls the channel it travels over +//! can swap in a different invite. The worst outcome is the invitee sending a +//! contact request to the attacker's identity, which an ordinary contact +//! request achieves anyway. Signing would not help: the channel is the trust +//! root. +//! - Because the identity id comes from the funding outpoint, the inviter knows +//! the invitee's identity id before the invitee claims it. +//! - The legacy wallets wrapped the link in an AppsFlyer OneLink, which sends +//! the plaintext `pk` to AppsFlyer's servers. We parse that host but never +//! emit it. use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; use dashcore::transaction::special_transaction::TransactionPayload; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 8f95fed9279..9486dca9bf5 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -800,6 +800,18 @@ impl DashPayView<'_> { } } +/// High-water rewind window applied to the incremental contact-request query. +/// Re-fetching the last 10 minutes each sweep covers clock skew **and** +/// equal-`$createdAt` documents straddling a page boundary, so it is +/// correctness-load-bearing — NOT a tunable; `0` is invalid. +const SYNC_OVERLAP_MS: u64 = 10 * 60_000; + +/// Lower bound for the incremental `$createdAt >` query: the high-water minus +/// the overlap window. `None` (no cursor yet) ⇒ full fetch. +fn query_lower_bound(high_water: Option) -> Option { + high_water.map(|hw| hw.saturating_sub(SYNC_OVERLAP_MS)) +} + /// Collapse a stream of parsed received contact requests to the single /// newest request per sender, keyed by `sender_id`. /// @@ -815,18 +827,29 @@ impl DashPayView<'_> { /// like a "rotation" away from the tracked state, thrashing it back and /// forth each pass. Collapsing to the newest first makes the sweep a /// fixpoint. -/// High-water rewind window applied to the incremental contact-request query. -/// Re-fetching the last 10 minutes each sweep covers clock skew **and** -/// equal-`$createdAt` documents straddling a page boundary, so it is -/// correctness-load-bearing — NOT a tunable; `0` is invalid. -const SYNC_OVERLAP_MS: u64 = 10 * 60_000; - -/// Lower bound for the incremental `$createdAt >` query: the high-water minus -/// the overlap window. `None` (no cursor yet) ⇒ full fetch. -fn query_lower_bound(high_water: Option) -> Option { - high_water.map(|hw| hw.saturating_sub(SYNC_OVERLAP_MS)) -} - +/// +/// Collapsing per sender is also why a contact has one payment channel even +/// though DIP-15 lets a sender expose several accounts at once. Supporting +/// several was deferred on purpose, and the collapse cannot simply be keyed +/// by `(sender, account_reference)`: +/// - The recipient cannot tell a rotation from a new account. The low 28 bits +/// of `accountReference` are masked with an HMAC of the new xpub under a +/// key only the sender knows, so a rotated channel and a brand-new account +/// look unrelated. Which +/// channel a request belongs to would have to come from the user. +/// - Keying by reference brings back the sweep thrash described above: a +/// rotated sender's old and new docs would both survive and flip the stored +/// channel on every pass. +/// - The receiving account index is a hardened derivation path component, so +/// a locally invented channel index would desync the addresses we advertise +/// from the ones we watch. +/// - Each new reference would become a pending prompt, turning a request +/// flood into queue exhaustion, and "add account" from an established +/// contact can redirect payments, so it needs the same care as accepting a +/// new contact. +/// +/// DIP-15 section 8.4 allows ignoring additional requests, which is what the +/// collapse does. fn newest_received_per_sender( requests: impl IntoIterator, ) -> std::collections::BTreeMap { @@ -3847,6 +3870,13 @@ impl DashPayView<'_, B> { /// /// Ignore is **local-only** — there is no on-chain artifact (syncing it /// would leak who you ignored via the public contact-request indices). + /// If ignore ever syncs across devices, it must be a single list the owner + /// encrypts to themselves, not a `contactInfo` per ignored sender: a + /// `contactInfo` about a non-contact exists publicly and its `$createdAt` + /// lines up with the incoming request, which reveals who was ignored. + /// Ignore also saves no fetch cost: the query has no "sender not in" + /// axis, so an ignored sender's requests are still fetched and verified, + /// then dropped here. /// The ignore is persisted through the existing /// changeset → apply → SQLite pipeline so it survives a relaunch. /// diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index 76ccd55ce9e..2c848be0aee 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -12,7 +12,6 @@ //! here: after a successful claim the UI asks the invitee whether to establish //! contact with the sender and, if so, calls the existing contact-request path //! ([`send_contact_request_with_external_signer`](IdentityWallet::send_contact_request_with_external_signer)). -//! See `docs/dashpay/DIP15_INVITATIONS_SPEC.md`. use std::collections::BTreeMap; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index 940b8da96ec..a5b49b41bc6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -250,10 +250,10 @@ impl DashPayView<'_, B> { /// starts, so a stretch of unused indices cannot stop it. /// /// Unused stretches come from sends that consumed an address and then - /// failed to build; five gap limits (100 addresses at the DIP-15 gap of - /// 20) covers far more consecutive failures than a contact realistically - /// accumulates, and the walk still extends past it whenever a match - /// lands near the frontier. + /// failed to build; five gap limits (100 addresses at key-wallet's gap + /// of 20; DIP-15 recommends 10) covers far more consecutive failures + /// than a contact realistically accumulates, and the walk still + /// extends past it whenever a match lands near the frontier. const HISTORICAL_SEED_GAP_MULTIPLE: u32 = 5; /// The `(owner identity, contact identity)` pair every reconstructed diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index dbd61fdd628..50a8aecbe10 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -542,6 +542,17 @@ impl DashPayView<'_, B> { /// are themselves managed identities are skipped (their own /// `dashpay_profile` is authoritative). Display-only: a failure never /// aborts the sweep. Returns the number of cache entries changed. + /// + /// Fetching pending senders' profiles is an accepted privacy cost: an + /// observer could link the `$ownerId In [...]` query to our inbound set, + /// but the DAPI node that serves our `toUserId == me` query already sees + /// that whole set, so the profile fetch adds little. + /// + /// Profiles are refetched after `CONTACT_PROFILE_REFRESH_MS` rather than by + /// `$updatedAt`: `$ownerId In [...] AND $updatedAt > marker` cannot be + /// proven in one query (an `In` on the first index field plus a range on + /// the second is not a contiguous index range). A per-owner `$updatedAt` + /// query would lose the batching. pub async fn sync_contact_profiles(&self) -> Result { let now_ms = crate::util::now_ms(); let dashpay_contract = super::dashpay_contract()?; diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs index 83fc5f9a5dc..ab661278f99 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/profile.rs @@ -100,6 +100,12 @@ pub fn calculate_avatar_hash(image_bytes: &[u8]) -> [u8; 32] { /// 3. Compare each pixel with its right neighbor /// 4. Generate 64-bit hash from comparisons /// +/// Compare fingerprints by Hamming distance, never by equality. The bit +/// layout matches dashj, but the pixel pipeline differs (grayscale weighting, +/// resize filter, 9x9 vs 9x8), so the same image hashed by another client +/// gives a close fingerprint, not an identical one. A cross-client test that +/// expects identical bytes is wrong by construction. +/// /// Returns `Err` if the bytes are not a valid image. pub fn calculate_dhash_fingerprint(image_bytes: &[u8]) -> Result<[u8; 8], String> { let img = diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/key_storage.rs b/packages/rs-platform-wallet/src/wallet/identity/types/key_storage.rs index 8dee1a702b9..634a6459e3f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/key_storage.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/key_storage.rs @@ -26,14 +26,27 @@ pub enum PrivateKeyData { } /// Identity lifecycle status on Platform. +/// +/// Intended transitions: `Unknown` -> `PendingCreation` -> `Active`, +/// `PendingCreation` -> `FailedCreation` -> `Active` (after a retry), and +/// `Active` -> `NotFound` -> `Active`. Today the library itself sets only +/// `Unknown` (the default) and `Active` (after loading or discovering the +/// identity on Platform); the other variants exist for hosts and are +/// persisted like the rest. The FFI stores each variant as a byte in +/// declaration order (0 to 4), so never reorder them. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum IdentityStatus { + /// Not checked against Platform yet. #[default] Unknown, + /// Registration submitted, not yet confirmed. PendingCreation, + /// Confirmed on Platform. Active, + /// Registration failed; it can be retried. FailedCreation, + /// Was active, but Platform no longer returns it. NotFound, } diff --git a/packages/swift-sdk/CLAUDE.md b/packages/swift-sdk/CLAUDE.md index 89efa185cf1..041a6fa1951 100644 --- a/packages/swift-sdk/CLAUDE.md +++ b/packages/swift-sdk/CLAUDE.md @@ -69,6 +69,16 @@ possible: add a single FFI entry point that does the whole thing and returns the bytes ready to persist. +Keep the two signer handles separate. The document signer (`VTableSigner` +/ `SignerHandle`) is a vtable that calls back into Swift to sign state +transitions. The wallet-HD signer (`MnemonicResolverHandle`) lets Rust +fetch the mnemonic for one operation, derive, and wipe. Raw-secret DashPay +operations (ECDH, `accountReference`, `contactInfo` encryption) run on the +wallet-HD side inside the FFI crate's Rust and return only results. +Merging the handles would either route the mnemonic through the +document-signing path or move DIP-15 crypto into Swift. What the Keychain +protection does and does not guarantee is written on `WalletStorage`. + ## Concrete precedent The correct shape is `platform_wallet_discover_identities` (and its @@ -93,3 +103,9 @@ If it's deciding anything — how many, which index, which path, which key, which order — move the decision to Rust. If you find a decision that Rust doesn't currently let you ask for by a single call, add the helper in the Rust library first. + +## Parity with the Kotlin SDK + +Capability parity between the two SDKs and example apps is tracked in +`docs/sdk/sdk-parity-manifest.json`. See "Keeping parity with iOS" in +`packages/kotlin-sdk/CLAUDE.md` for the rules and the regeneration command. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift index e468cc44406..76e530c4d76 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift @@ -29,6 +29,17 @@ import Security /// * Biometric-protected seed stash at `wallet.biometric` — not yet /// wired to a caller but kept because it's a different category /// (hardware-protected rather than a legacy PIN construct). +/// +/// What the mnemonic's protection actually is: the items are +/// `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` with no +/// `SecAccessControl` on the read path, and the biometric stash above is +/// unused. So "user present" means "device unlocked": any code in the +/// process can drive the mnemonic resolver while the device is unlocked. +/// Android's default auth-gated Keystore policy is stricter. Each +/// resolver-backed operation rebuilds the full BIP-39 seed and master +/// xprv inside the FFI crate's Rust and wipes them afterwards, so the gain +/// over a resident wallet is a short per-operation window, not "the seed +/// never enters memory". public class WalletStorage { /// Unified keychain service name for the app. Everything the /// SDK writes — per-wallet mnemonics (here), identity private diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift index d41605004fd..a0c1ac657e0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift @@ -601,6 +601,18 @@ public final class KeychainSigner: Signer, @unchecked Sendable { /// Shared by the sign trampoline and tests: consult any scoped /// in-memory registry first, then the existing platform-address / /// breadcrumb / persisted-key paths. + /// + /// Keep the stored-scalar fallback (`lookupIdentityPrivateKey` + + /// `ffiSign`) until a per-device migration stamp, persisted at + /// runtime, records that every identity key row has a derivation path + /// and the old `identity_privkey.*` Keychain items are purged. A + /// release-time check is not enough: a user can skip the version that + /// ran the backfill, so the build that drops the fallback must still + /// run the Keychain-driven backfill on first launch (the Keychain + /// survives a SwiftData store rebuild). Wallets without a mnemonic + /// (watch-only or imported with a bare key) can only sign through the + /// fallback until a mnemonic is imported. Removing it early locks those + /// users out of identities that work today. func signOnDemand( publicKey: Data, keyType: UInt8, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 982d064f4df..057fca7a0dd 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -119,8 +119,7 @@ class ShieldedService: ObservableObject { // // Surfaces wall-clock of each sync pass so devnet stress tests // (1M shielded notes via `dashpay/drive:3.1-shielded.*`) can be - // measured from the iOS client. See - // `docs/shielded-sync-timing-spec.md` for the design. + // measured from the iOS client. /// Wall-clock of the most recent NON-cooldown completed sync /// pass. Nil until the first such pass after `bind()`. @@ -1041,7 +1040,11 @@ class ShieldedService: ObservableObject { // here is the Swift-side timestamp of when the // event handler runs (≈ when isSyncing flipped // true → false), pairing with `currentSyncStartedAt` - // captured on the false → true edge. Clamp to >= 0 + // captured on the false → true edge. Both edges must + // be Swift `Date()`s: `event.syncUnixSeconds` is whole + // seconds, so pairing it with a `Date()` gives + // negative or inflated durations for sub-second + // passes. Clamp to >= 0 // defensively — should never be negative with // Swift-edge endpoints, but if the start timestamp // is missing (e.g. event arrived without a paired diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index c4376359aaf..e6c47d0a07f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -582,8 +582,7 @@ var body: some View { // completion, shows the most recent non-cooldown // pass duration. Mono digits keep the number // readable as it ticks during long initial - // syncs (e.g. 10 min at N=1M). See - // `docs/shielded-sync-timing-spec.md`. + // syncs (about 20 min for 1M notes on paloma). if shieldedService.isSyncing, let elapsed = shieldedService.currentSyncElapsed { VStack(alignment: .leading, spacing: 4) { diff --git a/packages/swift-sdk/SwiftExampleApp/docs/shielded-sync-timing-spec.md b/packages/swift-sdk/SwiftExampleApp/docs/shielded-sync-timing-spec.md deleted file mode 100644 index f4d89591de5..00000000000 --- a/packages/swift-sdk/SwiftExampleApp/docs/shielded-sync-timing-spec.md +++ /dev/null @@ -1,353 +0,0 @@ -# Shielded sync timing — SwiftExampleApp spec (revised) - -## Goal - -When the user runs SwiftExampleApp against a devnet whose chain has a -pre-seeded shielded pool (N=1M notes via `dashpay/drive:3.1-shielded.2`), -**they need to see in the UI how long a shielded sync pass took, AND -have a live signal that an in-flight sync is making progress.** - -Primary use case: confirming initial wallet sync against a 1M-note -devnet completes within expected wall-clock (~10 min on M-series), and -that the user can tell from the UI whether sync is alive vs hung during -the long initial pass. - -## Why the goal expanded vs the first draft - -Adversarial review surfaced that a post-hoc-only duration is theatre at -N=1M: the user stares at a "Syncing..." spinner for ten minutes with no -signal "alive vs hung" — exactly the failure mode this exercise is -meant to detect. A live elapsed display is therefore mandatory for the -primary use case, not optional. - -## Scope (two surfaces) - -### Surface 1 — timing display (the primary task) - -The existing "ZK Shielded Sync Status" section in -`CoreContentView.swift` (≈ lines 391–510). One inline addition to that -section, two new `@Published` fields on `ShieldedService`, one console -log line per pass. No existing functionality changes. - -### Surface 2 — TEMPORARY test-wallet import (to make the timing meaningful) - -To validate against `dashpay/drive:3.1-shielded.2` and recover the -seeded 400 000 balance, the iOS app needs to sync wallet A whose -ZIP-32 seed is the **raw bytes `[0x73; 32]`** — not derivable from -any BIP-39 mnemonic. - -This requires: - -1. **New FFI** `platform_wallet_manager_bind_shielded_with_raw_seed` - in `packages/rs-platform-wallet-ffi/src/shielded_sync.rs` — sibling - to the existing `platform_wallet_manager_bind_shielded`, but - accepts a `seed_bytes: *const u8` + `seed_len: usize` instead of - a `MnemonicResolverHandle`. Calls - `wallet.bind_shielded(raw_seed.as_slice(), ...)` directly. -2. **Swift wrapper** `bindShieldedRawSeed(walletId:rawSeed:accounts:)` - in `Sources/SwiftDashSDK/PlatformWallet/...` -3. **Debug-only UI button** "Bind Test Wallet A (Shielded)" in the - existing ZK Shielded Sync Status section. Hardcodes `[0x73; 32]` - and calls the new wrapper. - -**Everything in Surface 2 is tagged with explicit removal TODOs.** -Example tag: -``` -// TODO(shielded-snapshot-devnet-test): remove this FFI entry once -// SwiftExampleApp adopts a proper test-wallet import flow. -// Tracked: dashpay/platform#3714. -``` - -Surface 2 lives behind no `#[cfg]` gate (would complicate the -release build), so the TODO comments are the contract: this surface -is provisional and removed before merging the ultimate version of -PR #3732. - -## Non-goals - -- Not building a separate sync dashboard. -- Not orchestrating sync from Swift — `walletManager.startShieldedSync` - already runs the loop; we observe. -- Not exposing per-cmx mid-pass progress (e.g. "412k/1M scanned"). That - needs a Rust→Swift signal we don't have today. Out of scope. -- Not measuring "whole catch-up cycle" duration as a single value (see - §"Known limitation: catch-up vs per-pass" below). -- Not persisting timing across app launches — display state only. -- Not building a `timeSinceBind` / "cumulative since bind" number — it - grows unbounded and answers no concrete question. - -## Existing surface (recap) - -**Service:** `ShieldedService` (`Core/Services/ShieldedService.swift`) - -Existing `@Published`: -- `isSyncing: Bool` -- `lastSyncTime: Date?` (when the most recent sync **completed**) -- `syncCountSinceLaunch: Int` -- `totalScanned: UInt64`, `totalNewNotes: UInt64`, `totalNewlySpent: UInt64` -- counters, balance, address fields - -**UI:** `CoreContentView.swift` "ZK Shielded Sync Status" section -already shows `ProgressView()` + "Syncing..." while in-flight, -"Last sync: " via `lastSyncTime`, cumulative counters, -balance + Notes Synced watermark, Sync Now / Clear buttons. - -**Underlying flow (Rust → Swift):** -1. `walletManager.$shieldedSyncIsSyncing` publishes `Bool`. Flips true - at sync-pass start, false at completion. -2. `walletManager.$lastShieldedSyncEvent` publishes a - `ShieldedSyncEvent` on each pass completion. - -**Gap:** UI shows COUNTERS and relative completion time, but no -wall-clock duration of a single pass, and no live indication during a -10-minute initial sync. - -## Spec - -### S1. Service-level fields - -Two new `@Published` fields on `ShieldedService` (read by the UI): - -| Field | Type | Semantic | -|---|---|---| -| `lastSyncDuration: TimeInterval?` | seconds | wall-clock of the most recent non-cooldown sync pass (set at completion) | -| `currentSyncElapsed: TimeInterval?` | seconds | running wall-clock of the in-flight sync; ticks while `isSyncing == true`, nil otherwise | - -One new private field: - -| Field | Set when | -|---|---| -| `currentSyncStartedAt: Date?` | `isSyncing` Swift mirror transitions false → true | - -Both `@Published` fields stay nil until they have a real value to -show. Both reset to nil on `bind()` / `reset()` / `clearLocalState`. - -**No `lastBindCompletedAt`, no `lastSyncCompletedAt`, no `timeSinceBind`** — -dropped per Scope reviewer. - -### S2. Pass boundaries — Swift edges only - -Both pass endpoints are observed from the **Swift mirror of -`$shieldedSyncIsSyncing`**, not from Rust event timestamps. - -- **Start:** false → true transition of `isSyncing`. -- **End:** true → false transition of `isSyncing`. - -Rationale: `event.syncUnixSeconds` is integer-second resolution; mixing -it with `Date()` on the Swift edge can render negative or grossly -inflated durations for sub-second steady-state passes. Using the Swift -edge for both endpoints means the Rust↔Swift latency cancels out -symmetrically. - -### S3. Live ticker - -A 1-second `Timer` lives on the `ShieldedService` and: - -- Starts on the false → true transition. -- Tick handler: updates `currentSyncElapsed = Date() − currentSyncStartedAt`. -- Stops + nils `currentSyncElapsed` on the true → false transition. - -One timer source on the service rather than a per-view source — the -view subscribes to `$currentSyncElapsed` like any other published -field. Service is `@MainActor` so timer fires on main thread already. - -### S4. Edge handling (bug-fixes from review) - -- **B1 (clock skew):** Solved by S2 — Swift-edge for both endpoints. -- **B2 (failure leaves start stamped):** `currentSyncStartedAt` is - cleared on EVERY true → false transition, regardless of whether the - emitted `ShieldedSyncEvent` reports success or failure. Same for the - ticker. -- **B3 (`switchTo(walletId:)` silently resets timing):** Documented in - `bind()`'s comment block. UI behaviour after a wallet switch is the - same as a fresh bind — the row reappears after the next pass. -- **B4 (negative or absurd duration):** Clamp to `max(0, …)` in the - view formatter; if `currentSyncStartedAt` is nil at completion (which - shouldn't happen with S2 but is defensible defence-in-depth), set - `lastSyncDuration = nil` and skip the log line. -- **B5 (re-fire of `true`):** Set `currentSyncStartedAt` only when - transitioning **from** `false`. Track the previous mirror value - inside the `.sink` to detect the edge — the existing - `syncStateCancellable` `.sink` is the right place. -- **Cooldown skip:** `result.cooldownSkip == true` events do NOT update - `lastSyncDuration` (zeros are not signal). The ticker stops on the - edge regardless. No noisy log spam: emit the cooldown-skip log only - on the first one in a row; suppress contiguous skips. - -### S5. Console log - -In `handleShieldedSyncEvent`, when `result.success && !result.cooldownSkip`, -emit one line via `SDKLogger.log(.medium)`: - -``` -Shielded sync done pass= elapsed= rate= scanned= new= spent= balance= -``` - -- `.medium` (not `.high`) so devnet operators on default presets see - these without changing log settings. -- `rate` is suppressed when `elapsed ≈ 0` or `scanned == 0`. -- Format kept stable for `xcrun simctl spawn log` scraping. - -Skipped (cooldown) passes log a single `"Shielded sync skipped (cooldown)"` -at `.medium`; contiguous skips suppressed. - -Started passes log `"Shielded sync started"` at `.medium` on the -false → true edge. Paired with the "done" line for offline analysis. - -### S6. UI - -Inside the existing "ZK Shielded Sync Status" section, immediately -under the existing "Queries Since Launch" row (and above the badges): - -**While `isSyncing` is true AND `currentSyncElapsed != nil`:** -``` -Syncing… elapsed: 4.2 s -``` - -**While `isSyncing` is false AND `lastSyncDuration != nil`:** -``` -Last sync duration: 12.4 s -``` - -**Otherwise:** no row (clean state pre-first-sync, matches existing -"Not synced yet" behaviour). - -Layout: same `HStack { Text(label) ; Spacer() ; Text(value).monospacedDigit() }` -pattern as the surrounding rows. Mono digits keep the number readable -as it ticks. - -The existing `ProgressView()` + "Syncing..." spinner stays unchanged — -it's the qualitative "is something happening" affordance; the new row -is the quantitative "how long has it been going" affordance. - -### S7. Reset sites - -All three private/published fields nil'd in: -- `bind()` — before the new bind runs (so post-bind sync gets a clean - baseline, not stale from a prior wallet). -- `reset()` — full teardown. -- `clearLocalState` — global clear. -- True → false edge — both `currentSyncStartedAt` and - `currentSyncElapsed` nil'd (S4). - -### S8. What this does NOT change - -- No new FFI signatures. -- No changes to `rs-platform-wallet-ffi` or `rs-platform-wallet`. -- No changes to `PlatformWalletManager`. -- No SwiftData schema change. -- No new view / screen / sheet / menu entry. - -## Known limitation: catch-up vs per-pass - -At N=1M, the manager loop runs MANY internal passes during initial -catch-up — each is one `ShieldedSyncEvent`. We measure **per-pass** -wall-clock. The user-facing reality is that for an initial catch-up -the "Last sync duration" they see at the end will be the **last -pass**'s time (likely a few seconds — the trailing partial chunk), -NOT the whole catch-up's 10 minutes. - -This is acceptable for the live use case (the live ticker covers the -"is it alive" question), but means the post-hoc number reads smaller -than the user's perceived wall-clock for the first catch-up. The -**console log** mitigates by recording every pass — sum from the log -to get total catch-up time. - -A proper "catch-up completed" signal would require either: -- A Rust-side signal `next_start_index == tree_size` exposed as a - derived `isCaughtUp` event, OR -- Synthesizing it Swift-side from `event.totalScanned` + a tree-size - read. - -Both expand the scope materially. Deferred. The current per-pass -number + live ticker covers the primary use case ("is it alive, how -long is each pass taking"). - -## Architecture conformance (`swift-sdk/CLAUDE.md`) - -- ✅ Persist / load / bridge only. Timing fields are display state - derived from existing Combine publishers — no business logic, no - decisions, no orchestration. -- ✅ No new FFI surface. -- ✅ Timer is a UI-driving mechanism, not a policy loop. The decision - to keep syncing lives on the Rust side; the timer just animates - `currentSyncElapsed` for the view. - -## Test plan - -1. **Smoke (local dashmate devnet):** - - Bind a wallet; observe one sync pass. - - UI shows "Syncing… elapsed: X.Xs" with X ticking visibly. - - On completion: UI shows "Last sync duration: Y.Ys" with Y a - reasonable positive value. - - Console: paired "started" + "done" log lines at `.medium`. - -2. **Reset / clear behaviour:** - - After a successful sync, hit "Clear". UI row disappears. - - Hit "Sync Now". Row reappears live, then settles to post-hoc. - -3. **Cooldown skip:** - - After steady-state, force a cooldown-skip pass. UI does not - update `lastSyncDuration` (stays at the prior value). Console - shows one "skipped (cooldown)" line and no further skips until - the next real pass. - -4. **Failure path (offline):** - - Disconnect the gateway, hit Sync Now. Confirm `lastSyncDuration` - is NOT updated by the failed pass. `currentSyncStartedAt` IS - cleared (verified indirectly: next successful pass shows correct - duration, not absurdly inflated). - -5. **N=1M devnet (validation):** point the app at - `dashpay/drive:3.1-shielded.2` running on devnet. Bind a fresh - wallet: - - Live ticker increments visibly through the long initial pass. - - At the end, console log lines sum to the user-perceived wall-clock. - - Each subsequent pass shows a small (seconds) duration. - -## File touch list - -### Surface 1 (timing, permanent) - -- `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift` - - +1 private field (`currentSyncStartedAt`), +2 `@Published` - fields (`lastSyncDuration`, `currentSyncElapsed`). - - +1 Timer (`syncTickTimer: Timer?`). - - +1 previous-mirror state on the existing `syncStateCancellable.sink` - to detect false → true edges. - - Reset sites (`bind`, `reset`, `clearLocalState`) get the new - fields nil'd. - - `handleShieldedSyncEvent` logs at `.medium` on success path; - cooldown-skip path emits one suppressed log + leaves duration - alone. -- `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift` - - +1 inline row in the existing "ZK Shielded Sync Status" section - (≈ line 439 area, alongside "Queries Since Launch"). - -### Surface 2 (raw-seed test wallet bind, TEMPORARY) - -All marked with the same removal TODO tag. - -- `packages/rs-platform-wallet-ffi/src/shielded_sync.rs` - - +1 new FFI entry - `platform_wallet_manager_bind_shielded_with_raw_seed` - alongside the existing `platform_wallet_manager_bind_shielded`. - Same parameters EXCEPT replaces `mnemonic_resolver_handle` with - `seed_bytes: *const u8 + seed_len: usize`. -- `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedBind.swift` (or wherever `bindShielded` lives) - - +1 thin wrapper method - `bindShieldedRawSeed(walletId:rawSeed:accounts:)` that calls - the new FFI. -- `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift` - - +1 button "Bind Test Wallet A (Shielded)" inside the ZK - Shielded Sync Status section. Hardcodes `[0x73; 32]` and calls - the new wrapper. Button is visible only when the active wallet - has no shielded binding (matches the existing "Sync Now" - affordance's gating). - -### Diff size estimate - -- Surface 1 (timing): ~60 lines additive. -- Surface 2 (raw-seed test wallet): ~70 lines additive. -- Total: ~130 lines, no removals, no API drift to existing - functionality. From c4125c98bd04ef5e29a4b41b753195e09b28bb97 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 06:24:13 +0700 Subject: [PATCH 065/113] docs: encrypt the 69-byte compact xpub in the contact-request guide (#5087) Co-authored-by: Claude Opus 5.5 --- book/src/evo-sdk/dashpay-contact-requests.md | 19 +++++++-- .../src/contact_info.rs | 3 +- .../examples/shielded_sync_paloma.rs | 3 +- .../src/wallet/asset_lock/manager.rs | 2 +- .../src/wallet/asset_lock/sync/proof.rs | 2 +- .../src/wallet/asset_lock/sync/recovery.rs | 2 +- .../wallet/identity/crypto/contact_info.rs | 5 ++- .../identity/network/contact_requests.rs | 39 +++++++------------ .../src/wallet/identity/network/tokens/mod.rs | 3 +- .../state/managed_identity/dashpay.rs | 7 ++-- .../tests/shielded_decrypt_bench.rs | 2 +- 11 files changed, 46 insertions(+), 41 deletions(-) diff --git a/book/src/evo-sdk/dashpay-contact-requests.md b/book/src/evo-sdk/dashpay-contact-requests.md index beaf9e0953e..10f1a88081d 100644 --- a/book/src/evo-sdk/dashpay-contact-requests.md +++ b/book/src/evo-sdk/dashpay-contact-requests.md @@ -31,7 +31,15 @@ The current DashPay contract schema requires the system field `encryptedPublicKey` is exactly 96 bytes: - 16 bytes: AES-CBC initialization vector -- 80 bytes: AES-CBC ciphertext for the sender's 78-byte serialized contact xpub +- 80 bytes: AES-CBC ciphertext for the sender's 69-byte compact contact xpub + +The compact xpub is the parent fingerprint (4 bytes), chain code (32 bytes) +and public key (33 bytes), without the version, depth and child number of a +full 78-byte BIP32 serialization. Receivers, including the reference mobile +wallets and the Rust, Swift and Kotlin SDKs, refuse any other plaintext +length. Both 69 and 78 bytes pad to the same 80-byte ciphertext, so the +contract cannot catch the mistake: a request that encrypts the full 78 bytes +is stored on chain, and the recipient's wallet then drops it. The sender derives the contact xpub from the sender identity, recipient identity, account, and address index. The sender then encrypts that xpub with an @@ -99,14 +107,17 @@ function deriveSharedKey({ return crypto.createHash('sha256').update(Buffer.concat([compressedPrefix, x])).digest(); } -function serializedXpubPayload(xpub: string): Buffer { +function compactXpubPayload(xpub: string): Buffer { const payload = dashcore.encoding.Base58Check.decode(xpub); if (payload.length !== 78) { throw new Error(`Invalid DashPay contact xpub length: ${payload.length}`); } - return payload; + // BIP32 layout: version(4) depth(1) parentFingerprint(4) childNumber(4) + // chainCode(32) publicKey(33). DIP-15 encrypts only the parent + // fingerprint, chain code and public key: 69 bytes. + return Buffer.concat([payload.subarray(5, 9), payload.subarray(13, 78)]); } function encryptContactXpub({ @@ -122,7 +133,7 @@ function encryptContactXpub({ privateKeyWif: senderEncryptionPrivateKeyWif, publicKeyBytes: recipientDecryptionPublicKeyBytes, }); - const payload = serializedXpubPayload(contactXpub); + const payload = compactXpubPayload(contactXpub); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv); const encrypted = Buffer.concat([ diff --git a/packages/rs-platform-encryption/src/contact_info.rs b/packages/rs-platform-encryption/src/contact_info.rs index 48564c93143..bf24b8686b9 100644 --- a/packages/rs-platform-encryption/src/contact_info.rs +++ b/packages/rs-platform-encryption/src/contact_info.rs @@ -39,7 +39,8 @@ pub fn decrypt_enc_to_user_id(key: &[u8; 32], ciphertext: &[u8; 32]) -> [u8; 32] out } -/// Encrypt a `contactInfo.privateData` plaintext (CBOR bytes) as +/// Encrypt a `contactInfo.privateData` plaintext (the DIP-15 var-int +/// encoding, built by the wallet's `encode_private_data`) as /// `IV(16) ‖ AES-256-CBC(plaintext)` — the same prepended-IV layout /// `encryptedPublicKey` uses (DIP-15 doesn't pin the layout for this /// field; we adopt the same convention). diff --git a/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs b/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs index f015a3613ba..762280b0eb0 100644 --- a/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs +++ b/packages/rs-platform-wallet/examples/shielded_sync_paloma.rs @@ -247,8 +247,7 @@ async fn main() { hex::encode(platform_wallet.wallet_id()) ); - // --- 4. Bind shielded account 0 with the raw seed (mirrors the - // iOS `bindShieldedRawSeed` path; same FFI shape). --- + // --- 4. Bind shielded account 0 with the raw seed. --- let coordinator = manager .shielded_coordinator() .await diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs index 7bd6fdc0e8a..bd3c06f8637 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs @@ -36,7 +36,7 @@ pub struct AssetLockManager { pub(super) wallet_manager: Arc>>, /// Identifies which wallet within the manager this manager operates on. pub(super) wallet_id: WalletId, - /// Notified on InstantLock / ChainLock events by SpvEventForwarder. + /// Notified on InstantLock / ChainLock events by `LockNotifyHandler`. /// Used by `wait_for_proof()` and `wait_for_chain_lock()`. pub(super) lock_notify: Arc, /// Transaction broadcaster — pluggable so the same `AssetLockManager` diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs index 5a2eb7fc4b3..e7d4cdbc2c2 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs @@ -994,7 +994,7 @@ impl AssetLockManager { /// /// Wait for an asset lock proof by checking transaction context state. /// - /// Wakes on `lock_notify` (fired by `SpvEventForwarder` on InstantLock / + /// Wakes on `lock_notify` (fired by `LockNotifyHandler` on InstantLock / /// ChainLock events) and re-checks the transaction record context. /// /// Returns a properly-constructed `AssetLockProof` on success, or diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index e4fc8302234..7e886ea7da6 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -2682,7 +2682,7 @@ mod tests { /// rebuild that fails at input selection is direct proof the funding /// reservation is still held. signer: crate::test_support::WalletSigner, - /// The handle `SpvEventForwarder` fires on IS/ChainLock events, so + /// The handle `LockNotifyHandler` fires on IS/ChainLock events, so /// a test can wake an in-flight proof wait the way the live wallet /// does. lock_notify: Arc, diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs index 63b07a87f11..89047507b1a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/contact_info.rs @@ -137,8 +137,9 @@ pub struct ContactInfoPrivateData { pub alias_name: Option, /// Free-form note. pub note: Option, - /// Whether the contact is hidden / ignored (DIP-15 `displayHidden` — the - /// hide flag, also the cross-device ignore signal). + /// Whether the contact is hidden (DIP-15 `displayHidden`). This hides an + /// established contact; it is not the ignore feature, which is local to + /// each device and covers senders who are not contacts. pub display_hidden: bool, /// Accepted rotated account-references of an established contact (DIP-15 /// `acceptedAccounts`). Empty until multi-account is populated. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 9486dca9bf5..e2575529af9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -1908,33 +1908,24 @@ impl DashPayView<'_, B> { out } - /// Build the two DashPay accounts for one established contact, - /// applying the transient/permanent failure policy. + /// Queue the two DashPay account builds for one established contact. /// - /// Order: - /// 1. Register the `DashpayReceivingFunds` account — derivable from our - /// own seed, no decryption needed. This is what makes *incoming* - /// contact payments visible to SPV; restore-from-seed leaves it - /// unbuilt, so the sweep rebuilds it for every established contact. - /// 2. Fetch the counterparty identity and **validate** the request's - /// key indices via [`validate_contact_request`] BEFORE any ECDH — - /// an attacker-crafted index pointing at an AUTHENTICATION key would - /// otherwise derive a wrong shared secret and poison the account. - /// 3. Register the `DashpayExternalAccount` (decrypt + ECDH). + /// The recurring sweep runs without a signer, so it cannot derive the + /// receiving (friendship) xpub or the ECDH secret itself. It enqueues + /// both ops for the signer-backed drain + /// ([`Self::drain_pending_contact_crypto_verified`]), which registers the + /// `DashpayReceivingFunds` account, fetches the counterparty, validates + /// the request's key indices before any ECDH, and registers the + /// `DashpayExternalAccount`. Transient failures stay queued for the next + /// drain; permanent ones mark the contact `payment_channel_broken`. /// - /// Failure policy: - /// - **Transient** (identity fetch / network): logged, left for the - /// next sweep to retry. The broken flag stays clear. - /// - **Permanent** (validation failure, decrypt/decode failure): the - /// contact is marked `payment_channel_broken` so subsequent sweeps - /// skip it until a superseding request arrives. + /// Identities that are not ours to build (no `identity_index`) are + /// skipped and logged. Enqueueing is idempotent per (owner, contact, + /// kind), so calling this every sweep is a no-op until the drain clears + /// the entries. /// - /// Watch-only / seedless wallets (no `identity_index`) are skipped and - /// logged — the watch-only ECDH path (host-side signing hook) lands - /// later. - /// - /// Called **after** the sync write guard is dropped: the register - /// functions re-acquire the non-reentrant wallet-manager lock. + /// Called **after** the sync write guard is dropped: this re-acquires + /// the non-reentrant wallet-manager lock. async fn build_contact_accounts( &self, identity_id: &Identifier, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mod.rs index 84942b02e8c..e68496838f9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/mod.rs @@ -5,7 +5,8 @@ //! is an identity-as-actor operation, so it lives here next to the rest of //! the identity-lifecycle and DashPay surface (same precedent as the //! DashPay merge described in the parent `mod.rs`). Token *bookkeeping* -//! (watch / sync / balance) stays on [`crate::wallet::tokens::TokenWallet`] +//! (watch / sync / balance) lives on +//! [`IdentitySyncManager`](crate::manager::identity_sync::IdentitySyncManager) //! because it's wallet-scoped, not identity-scoped. mod burn; diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index fb981b0ebd4..fdb698f7cbf 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -150,9 +150,10 @@ pub struct DashPayState { /// on-chain ciphertext + public-key indices; each entry still carries its /// `owner_identity_id` (== this identity) as the drain's routing key and the /// SQLite key column. In-memory only for the live session: the queue is - /// persisted to the changeset (SQLite backend), but cold-load restore is - /// blocked upstream, so a re-imported wallet re-syncs from scratch and the - /// sweep re-enqueues what it needs. Deliberately NOT captured by + /// persisted to the changeset (SQLite backend), but `load()` does not + /// restore it (nothing blocks that any more; see the storage crate's + /// `pending_contact_crypto` reader), so after a restart the sweep + /// re-enqueues what it needs. Deliberately NOT captured by /// `IdentityEntry::from_managed` — persistence rides the flat changeset /// delta, not a per-identity snapshot. /// See [`PendingContactCrypto`](crate::changeset::PendingContactCrypto). diff --git a/packages/rs-platform-wallet/tests/shielded_decrypt_bench.rs b/packages/rs-platform-wallet/tests/shielded_decrypt_bench.rs index 423f0cdb9fc..1be9af56b95 100644 --- a/packages/rs-platform-wallet/tests/shielded_decrypt_bench.rs +++ b/packages/rs-platform-wallet/tests/shielded_decrypt_bench.rs @@ -33,7 +33,7 @@ use rayon::prelude::*; use platform_wallet::wallet::shielded::keys::OrchardKeySet; const ENCRYPTED_NOTE_WIRE_LEN: usize = 216; -const SEED_BENCH: [u8; 32] = [0x73; 32]; // matches SEED_A in drive-abci's seeder +const SEED_BENCH: [u8; 32] = [0x73; 32]; // same seed as SEED_A in examples/shielded_sync.rs /// Generate `count` filler `ShieldedEncryptedNote`s with random bytes /// matching the on-chain wire layout. Deterministic given `rng_seed`. From e0937ce1fd1be35b663f413987fb63242cca48c8 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 06:25:28 +0700 Subject: [PATCH 066/113] feat(kotlin-sdk)!: new propertyConstraints read kinds and system reads in the Kotlin SDK and Android example app (#5097) Co-authored-by: Claude Opus 5.5 --- .../ui/contracts/DocumentTypeDetailsScreen.kt | 17 +- .../ui/contracts/PropertyConstraints.kt | 18 ++ .../ui/contracts/PropertyConstraintsTest.kt | 49 ++++++ .../dashsdk/ffi/QueriesNative.kt | 10 +- .../queries/DocumentPropertyConstraints.kt | 82 +++++++-- .../dashsdk/queries/PlatformQueries.kt | 12 +- .../DocumentPropertyConstraintsTest.kt | 164 +++++++++++++++++- 7 files changed, 337 insertions(+), 15 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt index 6038ecc9636..9c150513ca4 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt @@ -404,13 +404,15 @@ private fun PropertyConstraintsFormSection(section: PropertyConstraintsSection) /** * One `propertyConstraints` rule: its name, the rule as declared, what it - * reads, and whether an owner change is judged against it too + * reads, the system times and heights it reads, and whether an owner change + * is judged against it too * (← `PropertyConstraintRowView` in DocumentTypeDetailsView.swift). */ @Composable private fun PropertyConstraintRow(rule: DocumentPropertyConstraint) { val prettyRule = remember(rule) { rule.prettyRuleJson } val reads = remember(rule) { propertyConstraintReadsText(rule) } + val systemReads = remember(rule) { propertyConstraintSystemReadsText(rule) } Column( modifier = Modifier .fillMaxWidth() @@ -446,6 +448,19 @@ private fun PropertyConstraintRow(rule: DocumentPropertyConstraint) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + if (systemReads != null) { + Text( + systemReads, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.testTag("documentType.propertyConstraint.${rule.name}.readsSystem"), + ) + Text( + PROPERTY_CONSTRAINT_SYSTEM_READS_NOTE, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } if (rule.readsOwner) { Text( "Reads \$ownerId, the document's owner: transfers and purchases are judged " + diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt index 24b89091beb..47da8df01a7 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt @@ -131,6 +131,24 @@ internal const val NATIVE_LIBRARY_PREDATES_RULES = internal fun propertyConstraintReadsText(rule: DocumentPropertyConstraint): String = rule.reads.distinct().joinToString(", ") { "${it.path} (${it.kind.name})" } +/** + * The system times and heights [rule] reads, repeats dropped, as the line + * shown under it: `Reads $createdAt, $updatedAt`. `null` for a rule reading + * none. + */ +internal fun propertyConstraintSystemReadsText(rule: DocumentPropertyConstraint): String? = + rule.readsSystem.distinct().takeIf { it.isNotEmpty() }?.joinToString(", ", prefix = "Reads ") + +/** + * The note under a rule reading a system time or height + * ([propertyConstraintSystemReadsText] not `null`): which writes besides a + * create or a replace consensus judges against such a rule. Only a reminder + * of the protocol's behaviour, the same for every such rule: Rust decides. + */ +internal const val PROPERTY_CONSTRAINT_SYSTEM_READS_NOTE = + "A price update is judged against the rules reading \$updatedAt or its block heights, " + + "and a transfer or purchase against those reading \$transferredAt or its block heights." + /** Title of the alert a broken rule raises instead of a broadcast. */ internal const val PROPERTY_CONSTRAINT_BROKEN_TITLE = "Not sent: a property constraint is broken" diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt index d801d71618f..98f7b410e5f 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt @@ -12,6 +12,7 @@ import org.dashfoundation.dashsdk.queries.PropertyConstraintViolation import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test @@ -48,6 +49,15 @@ class PropertyConstraintsTest { readsOwner = true, ) + /** A rule reading system times and heights, one twice, as Rust lists them. */ + private val timedRule = DocumentPropertyConstraint( + name = "settledAfterTransfer", + ruleJson = """{"greaterThan":["${'$'}updatedAt","${'$'}transferredAtCoreBlockHeight"]}""", + reads = emptyList(), + readsOwner = false, + readsSystem = listOf("${'$'}updatedAt", "${'$'}transferredAtCoreBlockHeight", "${'$'}updatedAt"), + ) + private val violation = PropertyConstraintViolation( rule = "perUnitFee", violation = PropertyConstraintViolation.Kind.DivisionByZero, @@ -208,6 +218,45 @@ class PropertyConstraintsTest { @Test fun `should list each read once with its kind`() { assertEquals("sellerId (presence), sellerId (identifier)", propertyConstraintReadsText(rule)) + assertEquals( + "title (length), tags (count), labels (elements)", + propertyConstraintReadsText( + DocumentPropertyConstraint( + name = "sizes", + ruleJson = "{}", + reads = listOf( + PropertyConstraintRead("title", PropertyConstraintRead.Kind.Length), + PropertyConstraintRead("tags", PropertyConstraintRead.Kind.Count), + PropertyConstraintRead("labels", PropertyConstraintRead.Kind.Elements), + ), + readsOwner = false, + ), + ), + ) + } + + @Test + fun `should list each system value a rule reads once`() { + assertEquals( + "Reads ${'$'}updatedAt, ${'$'}transferredAtCoreBlockHeight", + propertyConstraintSystemReadsText(timedRule), + ) + } + + @Test + fun `should show no system line for a rule reading none`() { + assertNull(propertyConstraintSystemReadsText(rule)) + } + + /** The note under a rule reading a system value; the SwiftExampleApp shows the same text. */ + @Test + fun `should say which writes the update and transfer times answer to`() { + assertEquals( + "A price update is judged against the rules reading ${'$'}updatedAt or its block " + + "heights, and a transfer or purchase against those reading ${'$'}transferredAt or " + + "its block heights.", + PROPERTY_CONSTRAINT_SYSTEM_READS_NOTE, + ) } @Test diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt index 7c1513a4ed6..e2985ed4916 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt @@ -75,7 +75,10 @@ internal object QueriesNative { /** * The `propertyConstraints` rules (protocol version 14) of [documentType] * as a JSON array in name order, each - * `{"name", "rule", "reads": [{"path", "kind"}], "readsOwner"}`. + * `{"name", "rule", "reads": [{"path", "kind"}], "readsOwner", "readsSystem"}`: + * `kind` is `value`, `presence`, `text`, `identifier`, `length`, `count` + * or `elements`, and `readsSystem` names the system times and heights the + * rule reads (`$createdAt`, `$updatedAtBlockHeight`, ...). * [serializedContract] is the contract's platform serialization (what * [dataContractFetchWithSerialization] returns), read by Rust at the SDK's * protocol version; no network call. Throws on error (unknown document @@ -92,7 +95,10 @@ internal object QueriesNative { * as `{"rule", "violation", "message"}`, or the JSON text `null` when it * meets every rule. [propertiesJson] is what the create would send and * [ownerId] the 32-byte owner `$ownerId` reads; [serializedContract] as - * for [dataContractGetPropertyConstraints]. No network call. Throws on + * for [dataContractGetPropertyConstraints]. The device clock stands in for + * the block time the create records (`$createdAt`, `$updatedAt`, + * `$transferredAt`), and a rule reading a block height is not judged, the + * height being unknown until the block. No network call. Throws on * error (as above, plus an owner id that is not 32 bytes or properties * that are not a JSON object). */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt index d8e87f6dc81..f54e7e30c93 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt @@ -15,7 +15,9 @@ import org.dashfoundation.dashsdk.errors.DashSdkError * named condition every created or replaced document's properties must meet. * Consensus checks every rule, in name order, and refuses a document breaking * one with `DocumentPropertyConstraintViolatedError` (code 10422); a refused - * state transition is still paid for. + * state transition is still paid for. A transfer, a purchase or a price update + * is judged against the rules reading what it changes ([readsOwner], + * [readsSystem]). * * Rust parses the rules and reports them * (`dash_sdk_data_contract_get_property_constraints`, through @@ -35,8 +37,8 @@ data class DocumentPropertyConstraint( val ruleJson: String, /** * Every property the rule reads, in declared order, a property read twice - * listed twice. `$ownerId` is no property and is not listed: see - * [readsOwner]. + * listed twice. `$ownerId` and the system times and heights are no + * properties and are not listed: see [readsOwner] and [readsSystem]. */ val reads: List, /** @@ -45,6 +47,25 @@ data class DocumentPropertyConstraint( * too. */ val readsOwner: Boolean, + /** + * The system times and heights the rule reads, by name, in declared order, + * one read twice listed twice: `$createdAt`, `$updatedAt` and + * `$transferredAt` (a block time in milliseconds), each also with + * `BlockHeight` or `CoreBlockHeight` appended (the Platform or the Core + * block height). The names are wasm-dpp2's + * `PropertyConstraintSystemProperty`. A rule reads only the ones its + * document type records, by listing them in `required`. + * + * Consensus judges a price update against the rules reading the update's + * (`$updatedAt...`), and a transfer or a purchase against those reading + * the transfer's (`$transferredAt...`) or the owner ([readsOwner]). This + * list only names what a rule reads; Rust decides which writes a rule + * answers to. + * + * Empty for a rule reading none, and for every rule when the native + * library predates the field. + */ + val readsSystem: List = emptyList(), ) { /** [ruleJson] indented for display, or [ruleJson] itself should it not parse back. */ val prettyRuleJson: String @@ -57,7 +78,9 @@ data class DocumentPropertyConstraint( companion object { /** * Decode the JSON array `dash_sdk_data_contract_get_property_constraints` - * returns, keeping its order (name order). + * returns, keeping its order (name order). A rule without + * `readsSystem`, from a native library built before it, reads as + * reading no system value. * * @throws DashSdkError.SerializationError for text that is not such an array. */ @@ -72,7 +95,10 @@ data class DocumentPropertyConstraint( val declaration = rule?.get("rule") val reads = rule?.get("reads") as? JsonArray val readsOwner = rule?.get("readsOwner")?.jsonBooleanOrNull() - if (name == null || declaration == null || reads == null || readsOwner == null) { + val readsSystem = rule?.let(::readsSystemOf) + if (name == null || declaration == null || reads == null || readsOwner == null || + readsSystem == null + ) { throw DashSdkError.SerializationError("Malformed propertyConstraints rule: $entry") } DocumentPropertyConstraint( @@ -80,9 +106,20 @@ data class DocumentPropertyConstraint( ruleJson = PropertyConstraintJson.compact(declaration), reads = reads.map(PropertyConstraintRead::fromJson), readsOwner = readsOwner, + readsSystem = readsSystem, ) } } + + /** + * The names [rule]'s `readsSystem` lists: empty when the key is + * missing, `null` when it is anything but an array of strings. + */ + private fun readsSystemOf(rule: JsonObject): List? { + val value = rule["readsSystem"] ?: return emptyList() + val names = value as? JsonArray ?: return null + return names.map { it.jsonStringOrNull() ?: return null } + } } } @@ -117,7 +154,28 @@ data class PropertyConstraintRead( override val name: String get() = "identifier" } - /** A kind this build does not know, by its name. */ + /** + * By its size, in a `length` operand (its characters) or a + * `byteLength` operand (its UTF-8 bytes): a string property. + */ + data object Length : Kind { + override val name: String get() = "length" + } + + /** + * By its size, in a `count` operand: an array property's items, or a + * byte array property's bytes. + */ + data object Count : Kind { + override val name: String get() = "count" + } + + /** By its elements, which a `contains` looks among: a typed array property. */ + data object Elements : Kind { + override val name: String get() = "elements" + } + + /** A kind this build does not know, by its name: one a later native library reports. */ data class Other(override val name: String) : Kind companion object { @@ -127,6 +185,9 @@ data class PropertyConstraintRead( Presence.name -> Presence Text.name -> Text Identifier.name -> Identifier + Length.name -> Length + Count.name -> Count + Elements.name -> Elements else -> Other(name) } } @@ -150,10 +211,11 @@ data class PropertyConstraintRead( * report it in `DocumentPropertyConstraintViolatedError` (code 10422). * * Rust judges the document (`dash_sdk_data_contract_check_property_constraints`, - * through [Contracts.checkPropertyConstraints]) with the check consensus runs; - * this type only carries the verdict. The fields mirror wasm-dpp2's - * `DocumentPropertyConstraintViolation` and the Swift SDK's - * `PropertyConstraintViolation`. + * through [Contracts.checkPropertyConstraints]) with the check consensus runs, + * the device clock standing in for the times the create records and a rule + * reading a block height left unjudged; this type only carries the verdict. + * The fields mirror wasm-dpp2's `DocumentPropertyConstraintViolation` and the + * Swift SDK's `PropertyConstraintViolation`. */ data class PropertyConstraintViolation( /** The broken rule's name. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt index 621364b45ea..530dd266ab2 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt @@ -666,6 +666,11 @@ class Contracts internal constructor(private val sdk: Sdk) { * declaring none, and for every type while this SDK's protocol version is * below 14. Port of Swift's `SDK.documentPropertyConstraints`. * + * Each rule lists the properties it reads and how + * ([DocumentPropertyConstraint.reads]), whether it reads the owner + * ([DocumentPropertyConstraint.readsOwner]) and the system times and + * heights it reads ([DocumentPropertyConstraint.readsSystem]). + * * [serializedContract] is the contract's platform serialization, the bytes * kept beside a fetched contract ([ContractWithSerialization.binarySerialization], * `DataContractEntity.binarySerialization`). Rust reads it at this SDK's @@ -699,7 +704,12 @@ class Contracts internal constructor(private val sdk: Sdk) { * with (what `DocumentTransactions.create` takes) and [ownerId] the 32-byte * identity that would own it, which `$ownerId` reads. Rust builds the * document the create path builds and judges it with the check consensus - * runs; nothing but the rules is checked. [serializedContract] is as for + * runs; nothing but the rules is checked. The device clock stands in for + * the block time the create records (`$createdAt`, `$updatedAt`, + * `$transferredAt`), so a rule comparing one is judged as of now; a rule + * reading a block height (`$createdAtBlockHeight`, ...) is not judged, + * since the height is unknown until the block, and consensus may still + * refuse the document for it. [serializedContract] is as for * [propertyConstraints]; no network call. * * @throws DashSdkError.InvalidParameter for empty contract bytes, an owner diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt index d053d150ab5..24363803a8e 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt @@ -36,6 +36,7 @@ class DocumentPropertyConstraintsTest { { "kind": "text", "path": "status" }, { "kind": "presence", "path": "closedAt" } ], + "readsSystem": [], "rule": { "anyOf": [ { "notEqual": ["status", { "const": "closed" }] }, @@ -50,6 +51,7 @@ class DocumentPropertyConstraintsTest { { "kind": "value", "path": "price" }, { "kind": "value", "path": "fee" } ], + "readsSystem": [], "rule": { "greaterThanOrEqual": [{ "divide": ["price", "fee"] }, 1] } }, { @@ -59,6 +61,7 @@ class DocumentPropertyConstraintsTest { { "kind": "presence", "path": "sellerId" }, { "kind": "identifier", "path": "sellerId" } ], + "readsSystem": [], "rule": { "anyOf": [{ "absent": "sellerId" }, { "equal": ["sellerId", "${'$'}ownerId"] }] } @@ -66,6 +69,82 @@ class DocumentPropertyConstraintsTest { ] """.trimIndent() + /** + * Rules reading sizes and the elements of an array, as the FFI reports + * them (wasm-dpp2's `DocumentPropertyConstraints.spec.ts` holds the same + * rules): a `byteLength` operand reads a string's size, a `count` operand + * an array's items, and a `contains` the array it looks in. + */ + private val sizeAndElementRulesJson = """ + [ + { + "name": "notUsed", + "readsOwner": false, + "reads": [{ "kind": "elements", "path": "labels" }], + "readsSystem": [], + "rule": { "not": { "contains": ["labels", { "const": "used" }] } } + }, + { + "name": "tagsWithinLimit", + "readsOwner": false, + "reads": [ + { "kind": "count", "path": "tags" }, + { "kind": "value", "path": "maxTags" } + ], + "readsSystem": [], + "rule": { "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] } + }, + { + "name": "titleBytes", + "readsOwner": false, + "reads": [{ "kind": "length", "path": "title" }], + "readsSystem": [], + "rule": { "lessThanOrEqual": [{ "byteLength": "title" }, 12] } + } + ] + """.trimIndent() + + /** + * Rules reading system times and heights (rs-sdk-ffi's + * `should_read_the_clock_for_system_times_and_skip_block_heights`), plus + * one reading an update time twice and a Core height, in declared order. + */ + private val systemRulesJson = """ + [ + { + "name": "endsAfterCreation", + "readsOwner": false, + "reads": [{ "kind": "value", "path": "endsAt" }], + "readsSystem": ["${'$'}createdAt"], + "rule": { "greaterThan": ["endsAt", "${'$'}createdAt"] } + }, + { + "name": "listedAfterHeight10", + "readsOwner": false, + "reads": [], + "readsSystem": ["${'$'}createdAtBlockHeight"], + "rule": { "greaterThanOrEqual": ["${'$'}createdAtBlockHeight", 10] } + }, + { + "name": "settledAfterTransfer", + "readsOwner": false, + "reads": [], + "readsSystem": [ + "${'$'}updatedAt", + "${'$'}transferredAtCoreBlockHeight", + "${'$'}updatedAt" + ], + "rule": { + "allOf": [ + { "greaterThan": ["${'$'}updatedAt", 0] }, + { "greaterThan": ["${'$'}transferredAtCoreBlockHeight", 0] }, + { "lessThan": ["${'$'}updatedAt", 4102444800000] } + ] + } + } + ] + """.trimIndent() + // Rules @Test @@ -89,6 +168,62 @@ class DocumentPropertyConstraintsTest { rules[2].reads.map { it.kind }, ) assertEquals(listOf(false, false, true), rules.map { it.readsOwner }) + assertEquals(List(3) { emptyList() }, rules.map { it.readsSystem }) + } + + @Test + fun `should decode size and element reads as length count and elements`() { + val rules = DocumentPropertyConstraint.listFromJson(sizeAndElementRulesJson) + + assertEquals(listOf("notUsed", "tagsWithinLimit", "titleBytes"), rules.map { it.name }) + assertEquals( + listOf(PropertyConstraintRead("labels", PropertyConstraintRead.Kind.Elements)), + rules[0].reads, + ) + assertEquals( + listOf( + PropertyConstraintRead("tags", PropertyConstraintRead.Kind.Count), + PropertyConstraintRead("maxTags", PropertyConstraintRead.Kind.Value), + ), + rules[1].reads, + ) + assertEquals( + listOf(PropertyConstraintRead("title", PropertyConstraintRead.Kind.Length)), + rules[2].reads, + ) + assertEquals("""{"lessThanOrEqual":[{"byteLength":"title"},12]}""", rules[2].ruleJson) + } + + /** The names come through as Rust reports them, in declared order, a repeat kept. */ + @Test + fun `should decode the system times and heights each rule reads`() { + val rules = DocumentPropertyConstraint.listFromJson(systemRulesJson) + + assertEquals( + listOf( + listOf("${'$'}createdAt"), + listOf("${'$'}createdAtBlockHeight"), + listOf("${'$'}updatedAt", "${'$'}transferredAtCoreBlockHeight", "${'$'}updatedAt"), + ), + rules.map { it.readsSystem }, + ) + assertEquals(listOf(PropertyConstraintRead("endsAt", PropertyConstraintRead.Kind.Value)), rules[0].reads) + assertEquals(emptyList(), rules[1].reads) + assertEquals(listOf(false, false, false), rules.map { it.readsOwner }) + } + + /** A native library built before `readsSystem` leaves the key out. */ + @Test + fun `should read a rule without readsSystem as reading no system value`() { + val rules = DocumentPropertyConstraint.listFromJson( + """[{"name":"r","readsOwner":false,"reads":[],"rule":{"present":"a"}}]""", + ) + + assertEquals(emptyList(), rules.single().readsSystem) + assertEquals( + DocumentPropertyConstraint("r", """{"present":"a"}""", emptyList(), readsOwner = false), + rules.single(), + ) } /** @@ -157,12 +292,15 @@ class DocumentPropertyConstraintsTest { @Test fun `should round trip every read kind name`() { - val names = listOf("value", "presence", "text", "identifier") + val names = listOf("value", "presence", "text", "identifier", "length", "count", "elements") val kinds = listOf( PropertyConstraintRead.Kind.Value, PropertyConstraintRead.Kind.Presence, PropertyConstraintRead.Kind.Text, PropertyConstraintRead.Kind.Identifier, + PropertyConstraintRead.Kind.Length, + PropertyConstraintRead.Kind.Count, + PropertyConstraintRead.Kind.Elements, ) assertEquals(kinds, names.map(PropertyConstraintRead.Kind::fromName)) @@ -191,6 +329,30 @@ class DocumentPropertyConstraintsTest { } } + /** Present, `readsSystem` must be an array of strings; only a missing key means none. */ + @Test + fun `should refuse a malformed readsSystem`() { + val malformed = listOf( + // A single name is not an array + "\"\$createdAt\"", + "null", + "true", + """{"${'$'}createdAt": true}""", + // Each entry must be a string + "[1]", + "[null]", + """["${'$'}createdAt", 7]""", + """[["${'$'}createdAt"]]""", + ) + for (readsSystem in malformed) { + val json = + """[{"name":"r","readsOwner":false,"reads":[],"readsSystem":$readsSystem,"rule":{"present":"a"}}]""" + assertThrows(json, DashSdkError.SerializationError::class.java) { + DocumentPropertyConstraint.listFromJson(json) + } + } + } + // Violations @Test From 789496310516bc421f69d0416f1d869e7d34b515 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 07:17:02 +0700 Subject: [PATCH 067/113] feat(swift-sdk)!: new propertyConstraints read kinds and system reads in the Swift SDK and iOS example app (#5098) Co-authored-by: Claude Opus 5.5 --- .../Utils/DocumentPropertyConstraints.swift | 90 ++++++- .../Models/PersistentDocumentType.swift | 6 +- .../Views/DocumentTypeDetailsView.swift | 24 +- .../SwiftExampleApp/Views/DocumentsView.swift | 7 +- .../DocumentPropertyConstraintsTests.swift | 225 ++++++++++++++++++ 5 files changed, 338 insertions(+), 14 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift index 72ccab1a759..d5855ab503a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift @@ -5,7 +5,10 @@ import Foundation /// version 14): a named condition every created or replaced document's /// properties must meet. Consensus checks every rule, in name order, and /// refuses a document breaking one with `DocumentPropertyConstraintViolatedError` -/// (code 10422); a refused state transition is still paid for. +/// (code 10422); a refused state transition is still paid for. A transfer or a +/// purchase is judged against the rules reading `$ownerId` or the transfer's +/// time or heights too, and a price update against the rules reading the +/// update's (see `readsOwner` and `readsSystem`). /// /// Rust parses the rules and reports them /// (`dash_sdk_data_contract_get_property_constraints`); this type only carries @@ -17,12 +20,16 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { /// The rule exactly as the document type's schema declares it, as compact /// JSON text with sorted keys (every operator object has a single key, so - /// sorting changes nothing a reader would notice). + /// sorting changes nothing a reader would notice). Among its operators: + /// sizes (`{ "length": path }`, `{ "byteLength": path }`, + /// `{ "count": path }`), system times and heights as bare operands + /// (`"$createdAt"`), `{ "contains": [arrayPath, value] }`, + /// `{ "startsWith": [a, b] }` and `{ "endsWith": [a, b] }`. public let ruleJSON: String /// Every property the rule reads, in declared order, a property read twice /// listed twice. `$ownerId` is no property and is not listed: see - /// `readsOwner`. + /// `readsOwner`; nor are the system times and heights: see `readsSystem`. public let reads: [PropertyConstraintRead] /// Whether the rule compares the document's owner, `$ownerId`: then a @@ -30,11 +37,31 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { /// too. public let readsOwner: Bool - public init(name: String, ruleJSON: String, reads: [PropertyConstraintRead], readsOwner: Bool) { + /// The system times and heights the rule reads, by name, in declared + /// order, one read twice listed twice: `$createdAt`, `$updatedAt` and + /// `$transferredAt` (block times, in milliseconds), each also with + /// `BlockHeight` or `CoreBlockHeight` appended (the Platform and Core + /// block heights), the names of wasm-dpp2's + /// `PropertyConstraintSystemProperty`. Consensus judges a price update + /// against the rules reading `$updatedAt…`, and a transfer or a purchase + /// against the rules reading `$transferredAt…` (or `$ownerId`). + /// + /// Empty for a rule reading none, and for every rule reported by a library + /// built before the field existed. + public let readsSystem: [String] + + public init( + name: String, + ruleJSON: String, + reads: [PropertyConstraintRead], + readsOwner: Bool, + readsSystem: [String] = [] + ) { self.name = name self.ruleJSON = ruleJSON self.reads = reads self.readsOwner = readsOwner + self.readsSystem = readsSystem } /// `ruleJSON` indented for display, or `ruleJSON` itself should it not @@ -63,7 +90,8 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { let name = rule["name"] as? String, let declaration = rule["rule"], let reads = rule["reads"] as? [Any], - let readsOwner = DocumentTypedArray.jsonBool(rule["readsOwner"]) + let readsOwner = DocumentTypedArray.jsonBool(rule["readsOwner"]), + let readsSystem = systemReads(rule["readsSystem"]) else { throw SDKError.serializationError("Malformed propertyConstraints rule: \(entry)") } @@ -71,10 +99,31 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { name: name, ruleJSON: try PropertyConstraintJSON.compactText(declaration), reads: try reads.map(PropertyConstraintRead.init(jsonEntry:)), - readsOwner: readsOwner + readsOwner: readsOwner, + readsSystem: readsSystem ) } } + + /// A rule's `readsSystem` names, `[]` when the key is missing (a library + /// built before it), or `nil` for anything but an array of strings. + private static func systemReads(_ value: Any?) -> [String]? { + guard let value else { + return [] + } + guard let entries = value as? [Any] else { + return nil + } + var names: [String] = [] + names.reserveCapacity(entries.count) + for entry in entries { + guard let name = entry as? String else { + return nil + } + names.append(name) + } + return names + } } /// A property a `propertyConstraints` rule reads, and how it reads it. @@ -90,6 +139,16 @@ public struct PropertyConstraintRead: Hashable, Sendable { case text /// By its value, compared with identifiers: an identifier property. case identifier + /// By its size, in a `length` or `byteLength` operand: a string + /// property, measured in characters (as `maxLength` counts them) or + /// in UTF-8 bytes. + case length + /// By its size, in a `count` operand: the items of an array property, + /// or the bytes of a byte array property. + case count + /// By its elements, which a `contains` looks among: a typed array + /// property. + case elements /// A kind this build does not know, by its name. case other(String) @@ -99,6 +158,9 @@ public struct PropertyConstraintRead: Hashable, Sendable { case "presence": self = .presence case "text": self = .text case "identifier": self = .identifier + case "length": self = .length + case "count": self = .count + case "elements": self = .elements default: self = .other(name) } } @@ -110,6 +172,9 @@ public struct PropertyConstraintRead: Hashable, Sendable { case .presence: return "presence" case .text: return "text" case .identifier: return "identifier" + case .length: return "length" + case .count: return "count" + case .elements: return "elements" case let .other(name): return name } } @@ -253,8 +318,8 @@ extension SDK { } /// The first `propertyConstraints` rule a document to create would break, - /// or `nil` when it meets them all (always so while this SDK's protocol - /// version is below 14). + /// or `nil` when it meets every rule judged (always so while this SDK's + /// protocol version is below 14). /// /// `propertiesJSON` is the properties JSON the document would be created /// with (the string handed to `ManagedPlatformWallet.createDocument`) and @@ -264,6 +329,15 @@ extension SDK { /// `serializedContract` is as for `documentPropertyConstraints`. Bridges /// `dash_sdk_data_contract_check_property_constraints`. /// + /// The block the create lands in is not known yet, so Rust estimates its + /// system values: the device clock stands in for the block time the create + /// records as `$createdAt`, `$updatedAt` and `$transferredAt`, and a rule + /// reading a block height (a `readsSystem` name ending in `BlockHeight` + /// or `CoreBlockHeight`) is not judged at all. So `nil` does not promise + /// consensus accepts the document: a rule reading a block height, or a + /// time rule the device clock judges differently from the block time, can + /// still refuse it. + /// /// - Throws: `SDKError.invalidParameter` for an owner id that is not 32 /// bytes or properties that are not a JSON object, `SDKError.notFound` for /// a document type the contract does not declare, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift index 3b79011b461..01cf24268a0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift @@ -177,9 +177,11 @@ extension PersistentDocumentType { /// The first `propertyConstraints` rule a document of this type, created /// with `propertiesJSON` and owned by `ownerId`, would break, or `nil` - /// when it meets them all: what + /// when it meets every rule judged: what /// `SDK.checkDocumentPropertyConstraints(serializedContract:documentType:propertiesJSON:ownerId:)` - /// reports for the parent contract's stored platform serialization. + /// reports for the parent contract's stored platform serialization. As + /// there, the device clock stands in for the create's block time and a + /// rule reading a block height is not judged. /// /// - Throws: `SDKError.invalidState` when the parent contract has no /// stored serialization, or what the SDK call throws. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift index 7c6f83000e6..55a453408f7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift @@ -451,7 +451,8 @@ struct ExpandableIndexRowView: View { } /// One `propertyConstraints` rule: its name, the rule as declared, what it -/// reads, and whether an owner change is judged against it too. +/// reads (properties, `$ownerId`, system times and heights), and whether an +/// owner change is judged against it too. struct PropertyConstraintRowView: View { let rule: DocumentPropertyConstraint @@ -493,8 +494,21 @@ struct PropertyConstraintRowView: View { .font(.caption2) .foregroundColor(.purple) } + + if !systemReadsText.isEmpty { + Label("Reads \(systemReadsText)", systemImage: "clock") + .font(.caption2) + .foregroundColor(.teal) + .accessibilityIdentifier("documentType.propertyConstraint.\(rule.name).readsSystem") + Text("A price update is judged against the rules reading $updatedAt or its block heights, and a transfer or purchase against those reading $transferredAt or its block heights.") + .font(.caption2) + .foregroundColor(.secondary) + } } .padding(.vertical, 4) + // Keeps the row's identifier on the row and the readsSystem line's on + // that line, rather than the row's on every child + .accessibilityElement(children: .contain) .accessibilityIdentifier("documentType.propertyConstraint.\(rule.name)") } @@ -506,6 +520,14 @@ struct PropertyConstraintRowView: View { .map { "\($0.path) (\($0.kind.name))" } .joined(separator: ", ") } + + /// The system times and heights the rule reads, repeats dropped. + private var systemReadsText: String { + var seen = Set() + return rule.readsSystem + .filter { seen.insert($0).inserted } + .joined(separator: ", ") + } } struct PropertyRowView: View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift index 478cc8b8209..b28d5a0633e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift @@ -1834,9 +1834,10 @@ struct CreateDocumentView: View { } /// The first propertyConstraints rule the document would break, or `nil` - /// when it meets them all or has none. A check that cannot run (no SDK, - /// no stored contract serialization) blocks nothing: consensus judges the - /// document either way. + /// when it meets every rule judged or has none (the device clock stands in + /// for the block time, and a rule reading a block height is not judged). + /// A check that cannot run (no SDK, no stored contract serialization) + /// blocks nothing: consensus judges the document either way. private func propertyConstraintViolation( of docType: PersistentDocumentType, propertiesJSON: String, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift index ee29cda741c..e03bcba8282 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift @@ -24,6 +24,7 @@ final class DocumentPropertyConstraintsTests: XCTestCase { { "name": "closedNeedsClosedAt", "readsOwner": false, + "readsSystem": [], "reads": [ { "kind": "text", "path": "status" }, { "kind": "presence", "path": "closedAt" } @@ -38,6 +39,7 @@ final class DocumentPropertyConstraintsTests: XCTestCase { { "name": "perUnitFee", "readsOwner": false, + "readsSystem": [], "reads": [ { "kind": "value", "path": "price" }, { "kind": "value", "path": "fee" } @@ -47,6 +49,7 @@ final class DocumentPropertyConstraintsTests: XCTestCase { { "name": "sellerIsOwner", "readsOwner": true, + "readsSystem": [], "reads": [ { "kind": "presence", "path": "sellerId" }, { "kind": "identifier", "path": "sellerId" } @@ -58,6 +61,85 @@ final class DocumentPropertyConstraintsTests: XCTestCase { ] """ + /// Rules measuring sizes and looking among an array's elements, as the FFI + /// reports them: the `length`, `count` and `elements` read kinds. + private let sizeAndElementRulesJSON = """ + [ + { + "name": "notUsed", + "readsOwner": false, + "readsSystem": [], + "reads": [{ "kind": "elements", "path": "labels" }], + "rule": { "not": { "contains": ["labels", { "const": "used" }] } } + }, + { + "name": "shortTitle", + "readsOwner": false, + "readsSystem": [], + "reads": [ + { "kind": "length", "path": "title" }, + { "kind": "length", "path": "title" } + ], + "rule": { + "allOf": [ + { "lessThanOrEqual": [{ "length": "title" }, 20] }, + { "lessThanOrEqual": [{ "byteLength": "title" }, 40] } + ] + } + }, + { + "name": "tagsFitSlots", + "readsOwner": false, + "readsSystem": [], + "reads": [ + { "kind": "count", "path": "tags" }, + { "kind": "value", "path": "slots" } + ], + "rule": { "lessThanOrEqual": [{ "count": "tags" }, "slots"] } + } + ] + """ + + /// Rules reading system times and heights, as the FFI reports them: the + /// `listing` type of rs-sdk-ffi's + /// `should_read_the_clock_for_system_times_and_skip_block_heights`, and a + /// rule comparing the last update with the last transfer. + private let systemRulesJSON = """ + [ + { + "name": "endsAfterCreation", + "readsOwner": false, + "readsSystem": ["$createdAt"], + "reads": [{ "kind": "value", "path": "endsAt" }], + "rule": { "greaterThan": ["endsAt", "$createdAt"] } + }, + { + "name": "listedAfterHeight10", + "readsOwner": false, + "readsSystem": ["$createdAtBlockHeight"], + "reads": [], + "rule": { "greaterThanOrEqual": ["$createdAtBlockHeight", 10] } + }, + { + "name": "repricedAfterTransfer", + "readsOwner": false, + "readsSystem": [ + "$updatedAt", + "$transferredAt", + "$updatedAtCoreBlockHeight", + "$transferredAtCoreBlockHeight" + ], + "reads": [], + "rule": { + "allOf": [ + { "greaterThanOrEqual": ["$updatedAt", "$transferredAt"] }, + { "greaterThanOrEqual": ["$updatedAtCoreBlockHeight", "$transferredAtCoreBlockHeight"] } + ] + } + } + ] + """ + /// The platform serialization of a contract created at protocol version /// 13, owned by `[7; 32]`, declaring one `note` type with a string /// `message` and no rules (generated by rs-sdk-ffi's @@ -87,6 +169,7 @@ final class DocumentPropertyConstraintsTests: XCTestCase { XCTAssertEqual(rules[1].reads.map(\.kind), [.value, .value]) XCTAssertEqual(rules[2].reads.map(\.kind), [.presence, .identifier]) XCTAssertEqual(rules.map(\.readsOwner), [false, false, true]) + XCTAssertEqual(rules.map(\.readsSystem), [[], [], []]) } /// The rule is kept as the JSON the schema declares, compact with sorted @@ -149,6 +232,148 @@ final class DocumentPropertyConstraintsTests: XCTestCase { } } + // MARK: - Read kinds + + func testSizeAndElementReadsDecodeToTheirKinds() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: sizeAndElementRulesJSON) + + XCTAssertEqual(rules.map(\.name), ["notUsed", "shortTitle", "tagsFitSlots"]) + XCTAssertEqual(rules[0].reads, [PropertyConstraintRead(path: "labels", kind: .elements)]) + // `length` and `byteLength` both read a string's size + XCTAssertEqual( + rules[1].reads, + [ + PropertyConstraintRead(path: "title", kind: .length), + PropertyConstraintRead(path: "title", kind: .length) + ] + ) + XCTAssertEqual( + rules[2].reads, + [ + PropertyConstraintRead(path: "tags", kind: .count), + PropertyConstraintRead(path: "slots", kind: .value) + ] + ) + XCTAssertEqual( + rules[1].ruleJSON, + #"{"allOf":[{"lessThanOrEqual":[{"length":"title"},20]},{"lessThanOrEqual":[{"byteLength":"title"},40]}]}"# + ) + } + + func testEveryReadKindNameRoundTrips() { + let names = ["value", "presence", "text", "identifier", "length", "count", "elements"] + let kinds: [PropertyConstraintRead.Kind] = [ + .value, .presence, .text, .identifier, .length, .count, .elements + ] + XCTAssertEqual(names.map(PropertyConstraintRead.Kind.init(name:)), kinds) + XCTAssertEqual(kinds.map(\.name), names) + XCTAssertEqual(PropertyConstraintRead.Kind(name: "Length"), .other("Length")) + } + + // MARK: - System reads + + func testSystemReadsDecodeInDeclaredOrder() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: systemRulesJSON) + + XCTAssertEqual( + rules.map(\.readsSystem), + [ + ["$createdAt"], + ["$createdAtBlockHeight"], + [ + "$updatedAt", + "$transferredAt", + "$updatedAtCoreBlockHeight", + "$transferredAtCoreBlockHeight" + ] + ] + ) + // A system time or height is no property, and not the owner + XCTAssertEqual(rules[0].reads, [PropertyConstraintRead(path: "endsAt", kind: .value)]) + XCTAssertEqual(rules[1].reads, []) + XCTAssertEqual(rules[2].reads, []) + XCTAssertEqual(rules.map(\.readsOwner), [false, false, false]) + } + + /// Every name Rust reports is kept as it is, in order, a system value read + /// twice listed twice. + func testEverySystemNameIsKeptVerbatimWithRepeats() throws { + let names = [ + "$createdAt", + "$updatedAt", + "$transferredAt", + "$createdAtBlockHeight", + "$updatedAtBlockHeight", + "$transferredAtBlockHeight", + "$createdAtCoreBlockHeight", + "$updatedAtCoreBlockHeight", + "$transferredAtCoreBlockHeight" + ] + let quoted = names.map { "\"\($0)\"" }.joined(separator: ", ") + + let rules = try DocumentPropertyConstraint.list(fromJSON: """ + [{ "name": "r", "readsOwner": false, "reads": [], + "readsSystem": [\(quoted), "$createdAt"], + "rule": { "greaterThan": [{ "add": [\(quoted)] }, "$createdAt"] } }] + """) + + XCTAssertEqual(rules.first?.readsSystem, names + ["$createdAt"]) + } + + /// A library built before `readsSystem` leaves the key out. + func testMissingSystemReadsDecodeAsEmpty() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: """ + [{ "name": "r", "rule": { "present": "a" }, "readsOwner": false, + "reads": [{ "path": "a", "kind": "presence" }] }] + """) + + XCTAssertEqual(rules.first?.readsSystem, []) + } + + func testMalformedSystemReadsAreRefused() { + let rule = #""name": "r", "rule": {"present": "a"}, "reads": [], "readsOwner": false"# + let malformed = [ + // Not an array + #"[{\#(rule), "readsSystem": "$createdAt"}]"#, + #"[{\#(rule), "readsSystem": {"$createdAt": true}}]"#, + // A null is no missing key + #"[{\#(rule), "readsSystem": null}]"#, + // An element that is not a string + #"[{\#(rule), "readsSystem": ["$createdAt", 1]}]"#, + #"[{\#(rule), "readsSystem": [["$createdAt"]]}]"# + ] + for json in malformed { + XCTAssertThrowsError(try DocumentPropertyConstraint.list(fromJSON: json), json) { error in + guard case SDKError.serializationError = error else { + return XCTFail("\(json): expected a serialization error, got \(error)") + } + } + } + } + + /// The initializer still builds a rule without naming `readsSystem`, as it + /// did before the field existed. + func testInitializerDefaultsToNoSystemReads() { + let rule = DocumentPropertyConstraint( + name: "r", + ruleJSON: #"{"present":"a"}"#, + reads: [PropertyConstraintRead(path: "a", kind: .presence)], + readsOwner: false + ) + + XCTAssertEqual(rule.readsSystem, []) + XCTAssertNotEqual( + rule, + DocumentPropertyConstraint( + name: "r", + ruleJSON: #"{"present":"a"}"#, + reads: [PropertyConstraintRead(path: "a", kind: .presence)], + readsOwner: false, + readsSystem: ["$createdAt"] + ) + ) + } + // MARK: - Violations func testViolationDecodes() throws { From a1d4d85f0b0b2c2e64656c09bb2c5a79c65e4d18 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 10:34:56 +0700 Subject: [PATCH 068/113] feat(platform)!: ifThen, ifThenElse, notIn, min, max and abs in propertyConstraints rules (PV14) (#5100) Co-authored-by: Claude Opus 5.5 Co-authored-by: Lil Claw --- .../contract-keywords/property-constraints.md | 15 +- book/src/data-model/documents.md | 8 +- packages/js-evo-sdk/README.md | 2 +- .../document/v3/document-meta.json | 82 +++- .../v3/property_constraints_tests.rs | 135 ++++++ .../src/data_contract/document_type/mod.rs | 5 +- .../document_type/property_constraints/mod.rs | 309 ++++++++++++- .../property_constraints/tests.rs | 408 +++++++++++++++++- .../src/data_contract/document_type/v2/mod.rs | 11 +- .../tests/document/property_constraints.rs | 151 +++++++ .../src/version/system_limits/mod.rs | 9 +- .../rs-platform-version/src/version/v14.rs | 20 +- .../document_type_property_constraints.rs | 21 +- .../unit/DocumentPropertyConstraints.spec.ts | 59 +++ 14 files changed, 1176 insertions(+), 59 deletions(-) diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index 640ab7138f1..e2160e341b3 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -73,6 +73,7 @@ A rule is a condition: a JSON object with exactly one key. | `equal`, `notEqual` | `[left, right]` | The two sides are equal, or differ. The sides are two integer expressions, or a string property and a string constant or another string property, or an identifier property and an identifier constant, another identifier property or `$ownerId` | | `lessThan`, `lessThanOrEqual`, `greaterThan`, `greaterThanOrEqual` | `[left, right]` | The left integer expression compares with the right one this way. Integers only | | `in` | `[expression, [v1, v2, ...]]` | The expression takes one of the listed values: two or more, no two alike, all integers or all strings. With strings, the expression is a string property, or an identifier property or `$ownerId` with the strings as base58 identifiers | +| `notIn` | `[expression, [v1, v2, ...]]` | The expression takes none of the listed values: an `in` negated, listed the same way, in as many nodes. A string or identifier property the document leaves out takes none | | `startsWith`, `endsWith` | `[text, affix]` | The first string starts, or ends, with the second, byte for byte with no case folding. Each side is a string constant, a string property or an `ifAbsent` string default, at least one a property and never the same one twice. A string property left out without a default takes no string, and the condition does not hold for it | | `contains` | `["path", value]` | The typed array property at the path holds an element equal to the value: an integer expression among integers; a string constant, a string property or an `ifAbsent` string default among strings; an identifier constant, an identifier property or `$ownerId` among identifiers. An array the document leaves out holds nothing, and a string or identifier property it leaves out is among no elements | | `present` | `"path"` | The document holds the property, with a value other than null | @@ -80,8 +81,10 @@ A rule is a condition: a JSON object with exactly one key. | `anyOf` | `[c1, c2, ...]` | At least one of two or more conditions holds | | `allOf` | `[c1, c2, ...]` | Every one of two or more conditions holds | | `not` | `condition` | Its one condition does not hold | +| `ifThen` | `[if, then]` | If the first condition holds, the second must. The second is evaluated only when the first holds, and a fault in either breaks the rule. The two may not be alike | +| `ifThenElse` | `[if, then, else]` | If the first condition holds, the second must; if not, the third must. Only the branch the first selects is evaluated. No two of the three may be alike | -Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan": ["quantity", 10] }] } }` refuses a free order of more than 10. An `anyOf` or `allOf` may not list the same condition twice, nor hold one of its own kind directly (it says what one flat list says), and a `not` may not hold a `not` directly. +Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan": ["quantity", 10] }] } }` refuses a free order of more than 10. An `anyOf` or `allOf` may not list the same condition twice, nor hold one of its own kind directly (it says what one flat list says), and a `not` may not hold a `not` or a `notIn` directly. An `in` says what an `anyOf` of `equal` comparisons says, in far fewer nodes: `{ "in": ["fee", [0, 10, 25, 50]] }` is 6 nodes where the `anyOf` is 13. @@ -109,6 +112,8 @@ An integer expression is one of: | `divide` | `{ "divide": [a, b] }` | The Euclidean quotient of `a` by `b` | | `modulo` | `{ "modulo": [a, b] }` | The Euclidean remainder of `a` by `b`, never negative | | `power` | `{ "power": [a, b] }` | `a` to the power `b` | +| `min`, `max` | `{ "max": [a, b, ...] }` | The least or greatest of two or more operands, every one evaluated | +| `abs` | `{ "abs": a }` | The absolute value of its one operand | | `length`, `byteLength` | `{ "length": "title" }` | The characters (as `maxLength` counts them) or UTF-8 bytes (as `maxBytes` counts them) of a string property, 0 when the document leaves it out | | `count` | `{ "count": "tags" }` | The items of an array property, or the bytes of a byte array property, 0 when the document leaves it out | | system time or height | `"$createdAt"`, `"$updatedAtBlockHeight"` | A time or height the document records (see [Times and heights](#times-and-heights)) | @@ -193,7 +198,7 @@ The meta-schema checks the shape (`JsonSchemaError`, 10101): - the keyword is an object of one or more rules, named with 1 to 64 letters, digits or underscores; - every condition and every operator object has exactly one key; - a comparison, `subtract`, `divide`, `modulo` and `power` take exactly two operands; `add` and `multiply` two or more; `anyOf` and `allOf` two or more conditions, no two alike; an `in` two or more distinct values, all integers or all strings; -- no `anyOf` or `allOf` holds its own kind directly, and no `not` holds a `not`; +- no `anyOf` or `allOf` holds its own kind directly, and no `not` holds a `not` or a `notIn`; - a path matches `$ownerId`, one of the nine [times and heights](#times-and-heights), or dotted names of 1 to 64 letters, digits or underscores, so `$revision` and other system properties are refused. The parser then checks the rules against the document type (`InvalidContractStructure`, 10231): @@ -206,7 +211,7 @@ The parser then checks the rules against the document type (`InvalidContractStru - no literal divisor is 0 and no literal exponent is negative; - every time or height a rule reads is one the type lists in `required`, and takes no `ifAbsent` default; - `present` and `absent` do not name `$ownerId` or a time or height, and an index-only type has no rule reading any of them; -- no `anyOf` or `allOf` lists two conditions that parse alike, such as `1` and `1.0`, or two `in` conditions listing the same values in another order; +- no `anyOf` or `allOf` lists two conditions that parse alike, such as `1` and `1.0`, or two `in` conditions listing the same values in another order, and no `ifThen` or `ifThenElse` holds two alike conditions; - no condition or operand nests more than 64 levels deep. Two limits come from the protocol version 14 `SystemLimits`, and a rule over one is refused the same way: @@ -226,8 +231,10 @@ A rule within 32 nodes is never deep enough to reach the 64-level bound. Nodes a | `present`, `absent` | 1 | | `anyOf`, `allOf` | 1, plus their conditions | | `not` | 1, plus its condition | +| `ifThen`, `ifThenElse` | 1, plus their conditions | +| `notIn` | as the `in` it negates | | An integer, a path, an `ifAbsent`, a size (`length`, `byteLength`, `count`) or a time or height | 1 | -| `add`, `multiply`, `subtract`, `divide`, `modulo`, `power` | 1, plus their operands | +| `add`, `multiply`, `subtract`, `divide`, `modulo`, `power`, `min`, `max`, `abs` | 1, plus their operands | `depositCoversOrder` above is 7 nodes (the comparison, `multiply`, `add` and four paths), and `closedNeedsClosedAt` is 5. An `in` fits up to 30 values in 32 nodes. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 4072de6a515..b628bd9fd6d 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -718,14 +718,18 @@ The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeas - `{ "present": path }`, holding if the document holds the property, and `{ "absent": path }`, holding if it leaves it out (a property set to null counts as left out). An operand reads a property the document leaves out as 0, so only these tell "not given" from "given as 0". They may name a property of any type, an object or a member of one included, since they read no value; - `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; - `{ "allOf": [...] }`, holding if every one of two or more conditions holds; -- `{ "not": condition }`, holding if its one condition does not. +- `{ "not": condition }`, holding if its one condition does not; +- `{ "ifThen": [a, b] }`, holding if `b` holds whenever `a` does: `b` is evaluated only when `a` holds, so `{ "ifThen": [{ "greaterThan": ["discount", 0] }, { "greaterThanOrEqual": [{ "divide": ["price", "discount"] }, 10] }] }` never divides by zero, and a fault in either breaks the rule. It says what `{ "anyOf": [{ "not": a }, b] }` says, in one node fewer. The two may not be alike; +- `{ "ifThenElse": [a, b, c] }`, holding if `b` holds when `a` does and `c` holds when it does not; only the branch `a` selects is evaluated. `{ "ifThenElse": [{ "greaterThanOrEqual": ["price", 1000] }, { "lessThanOrEqual": ["fee", 50] }, { "lessThanOrEqual": ["fee", 10] }] }` allows a higher fee on an expensive offer. No two of the three may be alike; +- `{ "notIn": [expression, [values]] }`, an `in` negated, listed the same way and in as many nodes: `{ "notIn": ["fee", [7, 13]] }` refuses two fees. -Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan": ["quantity", 10] }] } }` refuses a free order of more than 10. An `anyOf` or `allOf` may not list two alike conditions, nor hold one of its own kind directly (it says what one flat list says), and a `not` may not hold a `not` directly. An expression is one of: +Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan": ["quantity", 10] }] } }` refuses a free order of more than 10. An `anyOf` or `allOf` may not list two alike conditions, nor hold one of its own kind directly (it says what one flat list says), and a `not` may not hold a `not` or a `notIn` directly. An expression is one of: - an integer value (`100`; a float with no fractional part, `100.0`, reads as that integer, as the meta-schema's `integer` type admits it); - a string, the dotted path of an integer or boolean property of the document type (`"price"`, `"meta.total"`, `"waiveFee"`), whose value it takes, 0 when the document leaves the property out. A boolean reads as 1 for true and 0 for false, so `{ "equal": [{ "multiply": ["waiveFee", "fee"] }, 0] }` says a waived fee is 0; - `{ "ifAbsent": [path, value] }`, the property's value, or `value` when the document leaves it out (an integer value here; a string value gives a string property a default in a string comparison instead); - `{ "add": [...] }` or `{ "multiply": [...] }` over two or more operands; +- `{ "min": [...] }` or `{ "max": [...] }`, the least or greatest of two or more operands, every one evaluated (a fault in any breaks the rule), and `{ "abs": a }`, the absolute value of one: `{ "lessThanOrEqual": ["fee", { "max": [10, { "divide": ["price", 10] }] }] }` caps a fee at 10 or a tenth of the price, whichever is more, and `{ "lessThanOrEqual": [{ "abs": { "subtract": ["a", "b"] } }, 5] }` keeps two values within 5; - `{ "subtract": [a, b] }`, `{ "divide": [a, b] }`, `{ "modulo": [a, b] }` or `{ "power": [a, b] }`; - a size: `{ "length": path }`, the characters of a string property (counted as `maxLength` counts them), `{ "byteLength": path }`, its UTF-8 bytes (as `maxBytes` counts them), or `{ "count": path }`, the items of an array property or the bytes of a byte array property. Where `maxLength`, `maxBytes` and `maxItems` bound one property by a fixed number, a size can be compared with another property or bounded only under a condition: `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit, and `{ "anyOf": [{ "greaterThan": ["fee", 0] }, { "lessThanOrEqual": [{ "length": "title" }, 20] }] }` keeps a free listing's title short. A property the document leaves out or sets to null has size 0, and a size never breaks a rule by itself (a value of another type would read as 0 too, but the schema validation refuses it first); - a system time or height: `"$createdAt"`, `"$updatedAt"` and `"$transferredAt"`, block times in milliseconds, and each with `BlockHeight` or `CoreBlockHeight` appended, the Platform and Core block heights: those of the document's creation, of its last create, replace or price update, and of its last create, transfer or purchase. A rule may read one only on a type that records it by listing it in `required`, so every stored document holds it; it takes no `ifAbsent`, and an indexOnly type, whose deletes carry none, reads none. `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week from its creation, and `{ "lessThanOrEqual": ["$updatedAt", "endsAt"] }` refuses a replace or a price update after it ends. diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 454cc0c2793..d6c7672b7a1 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -425,7 +425,7 @@ From protocol version 14 a document type can declare rules its documents' proper } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `{ "startsWith": ["url", { "const": "https://" }] }` and `endsWith` test a string property's start or end, byte for byte, against a constant or another string property. `{ "contains": ["participants", "$ownerId"] }` holds when a typed array property has an element equal to the value, looked for as the array's elements are (an integer expression, a string or an identifier), so `{ "not": { "contains": ["labels", { "const": "used" }] } }` refuses a label; the array is reported as a read of kind `elements`. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0), or a size: `{ "length": path }` and `{ "byteLength": path }` give the characters and UTF-8 bytes of a string property, and `{ "count": path }` the items of an array or the bytes of a byte array, so `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit (a size read is reported with kind `length` or `count`). A type that lists `$createdAt`, `$updatedAt` or `$transferredAt` (or any of them with `BlockHeight` or `CoreBlockHeight` appended) in `required` may read it too: `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week, and since a price update sets `$updatedAt` and a transfer or purchase `$transferredAt`, each is judged against the rules reading those. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `{ "startsWith": ["url", { "const": "https://" }] }` and `endsWith` test a string property's start or end, byte for byte, against a constant or another string property. `{ "contains": ["participants", "$ownerId"] }` holds when a typed array property has an element equal to the value, looked for as the array's elements are (an integer expression, a string or an identifier), so `{ "not": { "contains": ["labels", { "const": "used" }] } }` refuses a label; the array is reported as a read of kind `elements`. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, `not` if its one condition does not, `{ "ifThen": [a, b] }` if `b` holds whenever `a` does (evaluating `b` only then), and `{ "ifThenElse": [a, b, c] }` if `b` holds when `a` does and `c` when it does not; `{ "notIn": [expression, [values]] }` is an `in` negated, and `min`, `max` (two or more operands) and `abs` (one) join the arithmetic; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0), or a size: `{ "length": path }` and `{ "byteLength": path }` give the characters and UTF-8 bytes of a string property, and `{ "count": path }` the items of an array or the bytes of a byte array, so `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit (a size read is reported with kind `length` or `count`). A type that lists `$createdAt`, `$updatedAt` or `$transferredAt` (or any of them with `BlockHeight` or `CoreBlockHeight` appended) in `required` may read it too: `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week, and since a price update sets `$updatedAt` and a transfer or purchase `$transferredAt`, each is judged against the rules reading those. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 84cd8b635a5..924fafd4377 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -40,7 +40,7 @@ } }, "propertyConstraint": { - "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right (equal and notEqual may instead compare the path of a string property with a const string or with the path of another string property, and likewise the path of an identifier property with a const base58 identifier or another identifier property), in listing an expression and the values it may take, startsWith or endsWith listing two strings, the first starting or ending with the second, contains listing a typed array property and a value its elements must include, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", + "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right (equal and notEqual may instead compare the path of a string property with a const string or with the path of another string property, and likewise the path of an identifier property with a const base58 identifier or another identifier property), in listing an expression and the values it may take, startsWith or endsWith listing two strings, the first starting or ending with the second, contains listing a typed array property and a value its elements must include, present or absent naming a property (the document holds it, or leaves it out), anyOf (at least one of its conditions holds), allOf (every one of its conditions holds), not (its one condition does not hold), ifThen (its second condition holds whenever its first does) or ifThenElse (its second condition holds when its first does, its third when it does not); notIn is in negated", "type": "object", "properties": { "equal": { @@ -89,6 +89,34 @@ "items": false, "minItems": 2 }, + "notIn": { + "description": "Holds if the expression listed first takes none of the values listed second, the in of the same values negated, in as many nodes: two or more, no two alike, all integers or all strings. With integers the expression is any integer expression; with strings it is the path of a string property, which one the document leaves out holds none of, or the path of an identifier property, the strings then being base58 identifiers. It says what an allOf of notEqual comparisons, or a not over the in, says, in as many nodes as the in", + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/propertyConstraintExpression" + }, + { + "type": "array", + "anyOf": [ + { + "items": { + "type": "integer" + } + }, + { + "items": { + "type": "string" + } + } + ], + "minItems": 2, + "uniqueItems": true + } + ], + "items": false, + "minItems": 2 + }, "startsWith": { "description": "Holds if the string listed first starts with the string listed second, byte for byte, with no case folding: each a const string or a string property (or an ifAbsent giving one a default), at least one a property, never the same one twice. A string property the document leaves out without a default takes no string, and the condition does not hold for it", "$ref": "#/$defs/propertyConstraintOperandPair" @@ -138,11 +166,43 @@ } }, "not": { - "description": "Holds if its one condition does not hold; a fault evaluating the condition still breaks the rule. The condition may not be directly another not", + "description": "Holds if its one condition does not hold; a fault evaluating the condition still breaks the rule. The condition may not be directly another not, nor a notIn (an in of the same values says it)", "$ref": "#/$defs/propertyConstraint", "properties": { - "not": false + "not": false, + "notIn": false } + }, + "ifThen": { + "description": "Holds if the second condition holds whenever the first does: the second is evaluated only when the first holds, and a fault in either breaks the rule. The two may not be alike", + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/propertyConstraint" + }, + { + "$ref": "#/$defs/propertyConstraint" + } + ], + "items": false, + "minItems": 2 + }, + "ifThenElse": { + "description": "Holds if the second condition holds when the first does, and the third when it does not: only the branch the first selects is evaluated, and a fault in it or in the first breaks the rule. No two of the three may be alike", + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/propertyConstraint" + }, + { + "$ref": "#/$defs/propertyConstraint" + }, + { + "$ref": "#/$defs/propertyConstraint" + } + ], + "items": false, + "minItems": 3 } }, "minProperties": 1, @@ -158,7 +218,7 @@ "uniqueItems": true }, "propertyConstraintExpression": { - "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; a system time or height the document type records ($createdAt, $updatedAtBlockHeight, ...); or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out, an integer, or a string for a string property compared with strings; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out; const, a string constant compared with a string property", + "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; a system time or height the document type records ($createdAt, $updatedAtBlockHeight, ...); or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out, an integer, or a string for a string property compared with strings; add, multiply, min or max, two or more operands; subtract, divide, modulo or power, exactly two; abs, one; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out; const, a string constant compared with a string property", "type": [ "integer", "string", @@ -211,6 +271,18 @@ "power": { "$ref": "#/$defs/propertyConstraintOperandPair" }, + "min": { + "description": "The least of two or more operands, every one evaluated", + "$ref": "#/$defs/propertyConstraintOperands" + }, + "max": { + "description": "The greatest of two or more operands, every one evaluated", + "$ref": "#/$defs/propertyConstraintOperands" + }, + "abs": { + "description": "The absolute value of its one operand", + "$ref": "#/$defs/propertyConstraintExpression" + }, "length": { "description": "The number of characters of the string property at this path, as maxLength counts them, or 0 when the document leaves it out", "$ref": "#/$defs/propertyConstraintPath" @@ -2102,7 +2174,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, startsWith or endsWith listing two strings, a const or a string property each, at least one a property, and holding if the first starts or ends with the second, byte for byte (a constant looked for in a string property that declares enum must start or end one of its values), contains listing the path of a typed array property and the value one of its elements must equal (an integer expression among integers, a const string or string property among strings, a const base58 identifier, identifier property or $ownerId among identifiers; an array left out holds nothing, and a constant must be one of the elements' enum values when they declare one), present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, a system time or height the document type records by listing it in required ($createdAt, $updatedAt and $transferredAt, block times in milliseconds, and each with BlockHeight or CoreBlockHeight appended, the Platform and Core block heights: those of the create, of the last create, replace or price update, and of the last create, transfer or purchase), or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add or multiply, two or more operands; subtract, divide, modulo or power, exactly two; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out ({ \"lessThanOrEqual\": [{ \"count\": \"tags\" }, \"maxTags\"] }). A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property length or byteLength measures a string property, every property count counts an array or byte array property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every contains and its array, every const, every present or absent, every arithmetic operator and every operand, a size included; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. Likewise a transfer or a purchase is judged against the rules reading the transfer's time and heights, and a price update against those reading the update's, since each sets them; an indexOnly type reads no system time or height. The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, startsWith or endsWith listing two strings, a const or a string property each, at least one a property, and holding if the first starts or ends with the second, byte for byte (a constant looked for in a string property that declares enum must start or end one of its values), contains listing the path of a typed array property and the value one of its elements must equal (an integer expression among integers, a const string or string property among strings, a const base58 identifier, identifier property or $ownerId among identifiers; an array left out holds nothing, and a constant must be one of the elements' enum values when they declare one), present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf, not, ifThen or ifThenElse over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not, ifThen if its second condition holds whenever its first does (the second evaluated only then), ifThenElse if its second holds when its first does and its third when it does not (only that branch evaluated); notIn lists what in lists and holds if the expression takes none of the values. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, a system time or height the document type records by listing it in required ($createdAt, $updatedAt and $transferredAt, block times in milliseconds, and each with BlockHeight or CoreBlockHeight appended, the Platform and Core block heights: those of the create, of the last create, replace or price update, and of the last create, transfer or purchase), or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add, multiply, min or max, two or more operands; subtract, divide, modulo or power, exactly two; abs, one; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out ({ \"lessThanOrEqual\": [{ \"count\": \"tags\" }, \"maxTags\"] }). A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property length or byteLength measures a string property, every property count counts an array or byte array property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not or a notIn, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every contains and its array, every const, every present or absent, every arithmetic operator and every operand, a size included; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. Likewise a transfer or a purchase is judged against the rules reading the transfer's time and heights, and a price update against those reading the update's, since each sets them; an indexOnly type reads no system time or height. The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index 962c9a5698e..b20030790a5 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -1155,6 +1155,7 @@ fn should_check_the_grammar_with_the_meta_schema_and_the_parser() { } }), json!({ "rule": { "not": { "not": { "equal": ["price", 1] } } } }), + json!({ "rule": { "not": { "notIn": ["price", [1, 2]] } } }), json!({ "rule": { "anyOf": [{ "equal": ["price", 1] }, { "equal": ["price"] }] } }), json!({ "rule": { "present": 1 } }), json!({ "rule": { "absent": ["note"] } }), @@ -1848,3 +1849,137 @@ fn should_test_string_properties_for_prefixes_and_suffixes() { ); } } + +/// `min`, `max`, `abs`, `ifThen`, `ifThenElse` and `notIn` register on both +/// paths, their reads held to the same checks as any; a `notIn` string is +/// checked against the property's enum, and an `ifThen` or `ifThenElse` +/// holding two alike conditions is refused under full validation only, like +/// any repeated condition. +#[test] +fn should_register_min_max_abs_if_then_and_not_in() { + let seller = Identifier::new([5; 32]).to_string(Encoding::Base58); + let other = Identifier::new([6; 32]).to_string(Encoding::Base58); + let rules = json!({ + "feeCapped": { "lessThanOrEqual": ["fee", { "max": [10, { "divide": ["price", 10] }] }] }, + "cheapSide": { "greaterThanOrEqual": [{ "min": ["price", "fee"] }, 1] }, + "depositNearOrder": { + "lessThanOrEqual": [{ "abs": { "subtract": ["deposit", "price"] } }, 1000] + }, + "closedHasNote": { + "ifThen": [{ "equal": ["state", { "const": "closed" }] }, { "present": "note" }] + }, + "feeByState": { + "ifThenElse": [ + { "equal": ["state", { "const": "open" }] }, + { "lessThanOrEqual": ["fee", 50] }, + { "lessThanOrEqual": ["fee", 10] } + ] + }, + "feeNotBanned": { "notIn": ["fee", [13, 666]] }, + "notSpam": { "notIn": ["note", ["spam", "scam"]] }, + "notTheseSellers": { "notIn": ["sellerId", [seller.clone(), other.clone()]] } + }); + for full_validation in [true, false] { + let document_type = parse_order(rules.clone(), full_validation) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let constraints = document_type.property_constraints(); + assert_eq!(constraints.len(), 8); + assert_eq!( + constraints["closedHasNote"].property_reads(), + [ + ("state", PropertyRead::Text), + ("note", PropertyRead::Presence) + ] + ); + assert_eq!( + constraints["feeByState"].property_paths(), + ["state", "fee", "fee"] + ); + assert_eq!( + constraints["depositNearOrder"].property_paths(), + ["deposit", "price"] + ); + } + + // The reads inside are held to the usual checks + for (rule, needle) in [ + ( + json!({ "notIn": ["state", ["open", "x"]] }), + "rule \"rule\" compares \"state\" with \"x\", which is not one of its enum values", + ), + ( + json!({ "equal": [{ "abs": "note" }, 1] }), + "reads \"note\", which has type string, not integer or boolean", + ), + ( + json!({ "ifThen": [{ "present": "note" }, { "lessThan": ["missing", 1] }] }), + "reads \"missing\", which is not an integer or boolean property", + ), + ( + json!({ + "ifThenElse": [ + { "present": "note" }, + { "present": "fee" }, + { "equal": ["state", { "const": "x" }] } + ] + }), + "rule \"rule\" compares \"state\" with \"x\", which is not one of its enum values", + ), + ] { + for full_validation in [true, false] { + expect_structure_error( + parse_order(json!({ "rule": rule.clone() }), full_validation), + needle, + ); + } + } + + // Two alike conditions say what a simpler rule says + for (same, needle) in [ + ( + json!({ "ifThen": [{ "present": "note" }, { "present": "note" }] }), + "rule \"rule\" at ifThen[1] repeats the condition at ifThen[0]", + ), + ( + json!({ + "ifThenElse": [{ "present": "note" }, { "present": "fee" }, { "present": "fee" }] + }), + "rule \"rule\" at ifThenElse[2] repeats the condition at ifThenElse[1]", + ), + ] { + let same = json!({ "rule": same }); + expect_structure_error(parse_order(same.clone(), true), needle); + parse_order(same, false).expect("a stored rule is not re-judged for repeats"); + } + + // The meta-schema checks the shapes when registering + for rules in [ + json!({ "rule": { "ifThen": [{ "present": "note" }] } }), + json!({ "rule": { "ifThen": { "present": "note" } } }), + json!({ + "rule": { "ifThen": [{ "present": "note" }, { "present": "fee" }, { "present": "id" }] } + }), + json!({ "rule": { "ifThenElse": [{ "present": "note" }, { "present": "fee" }] } }), + json!({ + "rule": { + "ifThenElse": [ + { "present": "note" }, + { "present": "fee" }, + { "present": "id" }, + { "present": "x" } + ] + } + }), + json!({ "rule": { "notIn": ["price"] } }), + json!({ "rule": { "notIn": ["price", [1, 1]] } }), + json!({ "rule": { "equal": [{ "min": ["price"] }, 1] } }), + json!({ "rule": { "equal": [{ "abs": ["price", "fee"] }, 1] } }), + ] { + let registered = parse_order(rules.clone(), true); + assert!( + registered.as_ref().is_err_and(is_json_schema_error), + "{rules}: the meta-schema should refuse it, got {registered:?}" + ); + expect_structure_error(parse_order(rules, false), "propertyConstraints"); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 79c65ff0dad..f86caed09b6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -126,8 +126,9 @@ pub(crate) mod property_names { /// Doctype-level object of named rules, each a condition on the document's /// properties (a comparison of two integer expressions, of a string or an /// identifier property with constants or with another property of its - /// kind, an `in` list of values, a `present` or `absent` test, or an - /// `anyOf`, `allOf` or `not` of conditions) that every created or replaced + /// kind, an `in` or `notIn` list of values, a `startsWith` or `endsWith`, + /// a `contains`, a `present` or `absent` test, or an `anyOf`, `allOf`, + /// `not`, `ifThen` or `ifThenElse` of conditions) that every created or replaced /// document must meet. Meta-schema v3+ (protocol version 14). See /// `parse_property_constraints` in `property_constraints`. pub const PROPERTY_CONSTRAINTS: &str = "propertyConstraints"; diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index 3e879f18925..f033bbb2ab1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -7,7 +7,8 @@ //! (`equal`, `notEqual`), a test of whether a string starts or ends with //! another (`startsWith`, `endsWith`), a test of whether an array property //! holds a value (`contains`), a test of whether the document holds a property -//! (`present`, `absent`), or `anyOf`, `allOf` or `not` over conditions. +//! (`present`, `absent`), or `anyOf`, `allOf`, `not`, `ifThen` or +//! `ifThenElse` over conditions; `notIn` is an `in` negated. //! //! ```json //! "propertyConstraints": { @@ -36,7 +37,8 @@ //! //! An operand is an integer value, the dotted path of an integer or boolean //! property (a boolean reads as 1 for true and 0 for false), or an object with -//! one key: an arithmetic operator over its operands, `ifAbsent`, a property +//! one key: an arithmetic operator over its operands (`min`, `max` and `abs` +//! included), `ifAbsent`, a property //! with the value it takes when the document leaves it out, or a size: //! `length` and `byteLength`, the characters and the UTF-8 bytes of a string //! property, and `count`, the items of an array or byte array property. A @@ -98,6 +100,12 @@ const POWER: &str = "power"; const ANY_OF: &str = "anyOf"; const ALL_OF: &str = "allOf"; const NOT: &str = "not"; +const IF_THEN: &str = "ifThen"; +const IF_THEN_ELSE: &str = "ifThenElse"; +const NOT_IN: &str = "notIn"; +const MIN: &str = "min"; +const MAX: &str = "max"; +const ABS: &str = "abs"; const PRESENT: &str = "present"; const ABSENT: &str = "absent"; const IN: &str = "in"; @@ -111,8 +119,8 @@ const COUNT: &str = "count"; const CONST: &str = "const"; /// Every key an operand object may hold, for the errors. -const OPERAND_KEYS: &str = - "add, subtract, multiply, divide, modulo, power, ifAbsent, length, byteLength or count"; +const OPERAND_KEYS: &str = "add, subtract, multiply, divide, modulo, power, min, max, abs, \ + ifAbsent, length, byteLength or count"; /// The deepest a condition or an operand may sit in its rule: the rule's own /// condition at depth 0, and each operand of a comparison, and each condition @@ -424,6 +432,12 @@ pub enum ConstraintExpression { Modulo(Box, Box), /// `power`: the left operand raised to the right one. Power(Box, Box), + /// `min`: the least of two or more operands. + Min(Vec), + /// `max`: the greatest of two or more operands. + Max(Vec), + /// `abs`: the absolute value of its one operand. + Abs(Box), } impl ConstraintExpression { @@ -453,6 +467,9 @@ impl ConstraintExpression { /// remainder `1`), which for operands that are not negative is ordinary /// integer division. A divisor of 0 is a /// [`PropertyConstraintViolation::DivisionByZero`]; + /// * `min` and `max` evaluate every operand, so a fault in any breaks the + /// rule; `abs` of `i128::MIN` does not fit + /// ([`PropertyConstraintViolation::Overflow`]); /// * `power` refuses a negative exponent /// ([`PropertyConstraintViolation::NegativeExponent`]), which has no /// integer result, and takes `0` to the power `0` as `1`. @@ -520,6 +537,21 @@ impl ConstraintExpression { (left.evaluate(data, system)?, right.evaluate(data, system)?); power(base, exponent) } + // Folded from their identities, as add and multiply are + ConstraintExpression::Min(operands) => { + operands.iter().try_fold(i128::MAX, |least, operand| { + Ok(least.min(operand.evaluate(data, system)?)) + }) + } + ConstraintExpression::Max(operands) => { + operands.iter().try_fold(i128::MIN, |greatest, operand| { + Ok(greatest.max(operand.evaluate(data, system)?)) + }) + } + ConstraintExpression::Abs(operand) => operand + .evaluate(data, system)? + .checked_abs() + .ok_or(PropertyConstraintViolation::Overflow), } } @@ -530,9 +562,13 @@ impl ConstraintExpression { | ConstraintExpression::Property { .. } | ConstraintExpression::Size { .. } | ConstraintExpression::System(_) => 0, - ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { + ConstraintExpression::Add(operands) + | ConstraintExpression::Multiply(operands) + | ConstraintExpression::Min(operands) + | ConstraintExpression::Max(operands) => { operands.iter().map(ConstraintExpression::node_count).sum() } + ConstraintExpression::Abs(operand) => operand.node_count(), ConstraintExpression::Subtract(left, right) | ConstraintExpression::Divide(left, right) | ConstraintExpression::Modulo(left, right) @@ -547,9 +583,13 @@ impl ConstraintExpression { ConstraintExpression::Property { .. } | ConstraintExpression::Size { .. } | ConstraintExpression::System(_) => true, - ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { + ConstraintExpression::Add(operands) + | ConstraintExpression::Multiply(operands) + | ConstraintExpression::Min(operands) + | ConstraintExpression::Max(operands) => { operands.iter().any(ConstraintExpression::reads_property) } + ConstraintExpression::Abs(operand) => operand.reads_property(), ConstraintExpression::Subtract(left, right) | ConstraintExpression::Divide(left, right) | ConstraintExpression::Modulo(left, right) @@ -566,11 +606,15 @@ impl ConstraintExpression { ConstraintExpression::Value(_) | ConstraintExpression::System(_) => {} ConstraintExpression::Property { path, .. } => reads.push((path, PropertyRead::Value)), ConstraintExpression::Size { measure, path } => reads.push((path, measure.read())), - ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { + ConstraintExpression::Add(operands) + | ConstraintExpression::Multiply(operands) + | ConstraintExpression::Min(operands) + | ConstraintExpression::Max(operands) => { for operand in operands { operand.collect_property_reads(reads); } } + ConstraintExpression::Abs(operand) => operand.collect_property_reads(reads), ConstraintExpression::Subtract(left, right) | ConstraintExpression::Divide(left, right) | ConstraintExpression::Modulo(left, right) @@ -589,11 +633,15 @@ impl ConstraintExpression { ConstraintExpression::Value(_) | ConstraintExpression::Property { .. } | ConstraintExpression::Size { .. } => {} - ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { + ConstraintExpression::Add(operands) + | ConstraintExpression::Multiply(operands) + | ConstraintExpression::Min(operands) + | ConstraintExpression::Max(operands) => { for operand in operands { operand.collect_system_reads(reads); } } + ConstraintExpression::Abs(operand) => operand.collect_system_reads(reads), ConstraintExpression::Subtract(left, right) | ConstraintExpression::Divide(left, right) | ConstraintExpression::Modulo(left, right) @@ -845,6 +893,19 @@ pub enum PropertyConstraint { AllOf(Vec), /// `not`: the condition does not hold. Not(Box), + /// `ifThen`: `then` holds whenever `condition` does, + /// `{ "ifThen": [{ "equal": ["status", { "const": "closed" }] }, { "present": "closedAt" }] }`; + /// or `ifThenElse`, with `otherwise` given: `then` when `condition` holds, + /// `otherwise` when it does not. Only the branch taken is evaluated. + IfThen { + condition: Box, + then: Box, + otherwise: Option>, + }, + /// `notIn`: an `in` ([`Self::In`], [`Self::TextIn`] or + /// [`Self::IdentifierIn`]) that does not hold, the operand taking none of + /// the listed values. It costs what the `in` costs. + NotIn(Box), } impl PropertyConstraint { @@ -856,7 +917,9 @@ impl PropertyConstraint { /// Evaluated left to right, and no further than the outcome needs: a /// comparison evaluates its left side, then its right one; `anyOf` checks /// its conditions in declared order and holds at the first that holds; - /// `allOf` fails at the first that fails; `not` inverts its condition; a + /// `allOf` fails at the first that fails; `not` inverts its condition; + /// `ifThen` and `ifThenElse` evaluate their condition, then only the + /// branch it selects (an `ifThen` holding when the condition does not); a /// string comparison, `present` or `absent` never faults. The first fault /// an evaluated expression meets ([`ConstraintExpression::evaluate`]) is /// returned whatever the conditions left unevaluated would say, and `not` @@ -991,6 +1054,20 @@ impl PropertyConstraint { Ok(true) } PropertyConstraint::Not(condition) => Ok(!condition.holds(data, system)?), + PropertyConstraint::IfThen { + condition, + then, + otherwise, + } => { + if condition.holds(data, system)? { + then.holds(data, system) + } else { + otherwise + .as_ref() + .map_or(Ok(true), |otherwise| otherwise.holds(data, system)) + } + } + PropertyConstraint::NotIn(condition) => Ok(!condition.holds(data, system)?), } } @@ -1056,6 +1133,19 @@ impl PropertyConstraint { conditions.iter().map(PropertyConstraint::node_count).sum() } PropertyConstraint::Not(condition) => condition.node_count(), + PropertyConstraint::IfThen { + condition, + then, + otherwise, + } => { + condition.node_count() + + then.node_count() + + otherwise + .as_ref() + .map_or(0, |otherwise| otherwise.node_count()) + } + // The `in`'s own nodes, the negation adding none + PropertyConstraint::NotIn(condition) => condition.node_count() - 1, } } @@ -1093,7 +1183,17 @@ impl PropertyConstraint { PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { conditions.iter().any(PropertyConstraint::reads_owner) } - PropertyConstraint::Not(condition) => condition.reads_owner(), + PropertyConstraint::Not(condition) | PropertyConstraint::NotIn(condition) => { + condition.reads_owner() + } + PropertyConstraint::IfThen { + condition, + then, + otherwise, + } => [Some(condition), Some(then), otherwise.as_ref()] + .into_iter() + .flatten() + .any(|part| part.reads_owner()), PropertyConstraint::Compare { .. } | PropertyConstraint::In { .. } | PropertyConstraint::TextCompare { .. } @@ -1144,7 +1244,21 @@ impl PropertyConstraint { condition.collect_system_reads(reads); } } - PropertyConstraint::Not(condition) => condition.collect_system_reads(reads), + PropertyConstraint::Not(condition) | PropertyConstraint::NotIn(condition) => { + condition.collect_system_reads(reads) + } + PropertyConstraint::IfThen { + condition, + then, + otherwise, + } => { + for part in [Some(condition), Some(then), otherwise.as_ref()] + .into_iter() + .flatten() + { + part.collect_system_reads(reads); + } + } PropertyConstraint::TextCompare { .. } | PropertyConstraint::TextCompareProperties { .. } | PropertyConstraint::TextIn { .. } @@ -1202,7 +1316,21 @@ impl PropertyConstraint { condition.collect_text_affixes(affixes); } } - PropertyConstraint::Not(condition) => condition.collect_text_affixes(affixes), + PropertyConstraint::Not(condition) | PropertyConstraint::NotIn(condition) => { + condition.collect_text_affixes(affixes) + } + PropertyConstraint::IfThen { + condition, + then, + otherwise, + } => { + for part in [Some(condition), Some(then), otherwise.as_ref()] + .into_iter() + .flatten() + { + part.collect_text_affixes(affixes); + } + } _ => {} } } @@ -1228,7 +1356,21 @@ impl PropertyConstraint { condition.collect_text_properties(properties); } } - PropertyConstraint::Not(condition) => condition.collect_text_properties(properties), + PropertyConstraint::Not(condition) | PropertyConstraint::NotIn(condition) => { + condition.collect_text_properties(properties) + } + PropertyConstraint::IfThen { + condition, + then, + otherwise, + } => { + for part in [Some(condition), Some(then), otherwise.as_ref()] + .into_iter() + .flatten() + { + part.collect_text_properties(properties); + } + } PropertyConstraint::TextAffix { text, affix, .. } => { for side in [text, affix] { if let TextOperand::Property(property) = side { @@ -1266,7 +1408,21 @@ impl PropertyConstraint { condition.collect_text_constants(constants); } } - PropertyConstraint::Not(condition) => condition.collect_text_constants(constants), + PropertyConstraint::Not(condition) | PropertyConstraint::NotIn(condition) => { + condition.collect_text_constants(constants) + } + PropertyConstraint::IfThen { + condition, + then, + otherwise, + } => { + for part in [Some(condition), Some(then), otherwise.as_ref()] + .into_iter() + .flatten() + { + part.collect_text_constants(constants); + } + } // Checked against the enum of the array's elements PropertyConstraint::Contains { array, @@ -1285,7 +1441,9 @@ impl PropertyConstraint { } } - /// Where an `anyOf` or `allOf` of the rule lists the same condition twice: + /// Where an `anyOf` or `allOf` of the rule lists the same condition twice, + /// or an `ifThen` or `ifThenElse` holds two alike (a then-branch equal to + /// the condition, or two equal branches, says what a simpler rule says): /// the repeat's place and the earlier one's (`anyOf[2]` and `anyOf[0]`), /// the first found in declared order, `None` when no list does. Conditions /// are alike when they parse alike, so `1` and `1.0` are the same value, @@ -1313,7 +1471,8 @@ impl PropertyConstraint { | PropertyConstraint::IdentifierIn { .. } | PropertyConstraint::Contains { .. } | PropertyConstraint::Present(_) - | PropertyConstraint::Absent(_) => return None, + | PropertyConstraint::Absent(_) + | PropertyConstraint::NotIn(_) => return None, PropertyConstraint::AnyOf(conditions) => (ANY_OF, conditions), PropertyConstraint::AllOf(conditions) => (ALL_OF, conditions), PropertyConstraint::Not(condition) => { @@ -1322,6 +1481,39 @@ impl PropertyConstraint { at.truncate(parent); return found; } + PropertyConstraint::IfThen { + condition, + then, + otherwise, + } => { + let key = if otherwise.is_some() { + IF_THEN_ELSE + } else { + IF_THEN + }; + let parent = enter(at, key); + let parts: Vec<&PropertyConstraint> = + [Some(condition), Some(then), otherwise.as_ref()] + .into_iter() + .flatten() + .map(|part| part.as_ref()) + .collect(); + let base = at.len(); + for (index, part) in parts.iter().enumerate() { + if let Some(earlier) = parts[..index].iter().position(|earlier| earlier == part) + { + return Some((format!("{at}[{index}]"), format!("{at}[{earlier}]"))); + } + // Writing to a `String` cannot fail + let _ = write!(at, "[{index}]"); + if let Some(found) = part.find_repeated_condition(at) { + return Some(found); + } + at.truncate(base); + } + at.truncate(parent); + return None; + } }; let parent = enter(at, key); let base = at.len(); @@ -1410,7 +1602,21 @@ impl PropertyConstraint { condition.collect_property_reads(reads); } } - PropertyConstraint::Not(condition) => condition.collect_property_reads(reads), + PropertyConstraint::Not(condition) | PropertyConstraint::NotIn(condition) => { + condition.collect_property_reads(reads) + } + PropertyConstraint::IfThen { + condition, + then, + otherwise, + } => { + for part in [Some(condition), Some(then), otherwise.as_ref()] + .into_iter() + .flatten() + { + part.collect_property_reads(reads); + } + } } } } @@ -1520,8 +1726,8 @@ fn single_entry(value: &Value) -> Option<(&str, &Value)> { /// Every key a condition object may hold, for the errors. fn condition_keys() -> String { format!( - "a comparison ({}), in, startsWith, endsWith, contains, present, absent, anyOf, allOf \ - or not", + "a comparison ({}), in, notIn, startsWith, endsWith, contains, present, absent, \ + anyOf, allOf, not, ifThen or ifThenElse", ConstraintComparison::ALL .map(ConstraintComparison::wire_name) .join(", ") @@ -1597,6 +1803,12 @@ fn parse_condition( condition inside it says: declare that condition" )); } + if single_entry(body).is_some_and(|(inner, _)| inner == NOT_IN) { + return Err(format!( + "at {at}.{NOT_IN} is a notIn directly inside a not, which says what an in \ + of the same values says: declare that in" + )); + } PropertyConstraint::Not(Box::new(parse_condition( body, at, @@ -1604,10 +1816,45 @@ fn parse_condition( property_kind, )?)) } - IN => { + IF_THEN | IF_THEN_ELSE => { + let base = at.len(); + let mut part = |index: usize, value: &Value| { + // Writing to a `String` cannot fail + let _ = write!(at, "[{index}]"); + let parsed = parse_condition(value, at, depth + 1, property_kind); + at.truncate(base); + parsed.map(Box::new) + }; + match (key, body.as_array().map(Vec::as_slice)) { + (IF_THEN, Some([condition, then])) => PropertyConstraint::IfThen { + condition: part(0, condition)?, + then: part(1, then)?, + otherwise: None, + }, + (IF_THEN_ELSE, Some([condition, then, otherwise])) => PropertyConstraint::IfThen { + condition: part(0, condition)?, + then: part(1, then)?, + otherwise: Some(part(2, otherwise)?), + }, + (IF_THEN, _) => { + return Err(format!( + "at {at} must list two conditions: the condition, then the one that \ + must hold when it does" + )); + } + _ => { + return Err(format!( + "at {at} must list three conditions: the condition, the one that must \ + hold when it does, and the one that must hold when it does not" + )); + } + } + } + IN | NOT_IN => { let Some([operand, values]) = body.as_array().map(Vec::as_slice) else { return Err(format!( - "at {at} must list an integer expression and the values it may take" + "at {at} must list an integer expression and the values it may {}take", + if key == NOT_IN { "not " } else { "" } )); }; let base = at.len(); @@ -1621,7 +1868,7 @@ fn parse_condition( let identifier_path = operand .as_text() .filter(|path| over_strings && kind_of(path) == Some(EqualityKind::Identifier)); - if let Some(path) = identifier_path { + let listed = if let Some(path) = identifier_path { at.push_str("[1]"); let values = in_identifier_values(values, at)?; at.truncate(base); @@ -1634,7 +1881,8 @@ fn parse_condition( else { return Err(format!( "at {at}[0] must be the path of a string property or an ifAbsent giving \ - one a string default: an in over strings reads a string property" + one a string default: {} over strings reads a string property", + if key == NOT_IN { "a notIn" } else { "an in" } )); }; at.push_str("[1]"); @@ -1656,6 +1904,11 @@ fn parse_condition( let values = in_values(values, at)?; at.truncate(base); PropertyConstraint::In { operand, values } + }; + if key == NOT_IN { + PropertyConstraint::NotIn(Box::new(listed)) + } else { + listed } } STARTS_WITH | ENDS_WITH => { @@ -2195,6 +2448,16 @@ fn parse_expression( } } ADD => ConstraintExpression::Add(operand_list(operands, at, depth + 1)?), + MIN => ConstraintExpression::Min(operand_list(operands, at, depth + 1)?), + MAX => ConstraintExpression::Max(operand_list(operands, at, depth + 1)?), + ABS => { + if operands.as_array().is_some() { + return Err(format!( + "at {at} must be one operand, not a list: abs takes a single operand" + )); + } + ConstraintExpression::Abs(Box::new(parse_expression(operands, at, depth + 1)?)) + } MULTIPLY => ConstraintExpression::Multiply(operand_list(operands, at, depth + 1)?), SUBTRACT => { let (left, right) = operand_pair(operands, at, depth + 1)?; diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index 6c420213353..40f3124cb2d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -1590,8 +1590,9 @@ fn should_parse_present_and_absent() { ( platform_value!({ "exists": "discount" }), "rule \"rule\" names \"exists\", which is not a comparison (equal, notEqual, \ - lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual), in, startsWith, \ - endsWith, contains, present, absent, anyOf, allOf or not", + lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual), in, notIn, \ + startsWith, endsWith, contains, present, absent, anyOf, allOf, not, ifThen or \ + ifThenElse", ), ] { expect_refusal(platform_value!({ "rule": condition }), needle); @@ -2196,7 +2197,7 @@ fn should_refuse_a_malformed_size_operand() { ( platform_value!({ "size": "title" }), "names \"size\", which is not one of add, subtract, multiply, divide, modulo, \ - power, ifAbsent, length, byteLength or count", + power, min, max, abs, ifAbsent, length, byteLength or count", ), ] { expect_refusal( @@ -2482,7 +2483,46 @@ fn should_not_judge_a_rule_reading_a_system_value_not_given() { /// transfer's time and heights, a price update those reading the update's. #[test] fn should_tell_which_writes_a_rule_answers_to() { + let banned = Identifier::new([9; 32]).to_string(Encoding::Base58); + let also_banned = Identifier::new([8; 32]).to_string(Encoding::Base58); for (rule, transfer, price_update) in [ + // notIn, ifThen and ifThenElse answer to what their conditions read + ( + platform_value!({ "notIn": ["$ownerId", [banned.clone(), also_banned.clone()]] }), + true, + false, + ), + ( + platform_value!({ "notIn": ["$updatedAtBlockHeight", [1, 2]] }), + false, + true, + ), + ( + platform_value!({ + "ifThen": [{ "present": "endsAt" }, { "lessThan": ["$transferredAt", "endsAt"] }] + }), + true, + false, + ), + ( + platform_value!({ + "ifThen": [{ "lessThan": ["$updatedAt", 5] }, { "present": "endsAt" }] + }), + false, + true, + ), + // The else branch counts though it is taken only when the condition fails + ( + platform_value!({ + "ifThenElse": [ + { "absent": "endsAt" }, + { "present": "note" }, + { "lessThan": ["$transferredAt", "endsAt"] } + ] + }), + true, + false, + ), ( platform_value!({ "lessThan": ["$transferredAt", "endsAt"] }), true, @@ -2979,3 +3019,365 @@ fn should_test_whether_a_string_starts_or_ends_with_another() { None ); } + +// ── min, max, abs, ifThen, ifThenElse and notIn ────────────────────────── + +/// `min` and `max` take two or more operands and evaluate every one; `abs` +/// takes one. Each is one node plus its operands. +#[test] +fn should_evaluate_min_max_and_abs() { + let values = data(&[ + ("a", Value::U64(5)), + ("b", Value::U64(2)), + ("zero", Value::U64(0)), + ]); + for (expression, expected) in [ + (platform_value!({ "min": ["a", "b", 3] }), 2), + (platform_value!({ "max": ["a", "b", 3] }), 5), + ( + platform_value!({ "max": [{ "subtract": ["b", "a"] }, -10] }), + -3, + ), + (platform_value!({ "abs": { "subtract": ["b", "a"] } }), 3), + (platform_value!({ "abs": "a" }), 5), + (platform_value!({ "min": ["missing", "a"] }), 0), + ] { + assert_eq!( + evaluate(expression.clone(), &values), + Ok(expected), + "{expression:?}" + ); + } + + // Every operand is evaluated: a later, smaller one does not hide a fault + assert_eq!( + evaluate( + platform_value!({ "min": [{ "divide": ["a", "zero"] }, -1] }), + &values + ), + Err(PropertyConstraintViolation::DivisionByZero) + ); + // The absolute value of the least i128 does not fit + assert_eq!( + evaluate( + platform_value!({ "abs": { "subtract": [Value::I128(i128::MIN + 1), 1] } }), + &values + ), + Err(PropertyConstraintViolation::Overflow) + ); + + let rule = parse_rule_value(platform_value!({ + "lessThanOrEqual": [{ "abs": { "subtract": ["a", "b"] } }, { "max": ["a", "b", 3] }] + })); + assert_eq!(rule.node_count(), 9); + assert_eq!(rule.property_paths(), ["a", "b", "a", "b"]); + + for (rule, needle) in [ + ( + platform_value!({ "equal": [{ "min": ["a"] }, 1] }), + "at equal[0].min must list two or more operands", + ), + ( + platform_value!({ "equal": [{ "max": "a" }, 1] }), + "at equal[0].max must list two or more operands", + ), + ( + platform_value!({ "equal": [{ "abs": ["a"] }, 1] }), + "at equal[0].abs must be one operand, not a list: abs takes a single operand", + ), + ] { + expect_refusal(platform_value!({ "rule": rule }), needle); + } +} + +/// `ifThen` holds when its second condition holds whenever its first does; +/// the second is evaluated only when the first holds, and a fault in either +/// breaks the rule. +#[test] +fn should_hold_the_then_branch_of_an_if_then_only_when_its_condition_holds() { + let none = DocumentSystemValues::default(); + let rule = parse_rule_value(platform_value!({ + "ifThen": [ + { "greaterThan": ["discount", 0] }, + { "greaterThanOrEqual": [{ "divide": ["price", "discount"] }, 10] } + ] + })); + assert_eq!(rule.node_count(), 1 + 3 + 5); + assert_eq!(rule.property_paths(), ["discount", "price", "discount"]); + let offer = |price: u64, discount: u64| { + data(&[ + ("price", Value::U64(price)), + ("discount", Value::U64(discount)), + ]) + }; + // No discount: the then branch, which would divide by zero, is not evaluated + assert_eq!(rule.violation(&offer(100, 0), &none), None); + assert_eq!(rule.violation(&offer(100, 10), &none), None); + assert_eq!( + rule.violation(&offer(100, 20), &none), + Some(PropertyConstraintViolation::NotMet) + ); + // A fault in the condition breaks the rule + let faulty = parse_rule_value(platform_value!({ + "ifThen": [ + { "greaterThan": [{ "divide": ["price", "discount"] }, 0] }, + { "present": "note" } + ] + })); + assert_eq!( + faulty.violation(&offer(100, 0), &none), + Some(PropertyConstraintViolation::DivisionByZero) + ); + + // An owner read in either condition makes a transfer answer to it + let owned = parse_rule_value(platform_value!({ + "ifThen": [{ "present": "sellerId" }, { "equal": ["sellerId", "$ownerId"] }] + })); + assert!(owned.reads_owner()); + + for (rule, needle) in [ + ( + platform_value!({ "ifThen": [{ "present": "a" }] }), + "at ifThen must list two conditions: the condition, then the one that must hold \ + when it does", + ), + ( + platform_value!({ "ifThen": [{ "present": "a" }, { "present": "b" }, { "present": "c" }] }), + "at ifThen must list two conditions", + ), + ( + platform_value!({ "ifThen": { "present": "a" } }), + "at ifThen must list two conditions", + ), + ( + platform_value!({ "ifThen": [{ "present": "a" }, { "exists": "b" }] }), + "at ifThen[1] names \"exists\"", + ), + ] { + expect_refusal(platform_value!({ "rule": rule }), needle); + } +} + +/// `ifThenElse` holds its second condition when its first holds and its third +/// when it does not, evaluating only the branch taken; a fault in the condition +/// or in the branch taken breaks the rule. +#[test] +fn should_hold_the_branch_an_if_then_else_selects() { + let none = DocumentSystemValues::default(); + // A discount needs at least ten times its value in price; without one the + // price is at most 1000 + let rule = parse_rule_value(platform_value!({ + "ifThenElse": [ + { "greaterThan": ["discount", 0] }, + { "greaterThanOrEqual": [{ "divide": ["price", "discount"] }, 10] }, + { "lessThanOrEqual": ["price", 1000] } + ] + })); + assert_eq!(rule.node_count(), 1 + 3 + 5 + 3); + assert_eq!( + rule.property_paths(), + ["discount", "price", "discount", "price"] + ); + let offer = |price: u64, discount: u64| { + data(&[ + ("price", Value::U64(price)), + ("discount", Value::U64(discount)), + ]) + }; + // The then branch + assert_eq!(rule.violation(&offer(100, 10), &none), None); + assert_eq!( + rule.violation(&offer(100, 20), &none), + Some(PropertyConstraintViolation::NotMet) + ); + // The else branch, taken with no discount, so the then branch's division by + // zero is never evaluated + assert_eq!(rule.violation(&offer(1000, 0), &none), None); + assert_eq!( + rule.violation(&offer(1001, 0), &none), + Some(PropertyConstraintViolation::NotMet) + ); + + // A fault in the branch taken breaks the rule, one in the branch not taken + // does not + let faulty_else = parse_rule_value(platform_value!({ + "ifThenElse": [ + { "greaterThan": ["discount", 0] }, + { "present": "price" }, + { "greaterThan": [{ "divide": ["price", "discount"] }, 0] } + ] + })); + assert_eq!(faulty_else.violation(&offer(100, 10), &none), None); + assert_eq!( + faulty_else.violation(&offer(100, 0), &none), + Some(PropertyConstraintViolation::DivisionByZero) + ); + + // An owner read in the else branch alone makes a transfer answer to it + let owned = parse_rule_value(platform_value!({ + "ifThenElse": [ + { "absent": "sellerId" }, + { "present": "note" }, + { "equal": ["sellerId", "$ownerId"] } + ] + })); + assert!(owned.reads_owner()); + + for (rule, needle) in [ + ( + platform_value!({ "ifThenElse": [{ "present": "a" }, { "present": "b" }] }), + "at ifThenElse must list three conditions: the condition, the one that must hold \ + when it does, and the one that must hold when it does not", + ), + ( + platform_value!({ + "ifThenElse": [ + { "present": "a" }, + { "present": "b" }, + { "present": "c" }, + { "present": "d" } + ] + }), + "at ifThenElse must list three conditions", + ), + ( + platform_value!({ + "ifThenElse": [{ "present": "a" }, { "present": "b" }, { "exists": "c" }] + }), + "at ifThenElse[2] names \"exists\"", + ), + ] { + expect_refusal(platform_value!({ "rule": rule }), needle); + } +} + +/// An `ifThen` or `ifThenElse` holding two alike conditions says what a +/// simpler rule says, and is reported as a repeat, like an `anyOf` listing a +/// condition twice. +#[test] +fn should_report_an_if_then_holding_two_alike_conditions() { + let rules = parse(platform_value!({ + "same": { "ifThen": [{ "present": "a" }, { "present": "a" }] }, + "nested": { + "anyOf": [ + { "equal": ["a", 1] }, + { "ifThen": [{ "equal": ["b", 1] }, { "equal": ["b", 1.0] }] } + ] + }, + "fine": { "ifThen": [{ "present": "a" }, { "present": "b" }] }, + "sameBranches": { + "ifThenElse": [{ "present": "a" }, { "present": "b" }, { "present": "b" }] + }, + "elseIsCondition": { + "ifThenElse": [{ "present": "a" }, { "present": "b" }, { "present": "a" }] + }, + "fineElse": { + "ifThenElse": [{ "present": "a" }, { "present": "b" }, { "present": "c" }] + } + })) + .expect("parses"); + for (name, found) in [ + ("same", Some(("ifThen[1]", "ifThen[0]"))), + ("nested", Some(("anyOf[1].ifThen[1]", "anyOf[1].ifThen[0]"))), + ("fine", None), + ("sameBranches", Some(("ifThenElse[2]", "ifThenElse[1]"))), + ("elseIsCondition", Some(("ifThenElse[2]", "ifThenElse[0]"))), + ("fineElse", None), + ] { + assert_eq!( + rules[name].repeated_condition(), + found.map(|(repeat, earlier)| (repeat.to_string(), earlier.to_string())), + "{name}" + ); + } +} + +/// `notIn` takes what `in` takes, integers, strings or identifiers, holds when +/// the operand takes none of the values, and costs what the `in` costs. +#[test] +fn should_negate_an_in_with_not_in() { + let none = DocumentSystemValues::default(); + let seller = Identifier::new([5; 32]); + for (rule, in_rule) in [ + ( + platform_value!({ "notIn": ["fee", [13, 666]] }), + platform_value!({ "in": ["fee", [13, 666]] }), + ), + ( + platform_value!({ "notIn": ["status", ["banned", "hidden"]] }), + platform_value!({ "in": ["status", ["banned", "hidden"]] }), + ), + ( + platform_value!({ + "notIn": ["buyerId", [seller.to_string(Encoding::Base58), Identifier::new([6; 32]).to_string(Encoding::Base58)]] + }), + platform_value!({ + "in": ["buyerId", [seller.to_string(Encoding::Base58), Identifier::new([6; 32]).to_string(Encoding::Base58)]] + }), + ), + ] { + let negated = parse_rule_value(rule.clone()); + let listed = parse_rule_value(in_rule); + assert_eq!( + negated, + PropertyConstraint::NotIn(Box::new(listed.clone())), + "{rule:?}" + ); + assert_eq!(negated.node_count(), listed.node_count(), "{rule:?}"); + assert_eq!( + negated.property_reads(), + listed.property_reads(), + "{rule:?}" + ); + } + + let fee = parse_rule_value(platform_value!({ "notIn": ["fee", [13, 666]] })); + assert_eq!( + fee.violation(&data(&[("fee", Value::U64(10))]), &none), + None + ); + assert_eq!( + fee.violation(&data(&[("fee", Value::U64(13))]), &none), + Some(PropertyConstraintViolation::NotMet) + ); + // A string left out takes none of the values + let status = parse_rule_value(platform_value!({ "notIn": ["status", ["banned", "hidden"]] })); + assert_eq!(status.violation(&data(&[]), &none), None); + assert_eq!( + status.violation(&data(&[("status", Value::from("hidden"))]), &none), + Some(PropertyConstraintViolation::NotMet) + ); + // Its strings face the enum check, as an in's do + assert_eq!( + status.text_constants(), + [("status", "banned"), ("status", "hidden")] + ); + // A fault in the operand still breaks the rule + let divided = parse_rule_value(platform_value!({ + "notIn": [{ "divide": ["fee", "zero"] }, [1, 2]] + })); + assert_eq!( + divided.violation(&data(&[("fee", Value::U64(4))]), &none), + Some(PropertyConstraintViolation::DivisionByZero) + ); + + expect_refusal( + platform_value!({ "rule": { "notIn": ["fee"] } }), + "at notIn must list an integer expression and the values it may not take", + ); + expect_refusal( + platform_value!({ "rule": { "notIn": [5, ["a", "b"]] } }), + "a notIn over strings reads a string property", + ); + // A not over a notIn says what the in says, as a not over a not does + expect_refusal( + platform_value!({ "rule": { "not": { "notIn": ["fee", [13, 666]] } } }), + "at not.notIn is a notIn directly inside a not, which says what an in of the same \ + values says: declare that in", + ); + parse_rule_value(platform_value!({ "not": { "in": ["fee", [13, 666]] } })); + expect_refusal( + platform_value!({ "rule": { "notIn": ["fee", [1, 1]] } }), + "at notIn[1]", + ); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 75ede731274..ef0529ca9ce 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -170,13 +170,16 @@ pub struct DocumentTypeV2 { /// order they are checked (`propertyConstraints` keyword, protocol version /// 14): each a condition on the document's properties, a comparison of two /// integer expressions, of a string or an identifier property with - /// constants or with another property of its kind, an `in` list of values, - /// a `present` or `absent` test, or an `anyOf`, `allOf` or `not` of + /// constants or with another property of its kind, an `in` or `notIn` list + /// of values, a `startsWith` or `endsWith`, a `contains`, a `present` or + /// `absent` test, or an `anyOf`, `allOf`, `not`, `ifThen` or `ifThenElse` of /// conditions. Empty on document types that declare none. The parser /// (`apply_property_constraints`) holds every property an operand reads to /// be an integer or a boolean, every property compared with strings or - /// identifiers to be of that kind, and every property a rule reads to be - /// neither transient nor inside a transient object. + /// identifiers to be of that kind, every property a size measures or a + /// `contains` looks in to be of the type it reads, every system time or + /// height a rule reads to be one the type records, and every property a + /// rule reads to be neither transient nor inside a transient object. pub(in crate::data_contract) property_constraints: BTreeMap, /// How many seconds after its creation (`$createdAt`) the platform deletes each /// document of the type (`ttl` keyword, protocol version 14), `None` when the diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index 802a3731b7b..f664c9bf6bc 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -417,6 +417,64 @@ mod property_constraints_tests { }) } + /// An `offer` type with the integers [`set_valid_offer`] fills and a + /// `discount`, with one rule per shorthand operator: `depositNearTotal` + /// (`abs`: the deposit is within 5 of the order total), `discountNeedsPrice` + /// (`ifThen`: a discount needs a price of 100 or more), `feeCapped` (`max`: + /// the fee is at most 10 or a tenth of the price), `feeNotBanned` (`notIn`), + /// `noZeroTerms` (`min`: price, fee and quantity are all above 0) and + /// `quantityTiers` (`ifThenElse`: at most 10 at a price of 100 or more, at + /// most 100 below it). + fn shorthand_offer_schema() -> Value { + platform_value!({ + "type": "object", + "properties": { + "price": { "type": "integer", "minimum": 0, "position": 0 }, + "fee": { "type": "integer", "minimum": 0, "position": 1 }, + "quantity": { "type": "integer", "minimum": 0, "position": 2 }, + "deposit": { "type": "integer", "minimum": 0, "position": 3 }, + "discount": { "type": "integer", "minimum": 0, "position": 4 } + }, + "required": ["price", "fee", "quantity", "deposit"], + "propertyConstraints": { + "depositNearTotal": { + "lessThanOrEqual": [ + { + "abs": { + "subtract": [ + "deposit", + { "multiply": [{ "add": ["price", "fee"] }, "quantity"] } + ] + } + }, + 5 + ] + }, + "discountNeedsPrice": { + "ifThen": [ + { "greaterThan": ["discount", 0] }, + { "greaterThanOrEqual": ["price", 100] } + ] + }, + "feeCapped": { + "lessThanOrEqual": ["fee", { "max": [10, { "divide": ["price", 10] }] }] + }, + "feeNotBanned": { "notIn": ["fee", [7, 13]] }, + "noZeroTerms": { + "greaterThan": [{ "min": ["price", "fee", "quantity"] }, 0] + }, + "quantityTiers": { + "ifThenElse": [ + { "greaterThanOrEqual": ["price", 100] }, + { "lessThanOrEqual": ["quantity", 10] }, + { "lessThanOrEqual": ["quantity", 100] } + ] + } + }, + "additionalProperties": false + }) + } + /// An offer that meets every rule: (100 + 10) * 2 = 220. fn set_valid_offer(document: &mut Document) { document.set("price", Value::U64(100)); @@ -1874,4 +1932,97 @@ mod property_constraints_tests { ); assert_eq!(fixture.stored_offers().len(), 1); } + + /// The shorthand operators read by real creates: each of seven offers breaks + /// exactly one rule and is refused with it, and the valid offers, one down + /// each branch of the `ifThenElse`, are stored. + #[tokio::test] + async fn should_judge_min_max_abs_if_then_and_not_in_on_create() { + let mut fixture = OfferFixture::with_schema(shorthand_offer_schema()); + let set = |document: &mut Document, entries: &[(&str, u64)]| { + for (property, value) in entries { + document.set(property, Value::U64(*value)); + } + }; + + // The total is 220: a deposit of 230 is 10 away + let result = fixture + .create(|document| set(document, &[("deposit", 230)])) + .await; + expect_violated( + result, + "depositNearTotal", + PropertyConstraintViolation::NotMet, + ); + + // A discount on a price of 50 (total and deposit 120) + let result = fixture + .create(|document| { + set( + document, + &[("price", 50), ("deposit", 120), ("discount", 5)], + ) + }) + .await; + expect_violated( + result, + "discountNeedsPrice", + PropertyConstraintViolation::NotMet, + ); + + // A fee of 11 on a price of 100, above max(10, 10) (total 222) + let result = fixture + .create(|document| set(document, &[("fee", 11), ("deposit", 222)])) + .await; + expect_violated(result, "feeCapped", PropertyConstraintViolation::NotMet); + + // A banned fee of 7 (total and deposit 214) + let result = fixture + .create(|document| set(document, &[("fee", 7), ("deposit", 214)])) + .await; + expect_violated(result, "feeNotBanned", PropertyConstraintViolation::NotMet); + + // A quantity of 0 (total and deposit 0) + let result = fixture + .create(|document| set(document, &[("quantity", 0), ("deposit", 0)])) + .await; + expect_violated(result, "noZeroTerms", PropertyConstraintViolation::NotMet); + + // 11 at a price of 100, above the then branch's 10 (total and deposit 1210) + let result = fixture + .create(|document| set(document, &[("quantity", 11), ("deposit", 1210)])) + .await; + expect_violated(result, "quantityTiers", PropertyConstraintViolation::NotMet); + + // 101 at a price of 50, above the else branch's 100 (total and deposit 6060) + let result = fixture + .create(|document| { + set( + document, + &[("price", 50), ("quantity", 101), ("deposit", 6060)], + ) + }) + .await; + expect_violated(result, "quantityTiers", PropertyConstraintViolation::NotMet); + assert!(fixture.stored_offers().is_empty()); + + // (100 + 10) * 2 = 220, no discount, a fee of 10 + assert_matches!( + fixture.create(|_| {}).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + // 50 at a price of 50, within the else branch's 100 (total and deposit 3000) + assert_matches!( + fixture + .create(|document| { + set( + document, + &[("price", 50), ("quantity", 50), ("deposit", 3000)], + ) + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 2); + } } 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 9c3a3596cfe..f6975430714 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -54,9 +54,12 @@ pub struct SystemLimits { /// reached before. pub max_property_constraints: u16, /// Maximum number of nodes in one `propertyConstraints` rule: every comparison, every - /// `in` and each value it lists, every `present` or `absent` and every `anyOf`, `allOf` or - /// `not`, every arithmetic operator and every operand, an integer value, a `const` - /// or a property. An `ifAbsent` operand is one node, the default it gives included. + /// `in` and each value it lists (a `notIn` costing what its `in` costs), every + /// `contains`, `startsWith`, `endsWith`, `present` or `absent`, every `anyOf`, `allOf`, + /// `not`, `ifThen` or `ifThenElse`, every arithmetic operator (`min`, `max` and `abs` + /// included) and every operand: an integer value, a `const`, a property, a size + /// (`length`, `byteLength`, `count`) or a system time or height. An `ifAbsent` operand + /// is one node, the default it gives included. /// Refused under full validation only, like `max_property_constraints`. Read by document /// type parser generation 3 (protocol version 14) and never reached before. pub max_property_constraint_nodes: u16, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 884529d3e78..7c8333773fc 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1049,8 +1049,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `greaterThanOrEqual`) of two integer expressions built from integer /// literals, paths of integer or boolean properties (a boolean reading as 1 /// for true and 0 for false), `add`, `subtract`, `multiply`, `divide`, -/// `modulo` and `power`, and sizes: `length` and `byteLength`, the -/// characters and UTF-8 bytes of a string property, and `count`, the items +/// `modulo` and `power`, `min` and `max` over two or more operands and +/// `abs` over one, and sizes: `length` and `byteLength`, the characters and +/// UTF-8 bytes of a string property, and `count`, the items /// of an array or byte array property, each 0 for a property the document /// leaves out, and the system times and heights `$createdAt`, `$updatedAt` /// and `$transferredAt` (block times in milliseconds), each also with @@ -1076,9 +1077,13 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// leaves out holding nothing; `present` or `absent` naming a property of /// any type, whether the document holds it (the one way to tell a property /// left out from one set to 0); `anyOf` or `allOf` over two or more -/// conditions; or `not` over one. In an operand, a property the document -/// leaves out counts as 0, or as the value of an `ifAbsent` operand naming -/// it. Arithmetic is exact `i128`: `divide` and `modulo` are Euclidean (the +/// conditions; `not` over one; `ifThen` over two (the second holding +/// whenever the first does, evaluated only then) or `ifThenElse` over three +/// (the second when the first holds, the third when it does not, only the +/// branch taken evaluated), no two alike; `notIn`, an `in` negated in as +/// many nodes. In an operand, a property the document leaves out counts as +/// 0, or as the value of an `ifAbsent` operand naming it. +/// Arithmetic is exact `i128`: `divide` and `modulo` are Euclidean (the /// remainder is never negative), and an overflow, a zero divisor, a /// negative exponent or a value that is not an integer refuses the document /// rather than wrapping. Conditions are checked in declared order and no @@ -1103,8 +1108,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// compared with itself; that strings and identifiers are only compared for /// equality, and never with each other; that no `in` lists a value twice; /// that an `anyOf` or `allOf` holds none directly of its own kind and a -/// `not` no `not`; that an indexOnly type, whose deletes carry no owner, -/// reads no `$ownerId`; and that no condition or operand nests deeper than +/// `not` no `not` or `notIn`; that an indexOnly type, whose deletes carry +/// no owner, reads no `$ownerId`; and that no condition or operand nests +/// deeper than /// `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every parse. Under full /// validation it holds the limits `SystemLimits::max_property_constraints` /// (16 rules) and `max_property_constraint_nodes` (32 per rule, every diff --git a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs index fbe4deb6a97..32f48eff62e 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs @@ -38,8 +38,9 @@ const DOCUMENT_PROPERTY_CONSTRAINTS_TS: &'static str = r#" * document type records by listing it in `required` * (`PropertyConstraintSystemProperty`); * - `ifAbsent`: a property path and the integer it takes when left out; - * - `add` and `multiply` over two or more operands, `subtract`, `divide`, - * `modulo` and `power` over exactly two. Arithmetic is exact over 128-bit + * - `add`, `multiply`, `min` and `max` over two or more operands, + * `subtract`, `divide`, `modulo` and `power` over exactly two, `abs` over + * one. Arithmetic is exact over 128-bit * integers; `divide` and `modulo` are Euclidean; * - `length` and `byteLength`: the characters (as `maxLength` counts them) * and the UTF-8 bytes of a string property; `count`: the items of an array @@ -57,6 +58,9 @@ export type PropertyConstraintExpression = | { divide: [PropertyConstraintExpression, PropertyConstraintExpression] } | { modulo: [PropertyConstraintExpression, PropertyConstraintExpression] } | { power: [PropertyConstraintExpression, PropertyConstraintExpression] } + | { min: PropertyConstraintExpression[] } + | { max: PropertyConstraintExpression[] } + | { abs: PropertyConstraintExpression } | { length: string } | { byteLength: string } | { count: string }; @@ -91,8 +95,12 @@ export type PropertyConstraintEqualityOperand = * equal, an integer expression, a string or an identifier operand as its * elements are; an array the document leaves out holds nothing; * - `present` / `absent`: whether the document holds a property of any type; - * - `anyOf` / `allOf` over two or more conditions, `not` over one. Conditions - * are checked in order and no further than the outcome needs. + * - `notIn`: what `in` lists, holding when the operand takes none of the values; + * - `anyOf` / `allOf` over two or more conditions, `not` over one, `ifThen` + * over two (the second must hold when the first does) and `ifThenElse` over + * three (the second must hold when the first does, the third when it does + * not). Conditions are checked in order and no further than the outcome + * needs. */ export type PropertyConstraintCondition = | { equal: [PropertyConstraintExpression, PropertyConstraintExpression] | [PropertyConstraintEqualityOperand, PropertyConstraintEqualityOperand] } @@ -102,6 +110,7 @@ export type PropertyConstraintCondition = | { greaterThan: [PropertyConstraintExpression, PropertyConstraintExpression] } | { greaterThanOrEqual: [PropertyConstraintExpression, PropertyConstraintExpression] } | { in: [PropertyConstraintExpression, Array] | [string | { ifAbsent: [path: string, value: string] }, string[]] } + | { notIn: [PropertyConstraintExpression, Array] | [string | { ifAbsent: [path: string, value: string] }, string[]] } | { startsWith: [PropertyConstraintEqualityOperand, PropertyConstraintEqualityOperand] } | { endsWith: [PropertyConstraintEqualityOperand, PropertyConstraintEqualityOperand] } | { contains: [path: string, PropertyConstraintExpression | PropertyConstraintEqualityOperand] } @@ -109,7 +118,9 @@ export type PropertyConstraintCondition = | { absent: string } | { anyOf: PropertyConstraintCondition[] } | { allOf: PropertyConstraintCondition[] } - | { not: PropertyConstraintCondition }; + | { not: PropertyConstraintCondition } + | { ifThen: [PropertyConstraintCondition, PropertyConstraintCondition] } + | { ifThenElse: [PropertyConstraintCondition, PropertyConstraintCondition, PropertyConstraintCondition] }; /** * How a rule reads a property: `value` as an integer operand, `presence` in diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts index 8ef042ac405..adee0db8a51 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts @@ -358,6 +358,65 @@ describe('DataContract: propertyConstraints (v14)', () => { .to.deep.include({ rule: 'secureUrl', violation: 'NotMet' }); }); + it('should check ifThen, ifThenElse, notIn, min, max and abs', () => { + const contract = buildContract({ + order: { + type: 'object', + properties: { + price: { type: 'integer', minimum: 0, position: 0 }, + fee: { type: 'integer', minimum: 0, position: 1 }, + discount: { type: 'integer', minimum: 0, position: 2 }, + }, + required: ['price', 'fee'], + additionalProperties: false, + propertyConstraints: { + discountNeedsPrice: { + ifThen: [{ greaterThan: ['discount', 0] }, { greaterThanOrEqual: ['price', 100] }], + }, + feeCapped: { lessThanOrEqual: ['fee', { max: [10, { divide: ['price', 10] }] }] }, + feeNotBanned: { notIn: ['fee', [7, 13]] }, + feeTiers: { + ifThenElse: [ + { greaterThanOrEqual: ['price', 1000] }, + { lessThanOrEqual: ['fee', 50] }, + { lessThanOrEqual: ['fee', 10] }, + ], + }, + spreadSmall: { lessThanOrEqual: [{ abs: { subtract: ['price', 'fee'] } }, 1000] }, + termsPositive: { greaterThan: [{ min: ['price', 'fee'] }, 0] }, + }, + }, + }); + const order = (properties: Record) => new wasm.Document({ + properties, + documentTypeName: 'order', + dataContractId: contract.id, + ownerId, + revision: BigInt(1), + }); + const violationOf = (properties: Record) => ( + contract.checkDocumentPropertyConstraints(order(properties)) + ); + + expect(violationOf({ price: 100, fee: 10 })).to.equal(undefined); + expect(violationOf({ price: 50, fee: 5, discount: 5 })) + .to.deep.include({ rule: 'discountNeedsPrice', violation: 'NotMet' }); + expect(violationOf({ price: 100, fee: 11 })) + .to.deep.include({ rule: 'feeCapped', violation: 'NotMet' }); + expect(violationOf({ price: 100, fee: 7 })) + .to.deep.include({ rule: 'feeNotBanned', violation: 'NotMet' }); + // Each branch of the ifThenElse + expect(violationOf({ price: 1000, fee: 40 })).to.equal(undefined); + expect(violationOf({ price: 1000, fee: 60 })) + .to.deep.include({ rule: 'feeTiers', violation: 'NotMet' }); + expect(violationOf({ price: 500, fee: 20 })) + .to.deep.include({ rule: 'feeTiers', violation: 'NotMet' }); + expect(violationOf({ price: 5000, fee: 10 })) + .to.deep.include({ rule: 'spreadSmall', violation: 'NotMet' }); + expect(violationOf({ price: 100, fee: 0 })) + .to.deep.include({ rule: 'termsPositive', violation: 'NotMet' }); + }); + it('should report integer literals past Number.MAX_SAFE_INTEGER exactly, as bigint', () => { const big = 9007199254740993n; // 2 ** 53 + 1, which a number rounds const rules = { From f40ee0af5213562eccb6eb046b238dab5cc0939d Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 11:01:03 +0700 Subject: [PATCH 069/113] test(sdk): ifThen and ifThenElse rules through rs-sdk-ffi and the Swift and Kotlin SDKs (#5106) Co-authored-by: Claude Opus 5.5 --- .../queries/DocumentPropertyConstraints.kt | 15 +- .../DocumentPropertyConstraintsTest.kt | 57 ++++++- .../src/data_contract/property_constraints.rs | 152 +++++++++++++++++- .../Utils/DocumentPropertyConstraints.swift | 25 +-- .../DocumentPropertyConstraintsTests.swift | 51 +++++- 5 files changed, 276 insertions(+), 24 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt index f54e7e30c93..7ed8f7db694 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt @@ -32,13 +32,22 @@ data class DocumentPropertyConstraint( /** * The rule exactly as the document type's schema declares it, as compact * JSON text with sorted keys (every operator object has a single key, so - * sorting changes nothing a reader would notice). + * sorting changes nothing a reader would notice). Among its operators: + * sizes (`{ "length": path }`, `{ "byteLength": path }`, + * `{ "count": path }`), system times and heights as bare operands + * (`"$createdAt"`), `{ "contains": [arrayPath, value] }`, + * `{ "startsWith": [a, b] }`, `{ "endsWith": [a, b] }`, + * `{ "notIn": [operand, [values]] }`, `{ "min": [a, b, ...] }`, + * `{ "max": [a, b, ...] }`, `{ "abs": a }`, `{ "ifThen": [if, then] }` + * and `{ "ifThenElse": [if, then, else] }`. */ val ruleJson: String, /** * Every property the rule reads, in declared order, a property read twice - * listed twice. `$ownerId` and the system times and heights are no - * properties and are not listed: see [readsOwner] and [readsSystem]. + * listed twice, every branch of an `ifThen` or `ifThenElse` included, + * whichever one a document takes. `$ownerId` and the system times and + * heights are no properties and are not listed: see [readsOwner] and + * [readsSystem], which cover every branch too. */ val reads: List, /** diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt index 24363803a8e..afe65362c8d 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt @@ -38,8 +38,8 @@ class DocumentPropertyConstraintsTest { ], "readsSystem": [], "rule": { - "anyOf": [ - { "notEqual": ["status", { "const": "closed" }] }, + "ifThen": [ + { "equal": ["status", { "const": "closed" }] }, { "present": "closedAt" } ] } @@ -212,6 +212,57 @@ class DocumentPropertyConstraintsTest { assertEquals(listOf(false, false, false), rules.map { it.readsOwner }) } + /** + * An `ifThenElse` reads what every branch reads, whichever a document + * takes: the descriptor rs-sdk-ffi's + * `should_report_every_branch_of_an_if_then_else_and_judge_the_one_taken` + * reports, the owner read in the then branch and the creation time in the + * else branch. + */ + @Test + fun `should decode what every branch of an ifThenElse reads`() { + val rule = DocumentPropertyConstraint.listFromJson( + """ + [ + { + "name": "openEndedSoldByOwner", + "readsOwner": true, + "reads": [ + { "kind": "presence", "path": "endsAt" }, + { "kind": "identifier", "path": "sellerId" }, + { "kind": "value", "path": "endsAt" } + ], + "readsSystem": ["${'$'}createdAt"], + "rule": { + "ifThenElse": [ + { "absent": "endsAt" }, + { "equal": ["sellerId", "${'$'}ownerId"] }, + { "greaterThan": ["endsAt", "${'$'}createdAt"] } + ] + } + } + ] + """.trimIndent(), + ).single() + + assertEquals("openEndedSoldByOwner", rule.name) + assertEquals( + listOf( + PropertyConstraintRead("endsAt", PropertyConstraintRead.Kind.Presence), + PropertyConstraintRead("sellerId", PropertyConstraintRead.Kind.Identifier), + PropertyConstraintRead("endsAt", PropertyConstraintRead.Kind.Value), + ), + rule.reads, + ) + assertTrue(rule.readsOwner) + assertEquals(listOf("${'$'}createdAt"), rule.readsSystem) + assertEquals( + """{"ifThenElse":[{"absent":"endsAt"},{"equal":["sellerId","${'$'}ownerId"]},""" + + """{"greaterThan":["endsAt","${'$'}createdAt"]}]}""", + rule.ruleJson, + ) + } + /** A native library built before `readsSystem` leaves the key out. */ @Test fun `should read a rule without readsSystem as reading no system value`() { @@ -235,7 +286,7 @@ class DocumentPropertyConstraintsTest { val rules = DocumentPropertyConstraint.listFromJson(rulesJson) assertEquals( - """{"anyOf":[{"notEqual":["status",{"const":"closed"}]},{"present":"closedAt"}]}""", + """{"ifThen":[{"equal":["status",{"const":"closed"}]},{"present":"closedAt"}]}""", rules[0].ruleJson, ) assertEquals("""{"greaterThanOrEqual":[{"divide":["price","fee"]},1]}""", rules[1].ruleJson) diff --git a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs index 513e30ab8c3..42b77266de8 100644 --- a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs +++ b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs @@ -439,9 +439,10 @@ mod tests { const OWNER: [u8; 32] = [1; 32]; const OTHER: [u8; 32] = [2; 32]; - /// The rules of the `offer` type, as declared: one of each family (a string - /// constant with `present`, an integer comparison, a division, `$ownerId` - /// with `absent`, and `in`), in a declaration order that is not name order. + /// The rules of the `offer` type, as declared: one of each family (an + /// `ifThen` over a string constant and `present`, an integer comparison, a + /// division, `$ownerId` with `absent`, and `in`), in a declaration order that + /// is not name order. fn offer_rules() -> serde_json::Value { json!({ "tieredFee": { "in": ["fee", [1, 10, 25]] }, @@ -450,8 +451,8 @@ mod tests { "anyOf": [{ "absent": "sellerId" }, { "equal": ["sellerId", "$ownerId"] }] }, "closedNeedsClosedAt": { - "anyOf": [ - { "notEqual": ["status", { "const": "closed" }] }, + "ifThen": [ + { "equal": ["status", { "const": "closed" }] }, { "present": "closedAt" } ] }, @@ -959,4 +960,145 @@ mod tests { assert_eq!(ended["violation"], "NotMet"); assert_eq!(open.expect("checked"), serde_json::Value::Null); } + + /// A `deal` type with two `ifThenElse` rules: `feePerPrice` (with a fee, the + /// price is at least ten times it; without one, at most 100) and + /// `openEndedSoldByOwner` (a deal without an end is sold by its owner, one + /// with an end ends after its creation). + fn branching_contract_bytes() -> Vec { + let platform_version = PlatformVersion::latest(); + let documents = platform_value!({ + "deal": { + "type": "object", + "properties": { + "price": { "type": "integer", "minimum": 0, "position": 0 }, + "fee": { "type": "integer", "minimum": 0, "position": 1 }, + "endsAt": { "type": "integer", "minimum": 0, "position": 2 }, + "sellerId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 3 + } + }, + "required": ["price", "fee", "$createdAt"], + "additionalProperties": false, + "propertyConstraints": { + "feePerPrice": { + "ifThenElse": [ + { "greaterThan": ["fee", 0] }, + { "greaterThanOrEqual": [{ "divide": ["price", "fee"] }, 10] }, + { "lessThanOrEqual": ["price", 100] } + ] + }, + "openEndedSoldByOwner": { + "ifThenElse": [ + { "absent": "endsAt" }, + { "equal": ["sellerId", "$ownerId"] }, + { "greaterThan": ["endsAt", "$createdAt"] } + ] + } + } + } + }); + DataContractFactory::new(platform_version.protocol_version) + .expect("factory for the protocol version") + .create_with_value_config(Identifier::new(OWNER), 1, documents, None, None) + .expect("deal contract") + .data_contract() + .serialize_to_bytes_with_platform_version(platform_version) + .expect("serialized contract") + } + + /// An `ifThenElse` reports what every branch reads, the owner and the + /// system times included, and the pre-check judges only the branch its + /// condition takes: with no fee, `feePerPrice`'s division by zero is never + /// reached. + #[test] + fn should_report_every_branch_of_an_if_then_else_and_judge_the_one_taken() { + let sdk = sdk_handle(PlatformVersion::latest()); + let contract = branching_contract_bytes(); + // Open-ended and sold by its owner unless stated, so + // `openEndedSoldByOwner` holds + let violation_of = |properties: serde_json::Value| { + let mut document = json!({ "price": 100, "fee": 10, "sellerId": base58(OWNER) }); + for (key, value) in properties.as_object().expect("an object") { + document[key] = value.clone(); + } + check(sdk, &contract, "deal", document, OWNER).expect("checked") + }; + + let rules = rules_of(sdk, &contract, "deal"); + let met = violation_of(json!({})); + let fee_too_high = violation_of(json!({ "fee": 20 })); + let no_fee = violation_of(json!({ "fee": 0 })); + let no_fee_too_dear = violation_of(json!({ "price": 101, "fee": 0 })); + let sold_by_other = violation_of(json!({ "sellerId": base58(OTHER) })); + let ended = violation_of(json!({ "endsAt": 1 })); + // Ends in 2100: the else branch is taken, and the seller is not judged + let ending_sold_by_other = violation_of(json!({ + "endsAt": 4_102_444_800_000u64, + "sellerId": base58(OTHER) + })); + destroy_mock_sdk_handle(sdk); + + assert_eq!( + rules.expect("rules of deal"), + json!([ + { + "name": "feePerPrice", + "rule": { + "ifThenElse": [ + { "greaterThan": ["fee", 0] }, + { "greaterThanOrEqual": [{ "divide": ["price", "fee"] }, 10] }, + { "lessThanOrEqual": ["price", 100] } + ] + }, + "reads": [ + { "path": "fee", "kind": "value" }, + { "path": "price", "kind": "value" }, + { "path": "fee", "kind": "value" }, + { "path": "price", "kind": "value" } + ], + "readsOwner": false, + "readsSystem": [] + }, + { + "name": "openEndedSoldByOwner", + "rule": { + "ifThenElse": [ + { "absent": "endsAt" }, + { "equal": ["sellerId", "$ownerId"] }, + { "greaterThan": ["endsAt", "$createdAt"] } + ] + }, + "reads": [ + { "path": "endsAt", "kind": "presence" }, + { "path": "sellerId", "kind": "identifier" }, + { "path": "endsAt", "kind": "value" } + ], + "readsOwner": true, + "readsSystem": ["$createdAt"] + } + ]) + ); + + assert_eq!(met, serde_json::Value::Null); + // The then branch: 100 / 20 is below 10 + assert_eq!(fee_too_high["rule"], "feePerPrice"); + assert_eq!(fee_too_high["violation"], "NotMet"); + // The else branch: no division, so no division by zero + assert_eq!(no_fee, serde_json::Value::Null); + assert_eq!(no_fee_too_dear["rule"], "feePerPrice"); + assert_eq!(no_fee_too_dear["violation"], "NotMet"); + // Without an end, the owner must be the seller + assert_eq!(sold_by_other["rule"], "openEndedSoldByOwner"); + assert_eq!(sold_by_other["violation"], "NotMet"); + // With one, it must come after the create, timed by the device clock + assert_eq!(ended["rule"], "openEndedSoldByOwner"); + assert_eq!(ended["violation"], "NotMet"); + assert_eq!(ending_sold_by_other, serde_json::Value::Null); + } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift index d5855ab503a..d4f34e6425f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift @@ -24,24 +24,29 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { /// sizes (`{ "length": path }`, `{ "byteLength": path }`, /// `{ "count": path }`), system times and heights as bare operands /// (`"$createdAt"`), `{ "contains": [arrayPath, value] }`, - /// `{ "startsWith": [a, b] }` and `{ "endsWith": [a, b] }`. + /// `{ "startsWith": [a, b] }`, `{ "endsWith": [a, b] }`, + /// `{ "notIn": [operand, [values]] }`, `{ "min": [a, b, ...] }`, + /// `{ "max": [a, b, ...] }`, `{ "abs": a }`, `{ "ifThen": [if, then] }` + /// and `{ "ifThenElse": [if, then, else] }`. public let ruleJSON: String /// Every property the rule reads, in declared order, a property read twice - /// listed twice. `$ownerId` is no property and is not listed: see - /// `readsOwner`; nor are the system times and heights: see `readsSystem`. + /// listed twice. The list describes the rule, not one document: it covers + /// every branch of an `ifThen` or `ifThenElse`, whichever one a document + /// takes. `$ownerId` is no property and is not listed: see `readsOwner`; + /// nor are the system times and heights: see `readsSystem`. public let reads: [PropertyConstraintRead] - /// Whether the rule compares the document's owner, `$ownerId`: then a - /// transfer or a purchase, which changes the owner, is judged against it - /// too. + /// Whether the rule compares the document's owner, `$ownerId`, in any + /// branch, as in `reads`: then a transfer or a purchase, which changes + /// the owner, is judged against it too. public let readsOwner: Bool /// The system times and heights the rule reads, by name, in declared - /// order, one read twice listed twice: `$createdAt`, `$updatedAt` and - /// `$transferredAt` (block times, in milliseconds), each also with - /// `BlockHeight` or `CoreBlockHeight` appended (the Platform and Core - /// block heights), the names of wasm-dpp2's + /// order, one read twice listed twice, every branch included as in + /// `reads`: `$createdAt`, `$updatedAt` and `$transferredAt` (block times, + /// in milliseconds), each also with `BlockHeight` or `CoreBlockHeight` + /// appended (the Platform and Core block heights), the names of wasm-dpp2's /// `PropertyConstraintSystemProperty`. Consensus judges a price update /// against the rules reading `$updatedAt…`, and a transfer or a purchase /// against the rules reading `$transferredAt…` (or `$ownerId`). diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift index e03bcba8282..2dbb9914832 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift @@ -30,8 +30,8 @@ final class DocumentPropertyConstraintsTests: XCTestCase { { "kind": "presence", "path": "closedAt" } ], "rule": { - "anyOf": [ - { "notEqual": ["status", { "const": "closed" }] }, + "ifThen": [ + { "equal": ["status", { "const": "closed" }] }, { "present": "closedAt" } ] } @@ -179,7 +179,7 @@ final class DocumentPropertyConstraintsTests: XCTestCase { XCTAssertEqual( rules[0].ruleJSON, - #"{"anyOf":[{"notEqual":["status",{"const":"closed"}]},{"present":"closedAt"}]}"# + #"{"ifThen":[{"equal":["status",{"const":"closed"}]},{"present":"closedAt"}]}"# ) XCTAssertEqual(rules[1].ruleJSON, #"{"greaterThanOrEqual":[{"divide":["price","fee"]},1]}"#) XCTAssertEqual( @@ -188,6 +188,51 @@ final class DocumentPropertyConstraintsTests: XCTestCase { ) } + /// An `ifThenElse` lists what every branch reads, whichever one a document + /// takes, the owner and the system times included (rs-sdk-ffi's + /// `should_report_every_branch_of_an_if_then_else_and_judge_the_one_taken`). + func testIfThenElseReadsEveryBranch() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: """ + [ + { + "name": "openEndedSoldByOwner", + "rule": { + "ifThenElse": [ + { "absent": "endsAt" }, + { "equal": ["sellerId", "$ownerId"] }, + { "greaterThan": ["endsAt", "$createdAt"] } + ] + }, + "reads": [ + { "path": "endsAt", "kind": "presence" }, + { "path": "sellerId", "kind": "identifier" }, + { "path": "endsAt", "kind": "value" } + ], + "readsOwner": true, + "readsSystem": ["$createdAt"] + } + ] + """) + + XCTAssertEqual(rules.count, 1) + let rule = try XCTUnwrap(rules.first) + XCTAssertEqual(rule.name, "openEndedSoldByOwner") + XCTAssertEqual( + rule.reads, + [ + PropertyConstraintRead(path: "endsAt", kind: .presence), + PropertyConstraintRead(path: "sellerId", kind: .identifier), + PropertyConstraintRead(path: "endsAt", kind: .value) + ] + ) + XCTAssertTrue(rule.readsOwner) + XCTAssertEqual(rule.readsSystem, ["$createdAt"]) + XCTAssertEqual( + rule.ruleJSON, + #"{"ifThenElse":[{"absent":"endsAt"},{"equal":["sellerId","$ownerId"]},{"greaterThan":["endsAt","$createdAt"]}]}"# + ) + } + func testPrettyRuleJSONIndentsTheSameRule() throws { let rule = try XCTUnwrap(DocumentPropertyConstraint.list(fromJSON: rulesJSON).first) From fc6e39488ad2b3e9f48dd70d84a8cdaaf1586c6c Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 12:33:33 +0700 Subject: [PATCH 070/113] feat(platform)!: elected moderation windows may be 0 off mainnet, mainnet keeps one day (PV14) (#5108) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/contested-documents.md | 5 +- book/src/data-model/contract-moderation.md | 4 +- .../config/moderation/elected.rs | 62 +++++++++++-- .../data_contract/config/moderation/mod.rs | 54 ++++++++---- .../contract_user_moderation/tests.rs | 74 ++++++++++++++-- .../basic_structure/v2/mod.rs | 7 +- .../basic_structure/v2/mod.rs | 7 +- .../masternode_vote/charter_election_tests.rs | 87 +++++++++++++++++++ .../fetch_charter_election_windows/mod.rs | 5 +- .../src/version/mocks/v2_test.rs | 2 +- .../src/version/system_limits/mod.rs | 8 +- .../src/version/system_limits/v1.rs | 8 +- .../src/version/system_limits/v2.rs | 8 +- .../src/version/system_limits/v3.rs | 8 +- .../src/version/system_limits/v4.rs | 13 +-- .../rs-platform-version/src/version/v14.rs | 5 +- packages/wasm-dpp2/src/data_contract/model.rs | 5 +- 17 files changed, 299 insertions(+), 63 deletions(-) diff --git a/book/src/data-model/contested-documents.md b/book/src/data-model/contested-documents.md index 55e6cbb06a1..12e0a18cbcb 100644 --- a/book/src/data-model/contested-documents.md +++ b/book/src/data-model/contested-documents.md @@ -84,8 +84,9 @@ An `electedCharter` contest of the moderation charters contract (protocol versio the target contract id, is a **moderation election** and does not take the generic parameters: - Its join window and vote window are the `joinWindow` and `voteWindow` of the target contract's - elected moderation declaration (one day to four weeks each, one week by default), on every - network. A single applicant wins when the join window closes; a second applicant moves the end + elected moderation declaration (at most four weeks each, one week by default; at least a day + on mainnet, while any other network takes 0), in place of the generic windows of the network. + A single applicant wins when the join window closes; a second applicant moves the end to the join window plus the vote window. A late applicant is refused with `DocumentContestNotJoinableError` naming the target's join window. - Each application prefunds the votes with the moderation fund, 0.5 Dash diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index f4b57bd5971..6c3d7b556d1 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -275,7 +275,7 @@ A contract may hand the choice of its moderators to the network instead of keepi ```rust pub struct ElectedModerators { - pub join_window: u32, // seconds; 1 day to 4 weeks, 1 week by default + pub join_window: u32, // seconds; at most 4 weeks, at least 1 day on mainnet (0 elsewhere), 1 week by default pub vote_window: u32, // the same pub challenge_cool_down: Option, // Some = seat contestable, seconds, 2 weeks to 3 years; None = never contested again pub election_delay: Option, // seconds after creation before the first charter; unbounded, none = at once @@ -288,7 +288,7 @@ pub struct ElectedModerators { The declaration lives in `packages/rs-dpp/src/data_contract/config/moderation/elected.rs`. On the wire it is the third `$type` of the moderators, flat: `{"$type": "elected", "seatContestable": true, "challengeCoolDown": 1209600, "moderatedDocumentTypes": {"post": ["ban", "deleteDocuments"]}, "interim": {"$type": "notYetUsable"}}`, or `"seatContestable": false` without a `challengeCoolDown`, with `joinWindow`, `voteWindow`, `electionDelay`, `maxAddedModerators` and `ownerProtected` optional. Its parts: -- **The election parameters** are fixed once set (`SystemLimits`: `min_contract_moderation_election_window_seconds` and `max_contract_moderation_election_window_seconds` bound both windows). The join window is how long applicants may join an election once the first one applied, the vote window how long masternodes then vote. The **election delay** is the one parameter the contract sets freely: how many seconds after its creation the first charter may be filed against it, the notice the contract gives before its first election can be called. It is optional and unbounded; left out, the election may be called at once. Because the declaration is made at the contract's creation and never changes, the creation is the declaration's own time. The charter contract's `targetContractId` reads it through the `moderation: "electionOpen"` requirement below. **`maxAddedModerators`** says how many members the leader of a seated team may add after the election, each one an identity that asked to join the team's proposal: additions ever filed, so a removal or a resignation frees no slot. It is 0 when left out, a team then being exactly what was elected, and at most `SystemLimits::max_contract_moderation_added_moderators` (15). +- **The election parameters** are fixed once set (`SystemLimits`: `max_contract_moderation_election_window_seconds` caps both windows at four weeks, and on mainnet `min_mainnet_contract_moderation_election_window_seconds` keeps them at least a day; every other network takes a window of 0, so a test election can be run through in a block or two). The join window is how long applicants may join an election once the first one applied, the vote window how long masternodes then vote. The **election delay** is the one parameter the contract sets freely: how many seconds after its creation the first charter may be filed against it, the notice the contract gives before its first election can be called. It is optional and unbounded; left out, the election may be called at once. Because the declaration is made at the contract's creation and never changes, the creation is the declaration's own time. The charter contract's `targetContractId` reads it through the `moderation: "electionOpen"` requirement below. **`maxAddedModerators`** says how many members the leader of a seated team may add after the election, each one an identity that asked to join the team's proposal: additions ever filed, so a removal or a resignation frees no slot. It is 0 when left out, a team then being exactly what was elected, and at most `SystemLimits::max_contract_moderation_added_moderators` (15). - **The seat** is contestable or not, and the contract says which: `seatContestable` is required, with no default. A default of false would make every team permanent, leaving a contract nothing to do about a leader who lost its keys or went rogue, since the leader can not change and a challenge is the only remedy; a default of true would opt every contract into challenges without it asking. A contestable seat declares its **challenge cool-down**, how long a seated team is safe from a challenge after a seat change (`min_contract_moderation_challenge_cool_down_seconds` to `max_contract_moderation_challenge_cool_down_seconds`, two weeks to three years), and a seat that can not be contested declares none, since a cool-down means nothing there: a declaration missing the key, or `seatContestable: true` without the cool-down, or `false` with one, does not parse. In Rust the two are one field, `challenge_cool_down: Option` (`ElectedModerators::seat_contestable` is `is_some`), so a declaration can not disagree with itself however it is encoded. Nothing reads the seat yet: challenges come after protocol version 14, a challenge then being a new contest on the same `byTargetContract` index of the charter contract, allowed only when the target declares its seat contestable. The key is there now because the declaration is frozen at the contract's creation. Until challenges ship a seat is never contested again, whatever the key says. - **The moderated set** is the document types the team moderates, each with the abilities the seated team holds on it: non-empty, each type a document type of the contract, each ability set non-empty and backed by the contract (`ban` needs the banlist, `suspend` the suspension list, `warn` the warning list, `deleteDocuments` the type itself flagged `canBeDeletedByModerators`, so deletions reach only flagged types, within their window). The charter of a team will say how those types are moderated, never which. The lists stay contract-wide: an ability on a type is what a team may do over the documents of that type. The set also bounds the interim block. A charter does not price the moderators part of an action: a type's own `actionFees.moderators` amount is the most a team may charge, a charter charges a share of it (the charter contract's business, not the declaration's), and the owner part stays what the type declares, immutable as before. - **The interim** says who moderates until a team is seated. `ContractOwner` and `AppointedModerators(set)` are the merged kinds, with their authority, their limit and their existence check (41110 at create): they moderate, they are protected, and they are the team that claims the moderators pot, all of it until a charter is seated and none of it after (see The seated team below). `NotYetUsable` names nobody: nobody moderates, nobody claims the pot (it accumulates for the team to come, `ContractFeeClaimNotAllowedError` for everyone), and the moderated document types can not be used. A contract that never attracts a team keeps those types unusable for good; the other types work as on an unmoderated contract. `NoModeration` names nobody too, with the moderated types usable meanwhile: nobody moderates and nobody claims the pot, and every type works as on an unmoderated contract until a team is seated. diff --git a/packages/rs-dpp/src/data_contract/config/moderation/elected.rs b/packages/rs-dpp/src/data_contract/config/moderation/elected.rs index 0ac813e5479..571e8f56ea2 100644 --- a/packages/rs-dpp/src/data_contract/config/moderation/elected.rs +++ b/packages/rs-dpp/src/data_contract/config/moderation/elected.rs @@ -20,6 +20,7 @@ use crate::prelude::TimestampMillis; #[cfg(feature = "json-conversion")] use crate::serialization::JsonSafeFields; use bincode::{Decode, DecodeUntrusted, Encode}; +use dashcore::Network; use platform_value::{Identifier, Value}; use platform_version::version::PlatformVersion; use serde::{Deserialize, Serialize}; @@ -311,9 +312,11 @@ impl fmt::Display for InterimModerators { #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] pub struct ElectedModerators { /// How long, in seconds, applicants may join an election once the first one applied. - /// `SystemLimits::min_contract_moderation_election_window_seconds` to - /// `SystemLimits::max_contract_moderation_election_window_seconds` (one day to four - /// weeks); [`DEFAULT_ELECTION_WINDOW_SECONDS`] when the declaration leaves it out. + /// At most `SystemLimits::max_contract_moderation_election_window_seconds` (four weeks), + /// and on mainnet at least + /// `SystemLimits::min_mainnet_contract_moderation_election_window_seconds` (one day); + /// any other network takes 0. [`DEFAULT_ELECTION_WINDOW_SECONDS`] when the declaration + /// leaves it out. pub join_window: u32, /// How long, in seconds, masternodes vote once the join window closed. The same bounds /// and default. @@ -429,10 +432,14 @@ impl ElectedModerators { /// the windows, and the cool-down of a contestable seat, within the limits, the moderated /// set non-empty, each of its types a document type of the contract with a non-empty /// ability set the contract backs. The first rule broken is the reason returned. + /// + /// The windows have a floor on mainnet only: any other network takes a window of 0, so + /// an election there can be run through in a block or two. pub(super) fn validation_error( &self, config: &ContractModerationConfig, document_schemas: &BTreeMap, + network: Network, platform_version: &PlatformVersion, ) -> Option { let limits = &platform_version.system_limits; @@ -441,7 +448,10 @@ impl ElectedModerators { format!("the {what} of {seconds} seconds is outside {min} to {max} seconds") }) }; - let window_min = limits.min_contract_moderation_election_window_seconds; + let window_min = match network { + Network::Mainnet => limits.min_mainnet_contract_moderation_election_window_seconds, + _ => 0, + }; let window_max = limits.max_contract_moderation_election_window_seconds; if let Some(reason) = within("join window", self.join_window, window_min, window_max) .or_else(|| within("vote window", self.vote_window, window_min, window_max)) @@ -616,10 +626,16 @@ mod tests { } } - /// The reason the declaration is refused for, `None` when it is accepted + /// The reason the declaration is refused for on mainnet, whose bounds are the strictest, + /// `None` when it is accepted fn refusal(config: &ContractModerationConfig) -> Option { + refusal_on(Network::Mainnet, config) + } + + /// The reason the declaration is refused for on `network`, `None` when it is accepted + fn refusal_on(network: Network, config: &ContractModerationConfig) -> Option { let result = config - .validate(&schemas(), PlatformVersion::latest()) + .validate(&schemas(), network, PlatformVersion::latest()) .expect("validate"); (!result.is_valid()).then(|| rendered(&result.errors)) } @@ -642,7 +658,7 @@ mod tests { #[test] fn should_accept_every_bound_and_refuse_one_second_outside_each() { let limits = &PlatformVersion::latest().system_limits; - let window_min = limits.min_contract_moderation_election_window_seconds; + let window_min = limits.min_mainnet_contract_moderation_election_window_seconds; let window_max = limits.max_contract_moderation_election_window_seconds; let cool_down_min = limits.min_contract_moderation_challenge_cool_down_seconds; let cool_down_max = limits.max_contract_moderation_challenge_cool_down_seconds; @@ -683,6 +699,38 @@ mod tests { } } + /// The windows have a floor on mainnet only, one day: every other network takes 0, and + /// its elections resolve in a block or two. The four-week ceiling holds everywhere. + #[test] + fn should_floor_the_windows_on_mainnet_only() { + let with_windows = |seconds: u32| { + let mut declaration = elected(); + declaration.join_window = seconds; + declaration.vote_window = seconds; + config(declaration) + }; + + let on_mainnet = refusal_on(Network::Mainnet, &with_windows(0)).expect("refused"); + assert!( + on_mainnet.contains("the join window of 0 seconds is outside 86400 to 2419200 seconds"), + "{on_mainnet}" + ); + assert_eq!(refusal_on(Network::Mainnet, &with_windows(86_400)), None); + + for network in [Network::Testnet, Network::Devnet, Network::Regtest] { + assert_eq!( + refusal_on(network, &with_windows(0)), + None, + "0 on {network:?}" + ); + let above = refusal_on(network, &with_windows(2_419_201)).expect("refused"); + assert!( + above.contains("the join window of 2419201 seconds is outside 0 to 2419200"), + "{above}" + ); + } + } + #[test] fn should_bound_the_cool_down_of_a_contestable_seat_only() { let mut permanent = elected(); diff --git a/packages/rs-dpp/src/data_contract/config/moderation/mod.rs b/packages/rs-dpp/src/data_contract/config/moderation/mod.rs index bea32fe3bdf..1657e5648b6 100644 --- a/packages/rs-dpp/src/data_contract/config/moderation/mod.rs +++ b/packages/rs-dpp/src/data_contract/config/moderation/mod.rs @@ -27,6 +27,7 @@ use crate::serialization::JsonSafeFields; use crate::validation::SimpleConsensusValidationResult; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; +use dashcore::Network; use platform_value::{Identifier, Value}; use platform_version::version::PlatformVersion; use serde::{Deserialize, Serialize}; @@ -576,10 +577,12 @@ impl ContractModerationConfig { /// ([`ElectedModerators::validation_error`] has its rules). /// Whether the named identities exist is state validation, done by the contract create /// and update transitions: a moderator that does not exist can never sign, so naming one - /// is a mistake, caught where it is cheapest. + /// is a mistake, caught where it is cheapest. The `network` is the one the node runs: an + /// elected declaration's windows have a floor on mainnet only. pub fn validate( &self, document_schemas: &BTreeMap, + network: Network, platform_version: &PlatformVersion, ) -> Result { match platform_version @@ -588,7 +591,7 @@ impl ContractModerationConfig { .methods .validate_moderation_config { - 0 => Ok(self.validate_v0(document_schemas, platform_version)), + 0 => Ok(self.validate_v0(document_schemas, network, platform_version)), version => Err(ProtocolError::UnknownVersionMismatch { method: "ContractModerationConfig::validate".to_string(), known_versions: vec![0], @@ -601,6 +604,7 @@ impl ContractModerationConfig { fn validate_v0( &self, document_schemas: &BTreeMap, + network: Network, platform_version: &PlatformVersion, ) -> SimpleConsensusValidationResult { let has_document_type_deletable_by_moderators = document_schemas @@ -641,11 +645,9 @@ impl ContractModerationConfig { ); } } - if let Some(reason) = self - .moderators - .elected() - .and_then(|elected| elected.validation_error(self, document_schemas, platform_version)) - { + if let Some(reason) = self.moderators.elected().and_then(|elected| { + elected.validation_error(self, document_schemas, network, platform_version) + }) { return SimpleConsensusValidationResult::new_with_error( InvalidContractModerationConfigError::new(format!("elected moderation: {reason}")) .into(), @@ -941,7 +943,11 @@ mod tests { moderators: ContractModerators::ContractOwner, }; let result = config - .validate(&BTreeMap::new(), PlatformVersion::latest()) + .validate( + &BTreeMap::new(), + Network::Mainnet, + PlatformVersion::latest(), + ) .expect("validate"); assert!(!result.is_valid()); } @@ -955,7 +961,11 @@ mod tests { moderators: ContractModerators::ContractOwner, }; let result = config - .validate(&schemas_with_a_deletable_type(), PlatformVersion::latest()) + .validate( + &schemas_with_a_deletable_type(), + Network::Mainnet, + PlatformVersion::latest(), + ) .expect("validate"); assert!(result.is_valid(), "{:?}", result.errors); assert_eq!(config.lists().count(), 0); @@ -971,7 +981,11 @@ mod tests { moderators: ContractModerators::AppointedModerators(set(&[9, 1])), }; let result = config - .validate(&BTreeMap::new(), PlatformVersion::latest()) + .validate( + &BTreeMap::new(), + Network::Mainnet, + PlatformVersion::latest(), + ) .expect("validate"); assert!(result.is_valid(), "{:?}", result.errors); // Naming the owner changes nothing about who may moderate or who is protected. @@ -992,11 +1006,11 @@ mod tests { )), }; assert!(config(max) - .validate(&BTreeMap::new(), platform_version) + .validate(&BTreeMap::new(), Network::Mainnet, platform_version) .expect("validate") .is_valid()); assert!(!config(max + 1) - .validate(&BTreeMap::new(), platform_version) + .validate(&BTreeMap::new(), Network::Mainnet, platform_version) .expect("validate") .is_valid()); } @@ -1011,7 +1025,7 @@ mod tests { moderators: ContractModerators::AppointedModerators(BTreeSet::new()), }; assert!(!empty - .validate(&BTreeMap::new(), platform_version) + .validate(&BTreeMap::new(), Network::Mainnet, platform_version) .expect("validate") .is_valid()); let too_many: Vec = @@ -1023,7 +1037,7 @@ mod tests { moderators: ContractModerators::AppointedModerators(set(&too_many)), }; assert!(!oversized - .validate(&BTreeMap::new(), platform_version) + .validate(&BTreeMap::new(), Network::Mainnet, platform_version) .expect("validate") .is_valid()); } @@ -1038,7 +1052,11 @@ mod tests { moderators: ContractModerators::AppointedModerators(set(&[1, 2, 3])), }; assert!(config - .validate(&BTreeMap::new(), PlatformVersion::latest()) + .validate( + &BTreeMap::new(), + Network::Mainnet, + PlatformVersion::latest() + ) .expect("validate") .is_valid()); assert!(config.may_moderate(&owner, &owner)); @@ -1073,7 +1091,11 @@ mod tests { moderators: ContractModerators::ContractOwner, }; let result = config - .validate(&BTreeMap::new(), PlatformVersion::latest()) + .validate( + &BTreeMap::new(), + Network::Mainnet, + PlatformVersion::latest(), + ) .expect("validate"); assert!(result.is_valid(), "{:?}", result.errors); assert_eq!( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs index 8565073736f..a5f3a6c10b5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs @@ -14,6 +14,7 @@ use dpp::consensus::codes::ErrorWithCode; use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; use dpp::dash_to_credits; +use dpp::dashcore::Network; use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; use dpp::data_contract::accessors::v1::DataContractV1Setters; use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; @@ -3330,12 +3331,12 @@ async fn should_refuse_an_elected_declaration_the_contract_can_not_back() { }; for (moderation, what) in [ ( - with(|d| d.join_window = 86_399), - "a join window under a day", + with(|d| d.join_window = 2_419_201), + "a join window over four weeks", ), ( - with(|d| d.vote_window = 86_399), - "a vote window under a day", + with(|d| d.vote_window = 2_419_201), + "a vote window over four weeks", ), ( with(|d| d.challenge_cool_down = Some(94_608_001)), @@ -3380,7 +3381,23 @@ async fn should_refuse_an_elected_declaration_the_contract_can_not_back() { "expected {what} to name the declaration, got {execution:?}" ); } - // The bounds hold: the same declaration at its minimums is accepted. + // The bounds hold: the same declaration at its maximums is accepted, and off mainnet + // (the test platform runs on testnet) so are windows of 0. + for windows in [2_419_200, 0] { + let mut declaration = with(|_| {}); + if let ContractModerators::Elected(elected) = &mut declaration.moderators { + elected.join_window = windows; + elected.vote_window = windows; + } + setup + .contract + .set_config(contract.config().clone().with_moderation(Some(declaration))); + let create = setup + .contract_create(setup.owner.identity_nonce(), PlatformVersion::latest()) + .await; + assert_success(&setup.process(&create, &transaction)); + } + // The same declaration at the mainnet minimum is accepted. setup .contract .set_config(contract.config().clone().with_moderation(Some(with(|d| { @@ -3418,6 +3435,53 @@ async fn should_refuse_an_elected_declaration_the_contract_can_not_back() { "entering elected moderation", ); } + +/// On mainnet an elected declaration's windows are at least a day: a window of 86,399 +/// seconds is refused, unpaid, at the create, and 86,400 is accepted. The floor is read from +/// the network the node runs, so every other network takes 0. +#[tokio::test] +async fn should_floor_the_election_windows_at_a_day_on_mainnet() { + let mut setup = Setup::new(None).await; + setup.platform.platform.config.network = Network::Mainnet; + let transaction = setup.platform.drive.grove.start_transaction(); + let contract = setup.contract.clone(); + let with_windows = |join_window: u32, vote_window: u32| { + let mut moderation = elected(InterimModerators::ContractOwner, &[DOCUMENT_TYPE]); + if let ContractModerators::Elected(declaration) = &mut moderation.moderators { + declaration.join_window = join_window; + declaration.vote_window = vote_window; + } + moderation + }; + for (moderation, what) in [ + (with_windows(86_399, 86_400), "join window of 86399 seconds"), + (with_windows(86_400, 86_399), "vote window of 86399 seconds"), + (with_windows(0, 0), "join window of 0 seconds"), + ] { + setup + .contract + .set_config(contract.config().clone().with_moderation(Some(moderation))); + let create = setup + .contract_create(setup.owner.identity_nonce(), PlatformVersion::latest()) + .await; + let execution = setup.process(&create, &transaction); + assert_unpaid_with_code(&execution, INVALID_CONTRACT_MODERATION_CONFIG); + assert!( + matches!(&execution, StateTransitionExecutionResult::UnpaidConsensusError(error) if error.to_string().contains(what)), + "expected the {what} to be refused, got {execution:?}" + ); + } + setup.contract.set_config( + contract + .config() + .clone() + .with_moderation(Some(with_windows(86_400, 86_400))), + ); + let create = setup + .contract_create(setup.owner.identity_nonce(), PlatformVersion::latest()) + .await; + assert_success(&setup.process(&create, &transaction)); +} /// How long after a moderator's deletion a document can be restored: the protocol's week. fn restore_window_ms() -> TimestampMillis { PlatformVersion::latest() diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs index e4f819a011c..3729aa25900 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs @@ -132,8 +132,11 @@ impl DataContractCreateStateTransitionBasicStructureValidationV2 for DataContrac // an elected declaration within its bounds and naming document types of the contract). // That the named moderators exist is checked against the state. if let Some(moderation) = self.data_contract().config().moderation() { - let result = - moderation.validate(self.data_contract().document_schemas(), platform_version)?; + let result = moderation.validate( + self.data_contract().document_schemas(), + network_type, + platform_version, + )?; if !result.is_valid() { return Ok(result); } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/basic_structure/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/basic_structure/v2/mod.rs index a7bcd1bae61..233ba23649f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/basic_structure/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/basic_structure/v2/mod.rs @@ -36,8 +36,11 @@ impl DataContractUpdateStateTransitionBasicStructureValidationV2 for DataContrac } if let Some(moderation) = self.data_contract().config().moderation() { - let result = - moderation.validate(self.data_contract().document_schemas(), platform_version)?; + let result = moderation.validate( + self.data_contract().document_schemas(), + network_type, + platform_version, + )?; if !result.is_valid() { return Ok(result); } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs index 3a5e421f1e8..b9f0bbdfa03 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs @@ -793,6 +793,93 @@ async fn should_move_the_end_to_the_join_and_vote_windows_when_a_second_applican assert!(end_dates(&platform, platform_version).is_empty()); } +/// Off mainnet a target may declare windows of 0: only the applicants of the block that opened +/// the election get in, a second one moves no end, nobody has time to vote, and the next block +/// awards the seat, the tie going to the earliest application (by document id within a block). +#[tokio::test] +async fn should_award_an_election_with_windows_of_zero_in_the_next_block() { + let (mut platform, platform_version, charters, mut rng) = setup(); + let target = elected_target(&platform, 0xA9, 0, 0, platform_version); + let mut alice = applicant(&mut platform, &mut rng); + let mut bob = applicant(&mut platform, &mut rng); + let mut carol = applicant(&mut platform, &mut rng); + let poll = charter_poll(target); + + let (start, _) = apply( + &platform, + &charters, + &mut alice, + target, + 10_000, + &mut rng, + platform_version, + ) + .await; + // At the same block time, which a join window of 0 still admits + let (bob_start, _) = apply( + &platform, + &charters, + &mut bob, + target, + start - 1000, + &mut rng, + platform_version, + ) + .await; + assert_eq!(bob_start, start); + assert_eq!( + end_dates(&platform, platform_version), + vec![( + start, + VotePoll::ContestedDocumentResourceVotePoll(poll.clone()) + )], + "the election ends at the time it opened, the second applicant moving nothing" + ); + + // A second later the join window is closed + let proposal_id = propose( + &platform, + &charters, + &mut carol, + target, + start, + &mut rng, + platform_version, + ) + .await; + let late = application( + &charters, + &mut carol, + target, + proposal_id, + &mut rng, + platform_version, + ) + .await; + let refusal = process_refused(&platform, late, start + 1000, platform_version); + let ConsensusError::StateError(StateError::DocumentContestNotJoinableError(error)) = refusal + else { + panic!("expected the contest not to be joinable, got {refusal:?}"); + }; + assert_eq!( + error.joinable_time(), + 0, + "the refusal names the window of 0" + ); + + end_polls_at(&platform, start, 10, platform_version); + let ContestedDocumentVotePollStatus::Awarded(winner) = + status(&platform, &poll, platform_version) + else { + panic!("expected the seat to be awarded without a vote"); + }; + assert!( + winner == alice.id() || winner == bob.id(), + "the seat goes to an applicant of the first block" + ); + assert!(end_dates(&platform, platform_version).is_empty()); +} + #[tokio::test] async fn should_end_elections_of_targets_with_different_windows_at_different_heights() { let (mut platform, platform_version, charters, mut rng) = setup(); diff --git a/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/mod.rs index dae3694b5fa..0d295c5d94e 100644 --- a/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/mod.rs +++ b/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/mod.rs @@ -51,7 +51,10 @@ impl ContestWindows { } /// The windows an elected moderation declaration gives the elections for its contract. Its - /// windows are seconds, each one day to four weeks. + /// windows are seconds, each at most four weeks and at least a day on mainnet. Any other + /// network takes 0: a join window of 0 lets in only the applicants of the block that + /// opened the election, and a vote window of 0 leaves the masternodes no time to vote, so + /// the tie goes to the earliest application. /// /// # Parameters /// 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 66ab28d078a..a10335f9800 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -596,7 +596,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_contract_moderation_reason_length: 1024, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, - min_contract_moderation_election_window_seconds: 86_400, + min_mainnet_contract_moderation_election_window_seconds: 86_400, max_contract_moderation_election_window_seconds: 2_419_200, min_contract_moderation_challenge_cool_down_seconds: 1_209_600, max_contract_moderation_challenge_cool_down_seconds: 94_608_000, 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 f6975430714..d39e3abd60a 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -181,9 +181,11 @@ pub struct SystemLimits { /// version 14) and never reached before. pub max_contract_moderation_reason_documents: u16, /// Shortest join window and vote window, in seconds, an elected moderation team - /// declaration (`ContractModerators::Elected`) may set: one day. Read by the contract's - /// `validate_moderation_config` v0 (protocol version 14) and never reached before. - pub min_contract_moderation_election_window_seconds: u32, + /// declaration (`ContractModerators::Elected`) may set on mainnet: one day. Every other + /// network has no floor, a window of 0 included, so test elections resolve at once. Read + /// by the contract's `validate_moderation_config` v0 (protocol version 14) and never + /// reached before. + pub min_mainnet_contract_moderation_election_window_seconds: u32, /// Longest join window and vote window, in seconds, such a declaration may set: four /// weeks. pub max_contract_moderation_election_window_seconds: u32, 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 08eac9bf4ac..2494b47588e 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -57,11 +57,11 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_contract_moderation_reason_length: 1024, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, - min_contract_moderation_election_window_seconds: 86_400, // one day - max_contract_moderation_election_window_seconds: 2_419_200, // four weeks - min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks + min_mainnet_contract_moderation_election_window_seconds: 86_400, // one day + max_contract_moderation_election_window_seconds: 2_419_200, // four weeks + min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days - contract_document_restore_window_ms: 604_800_000, // 7 days + contract_document_restore_window_ms: 604_800_000, // 7 days max_contract_moderation_added_moderators: 15, max_contenders_per_contest: 1_000, max_token_redemption_cycles: 128, 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 5777773344d..ebfdb2980e9 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -38,11 +38,11 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_contract_moderation_reason_length: 1024, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, - min_contract_moderation_election_window_seconds: 86_400, // one day - max_contract_moderation_election_window_seconds: 2_419_200, // four weeks - min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks + min_mainnet_contract_moderation_election_window_seconds: 86_400, // one day + max_contract_moderation_election_window_seconds: 2_419_200, // four weeks + min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days - contract_document_restore_window_ms: 604_800_000, // 7 days + contract_document_restore_window_ms: 604_800_000, // 7 days max_contract_moderation_added_moderators: 15, max_contenders_per_contest: 1_000, max_token_redemption_cycles: 128, 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 7ca7b9686ec..5cbb8670220 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -40,11 +40,11 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_contract_moderation_reason_length: 1024, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, - min_contract_moderation_election_window_seconds: 86_400, // one day - max_contract_moderation_election_window_seconds: 2_419_200, // four weeks - min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks + min_mainnet_contract_moderation_election_window_seconds: 86_400, // one day + max_contract_moderation_election_window_seconds: 2_419_200, // four weeks + min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days - contract_document_restore_window_ms: 604_800_000, // 7 days + contract_document_restore_window_ms: 604_800_000, // 7 days max_contract_moderation_added_moderators: 15, max_contenders_per_contest: 1_000, max_token_redemption_cycles: 128, 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 0cdee2788b5..cbe142a5bbf 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -48,8 +48,9 @@ use crate::version::system_limits::SystemLimits; /// 2^53 - 1 milliseconds of block time, the largest value JSON clients read exactly. The /// text of the reason a ban or a suspension carries is at most 1024 bytes. /// * Elected moderation teams (protocol version 14): a contract that declares an elected -/// moderation team sets its join window and vote window between one day and four weeks, -/// and its challenge cool-down between two weeks and three years, all in seconds. +/// moderation team sets its join window and vote window at most four weeks, and at least +/// one day on mainnet (0 is allowed on every other network), and its challenge cool-down +/// between two weeks and three years, all in seconds. /// * Typed array document properties (protocol version 14): a typed array property declares /// `maxItems`, at most 1024 elements (`max_typed_array_items`, backfilled into the /// earlier tables, whose parsers never read it). @@ -118,11 +119,11 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_contract_moderation_reason_length: 1024, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, - min_contract_moderation_election_window_seconds: 86_400, // one day - max_contract_moderation_election_window_seconds: 2_419_200, // four weeks - min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks + min_mainnet_contract_moderation_election_window_seconds: 86_400, // one day + max_contract_moderation_election_window_seconds: 2_419_200, // four weeks + min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days - contract_document_restore_window_ms: 604_800_000, // 7 days + contract_document_restore_window_ms: 604_800_000, // 7 days max_contract_moderation_added_moderators: 15, max_contenders_per_contest: 1_000, max_token_redemption_cycles: 128, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 7c8333773fc..3719040449a 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -514,8 +514,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// contract may declare, when it is created, that its moderators are a team /// elected by masternodes and evonodes (`ContractModerators::Elected`, a third kind /// beside the owner and an appointed set, in the same config V2). The -/// declaration is frozen: the join and vote windows (one day to four weeks, -/// one week by default), in seconds and bounded by `SYSTEM_LIMITS_V4`; +/// declaration is frozen: the join and vote windows (at most four weeks, at +/// least one day on mainnet and 0 elsewhere, one week by default), in +/// seconds and bounded by `SYSTEM_LIMITS_V4`; /// whether the seat can be contested again once a team is seated /// (`seatContestable`, required with no default), and for a contestable /// seat the challenge cool-down (`challengeCoolDown`, in seconds, two weeks diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index 50071335c48..c4a8f6d6620 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -148,8 +148,9 @@ export interface DataContractConfig { * team is seated (no election exists yet) the contract is moderated by its `interim` * moderators, or by nobody: with the moderated document types not yet usable (every * document transition of one is refused) or used unmoderated meanwhile. Windows and the - * cool-down are in seconds: the windows one day to four weeks (one week when left out), the - * cool-down of a contestable seat two weeks to three years. + * cool-down are in seconds: the windows at most four weeks, at least one day on mainnet and + * 0 on any other network (one week when left out), the cool-down of a contestable seat two + * weeks to three years. */ export type ContractModerators = | { $type: "contractOwner" } From 8d587f2547598567f363098d0e19a0ad59cc0700 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 12:37:04 +0700 Subject: [PATCH 071/113] fix(dpp)!: propertyConstraints read empty objects as absent and follow $defs refs (PV14) (#5101) Co-authored-by: Claude Opus 5.5 --- book/src/contract-keywords.md | 2 +- .../contract-keywords/property-constraints.md | 6 +- book/src/data-model/documents.md | 2 +- .../document/v3/document-meta.json | 4 +- .../class_methods/try_from_schema/mod.rs | 253 ++++++++++++++++-- .../try_from_schema/v3/immutable_tests.rs | 13 +- .../class_methods/try_from_schema/v3/mod.rs | 18 +- .../v3/property_constraints_tests.rs | 239 ++++++++++++++++- .../document_type/property_constraints/mod.rs | 39 ++- .../property_constraints/tests.rs | 6 + .../tests/document/property_constraints.rs | 85 ++++++ .../rs-platform-version/src/version/v14.rs | 12 + 12 files changed, 639 insertions(+), 40 deletions(-) diff --git a/book/src/contract-keywords.md b/book/src/contract-keywords.md index 546e8b4fc23..d618663e2ec 100644 --- a/book/src/contract-keywords.md +++ b/book/src/contract-keywords.md @@ -230,7 +230,7 @@ A rule is one condition. Conditions: | `equal`, `notEqual` | `[a, b]` | The two sides are equal, or differ: integer expressions, strings or identifiers. | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | | `lessThan`, `lessThanOrEqual`, `greaterThan`, `greaterThanOrEqual` | `[a, b]` | The integer comparison holds. | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | | `in` | `[a, [values]]` | `a` takes one of two or more listed integers, strings or identifiers. | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | -| `present`, `absent` | a path | The document holds the property, or leaves it out (or null). | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | +| `present`, `absent` | a path | The document holds the property, or leaves it out (or null, or an object with no member present). | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | | `anyOf`, `allOf` | two or more conditions | At least one, or every, condition holds, checked in order. | 14 | [Evaluation order](contract-keywords/property-constraints.md#evaluation-order-and-short-circuiting) | | `not` | a condition | The condition does not hold. | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index e2160e341b3..c90cf1daa1d 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -76,8 +76,8 @@ A rule is a condition: a JSON object with exactly one key. | `notIn` | `[expression, [v1, v2, ...]]` | The expression takes none of the listed values: an `in` negated, listed the same way, in as many nodes. A string or identifier property the document leaves out takes none | | `startsWith`, `endsWith` | `[text, affix]` | The first string starts, or ends, with the second, byte for byte with no case folding. Each side is a string constant, a string property or an `ifAbsent` string default, at least one a property and never the same one twice. A string property left out without a default takes no string, and the condition does not hold for it | | `contains` | `["path", value]` | The typed array property at the path holds an element equal to the value: an integer expression among integers; a string constant, a string property or an `ifAbsent` string default among strings; an identifier constant, an identifier property or `$ownerId` among identifiers. An array the document leaves out holds nothing, and a string or identifier property it leaves out is among no elements | -| `present` | `"path"` | The document holds the property, with a value other than null | -| `absent` | `"path"` | The document leaves the property out, or sets it to null | +| `present` | `"path"` | The document holds the property, with a value other than null and, for an object, with at least one member present | +| `absent` | `"path"` | The document leaves the property out, sets it to null, or gives an object no member that is present | | `anyOf` | `[c1, c2, ...]` | At least one of two or more conditions holds | | `allOf` | `[c1, c2, ...]` | Every one of two or more conditions holds | | `not` | `condition` | Its one condition does not hold | @@ -269,7 +269,7 @@ Six nodes: the comparison, `add`, three paths and `100`. } ``` -`present` and `absent` work on properties of any type, strings and objects included. On an integer they are also the only way to tell "not given" from "given as 0", since a missing integer reads as 0 in an expression. +`present` and `absent` work on properties of any type, strings and objects included. On an integer they are also the only way to tell "not given" from "given as 0", since a missing integer reads as 0 in an expression. An object with no member present, `{}` or `{ "inner": {} }`, counts as absent: a stored document does not keep it, and a transfer, purchase or price update is judged on the stored document, so a create or replace is judged the same way. **A time window.** An event ends after it starts, and lasts at most a week (`startsAt` and `endsAt` are required integer timestamps in milliseconds): diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index b628bd9fd6d..fd683f1d9de 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -715,7 +715,7 @@ The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeas - an identifier comparison, the same three forms for identifier properties, those declaring `refersTo` included: `{ "equal": ["paymentToken", { "const": "" }] }` or `notEqual`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. Constants are base58 identifiers of 32 bytes, checked at registration, and compared by their bytes, whatever form the document gives the identifier in. An identifier property the document leaves out equals no identifier, not even another one left out; identifiers take no `ifAbsent` default and are never ordered. `$ownerId`, the document's owner, is an identifier operand too: `{ "equal": ["authorId", "$ownerId"] }` holds the author to the owner, and `{ "in": ["$ownerId", ["", ...]] }` lets only the identities listed own a document of the type. It is no property, so `present` or an integer operand refuses it, and comparing it with itself is refused. Since a transfer and a purchase give the document a new owner, each is judged against the rules reading `$ownerId`, with the new owner, and refused when it would break one; an indexOnly type refuses such a rule, since its deletes carry no owner; - `{ "startsWith": [text, affix] }` and `{ "endsWith": [text, affix] }`, holding if the first string starts or ends with the second, byte for byte with no case folding: each side a `{ "const": ... }`, a string property or an `ifAbsent` string default, at least one a property, never the same one twice. `{ "startsWith": ["url", { "const": "https://" }] }` holds a link to https, and `{ "startsWith": ["path", "parentPath"] }` a path under its parent's. A string property left out without a default takes no string, and the condition does not hold for it; a constant tested against a property that declares an `enum` must start or end one of its values; - `{ "contains": [path, value] }`, holding if the typed array property at the path holds an element equal to the value, looked for as the array's elements are: an integer expression among integers, a string constant, string property or `ifAbsent` string default among strings, an identifier constant, identifier property or `$ownerId` among identifiers. `{ "not": { "contains": ["labels", { "const": "used" }] } }` refuses a `"used"` label, and `{ "contains": ["participants", "$ownerId"] }` holds the owner to the participants (so a transfer or purchase to a non-participant is refused). An array the document leaves out holds nothing, a string or identifier property it leaves out is among no elements, and a string constant must be one of the elements' `enum` values when they declare one; -- `{ "present": path }`, holding if the document holds the property, and `{ "absent": path }`, holding if it leaves it out (a property set to null counts as left out). An operand reads a property the document leaves out as 0, so only these tell "not given" from "given as 0". They may name a property of any type, an object or a member of one included, since they read no value; +- `{ "present": path }`, holding if the document holds the property, and `{ "absent": path }`, holding if it leaves it out (a property set to null counts as left out, and so does an object none of whose members is present, such as `{}`, which a stored document does not keep). An operand reads a property the document leaves out as 0, so only these tell "not given" from "given as 0". They may name a property of any type, an object or a member of one included, since they read no value; - `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; - `{ "allOf": [...] }`, holding if every one of two or more conditions holds; - `{ "not": condition }`, holding if its one condition does not; diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 924fafd4377..736cc5a4278 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -140,11 +140,11 @@ "minItems": 2 }, "present": { - "description": "Holds if the document holds the property at this path, of any type, an object included, with a value other than null. Unlike an operand, which reads a property the document leaves out as 0, it tells a property left out from one set to 0", + "description": "Holds if the document holds the property at this path, of any type, an object included, with a value other than null and, for an object, with a member that is present: a stored document does not keep an object without one. Unlike an operand, which reads a property the document leaves out as 0, it tells a property left out from one set to 0", "$ref": "#/$defs/propertyConstraintPath" }, "absent": { - "description": "Holds if the document leaves the property at this path out, or sets it to null", + "description": "Holds if the document leaves the property at this path out, sets it to null, or gives an object there no member that is present", "$ref": "#/$defs/propertyConstraintPath" }, "anyOf": { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index c96bb9605ec..8e85e3e8ab4 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -41,6 +41,9 @@ mod v3; const NOT_ALLOWED_SYSTEM_PROPERTIES: [&str; 1] = ["$id"]; +/// How a `$ref` to one of the contract's `$defs` starts: `#/$defs/`. +const DEFINITIONS_REF_PREFIX: &str = "#/$defs/"; + /// The longest property path a keyword may name: `keyIdProperty`, the /// `propertyAgreement` pairs and the `encryptedFor` paths share it, and the /// meta-schema states the same bound as `maxLength`. @@ -1790,11 +1793,13 @@ fn apply_encrypted_for_v0( /// the stored ciphertext without its recipe), and the byte array's own /// `maxItems` must hold the scheme's shortest ciphertext. Paths are looked up /// among the flattened properties, so a nested property is named by its -/// dotted path. +/// dotted path. A key property's schema reached through a `$ref` is read from +/// `schema_defs`, the contract's `$defs`, as the core parse reads it. /// /// Owned by parser generation 3: the only generation that admits the keyword. pub(super) fn validate_encrypted_for_declarations( document_type: &DocumentTypeV2, + schema_defs: Option<&BTreeMap>, document_type_name: &str, ) -> Result<(), DataContractError> { let flattened_properties = &document_type.flattened_properties; @@ -1858,7 +1863,7 @@ pub(super) fn validate_encrypted_for_declarations( "{key} \"{key_path}\" is not a property of the document type" ))); } - if !is_key_id_schema(&document_type.schema, key_path)? { + if !is_key_id_schema(&document_type.schema, schema_defs, key_path)? { return Err(structure_error(format!( "{key} \"{key_path}\" must be an integer property with minimum at least 0 \ and maximum at most {}, so that it carries a key id", @@ -1891,7 +1896,9 @@ pub(super) fn validate_encrypted_for_declarations( /// checked on every parse; under full validation, the limits too: at most /// `SystemLimits::max_property_constraints` rules, each of at most /// `max_property_constraint_nodes` nodes, and no `anyOf` or `allOf` listing -/// the same condition twice. +/// the same condition twice. The `enum` a constant or a default is checked +/// against is read from the property's schema, through a `$ref` into +/// `schema_defs`, the contract's `$defs`, as the core parse reads it. /// /// Only parser generation 3 calls it, once the core parse has run the /// meta-schema, so under full validation a malformed declaration is the @@ -1901,6 +1908,7 @@ pub(super) fn validate_encrypted_for_declarations( /// entirely. pub(super) fn apply_property_constraints( document_type: &mut DocumentTypeV2, + schema_defs: Option<&BTreeMap>, document_type_name: &str, full_validation: bool, platform_version: &PlatformVersion, @@ -1915,6 +1923,7 @@ pub(super) fn apply_property_constraints( None => Ok(()), Some(0) => apply_property_constraints_v0( document_type, + schema_defs, document_type_name, full_validation, platform_version, @@ -1944,6 +1953,7 @@ fn property_at_path<'a>( fn apply_property_constraints_v0( document_type: &mut DocumentTypeV2, + schema_defs: Option<&BTreeMap>, document_type_name: &str, full_validation: bool, platform_version: &PlatformVersion, @@ -2220,7 +2230,7 @@ fn apply_property_constraints_v0( // A constant or a default a string property's `enum` does not list is a // typo: the property could never hold it for (path, constant) in constraint.text_constants() { - if !enum_admits(&document_type.schema, path, constant)? { + if !enum_admits(&document_type.schema, schema_defs, path, constant)? { return Err(structure_error(format!( "rule \"{name}\" compares \"{path}\" with \"{constant}\", which is not one of \ its enum values" @@ -2230,7 +2240,7 @@ fn apply_property_constraints_v0( // A constant a string property must start or end with, when the property // declares an `enum`, must fit one of its values, or the test never holds for (path, affix, position) in constraint.text_affixes() { - if !enum_any(&document_type.schema, path, |member| { + if !enum_any(&document_type.schema, schema_defs, path, |member| { position.holds(member, affix) })? { let tests = match position { @@ -2244,7 +2254,7 @@ fn apply_property_constraints_v0( } } for (path, default) in constraint.text_defaults() { - if !enum_admits(&document_type.schema, path, default)? { + if !enum_admits(&document_type.schema, schema_defs, path, default)? { return Err(structure_error(format!( "rule \"{name}\" gives \"{path}\" the default \"{default}\", which is not \ one of its enum values" @@ -2284,25 +2294,32 @@ fn apply_property_constraints_v0( /// Whether the string property at the dotted `path` of `schema`, a document /// type's, may hold `value`: always, unless it declares an `enum` that does not -/// list it. -fn enum_admits(schema: &Value, path: &str, value: &str) -> Result { - enum_any(schema, path, |member| member == value) +/// list it. `$ref`s are followed into `schema_defs`, the contract's `$defs`. +fn enum_admits( + schema: &Value, + schema_defs: Option<&BTreeMap>, + path: &str, + value: &str, +) -> Result { + enum_any(schema, schema_defs, path, |member| member == value) } /// Whether the string property at the dotted `path` of `schema` (or the /// elements of the typed array there) may hold a value `admits`: always, -/// unless it declares an `enum`, one of whose values must then pass. +/// unless it declares an `enum`, one of whose values must then pass. `$ref`s +/// are followed into `schema_defs`, the contract's `$defs`. fn enum_any( schema: &Value, + schema_defs: Option<&BTreeMap>, path: &str, admits: impl Fn(&str) -> bool, ) -> Result { - let Some(property_schema) = schema_at_path(schema, path)? else { + let Some(property_schema) = schema_at_path(schema, schema_defs, path)? else { return Ok(true); }; // A value a `contains` looks for in an array is one of its elements let property_schema = match property_schema.get(property_names::ITEMS) { - Some(items) => resolve_schema(schema, items)?, + Some(items) => resolve_schema(schema, schema_defs, items)?, None => property_schema, }; let Some(Value::Array(members)) = property_schema.get(property_names::ENUM) else { @@ -2314,12 +2331,14 @@ fn enum_any( } /// The schema of the property at the dotted `path` of `schema`, a document -/// type's, `None` when the path names none. `$ref`s are followed. +/// type's, `None` when the path names none. `$ref`s are followed, into +/// `schema_defs`, the contract's `$defs`, for `#/$defs/...`. fn schema_at_path<'a>( schema: &'a Value, + schema_defs: Option<&'a BTreeMap>, path: &str, ) -> Result>, DataContractError> { - let mut current = resolve_schema(schema, schema)?; + let mut current = resolve_schema(schema, schema_defs, schema)?; for segment in path.split('.') { let Some(properties) = current.get(property_names::PROPERTIES) else { return Ok(None); @@ -2327,30 +2346,70 @@ fn schema_at_path<'a>( let Some(next) = properties.to_btree_ref_string_map()?.get(segment).copied() else { return Ok(None); }; - current = resolve_schema(schema, next)?; + current = resolve_schema(schema, schema_defs, next)?; } Ok(Some(current)) } /// The schema `value` is within the document type schema `schema`, its `$ref` -/// followed. +/// followed ([`resolve_schema_ref`]). fn resolve_schema<'a>( schema: &'a Value, + schema_defs: Option<&'a BTreeMap>, value: &'a Value, ) -> Result, DataContractError> { let map = value.to_btree_ref_string_map()?; match map.get_optional_str(property_names::REF)? { - Some(schema_ref) => Ok(resolve_uri(schema, schema_ref)?.to_btree_ref_string_map()?), + Some(schema_ref) => { + Ok(resolve_schema_ref(schema, schema_defs, schema_ref)?.to_btree_ref_string_map()?) + } None => Ok(map), } } +/// The value the `$ref` `uri` of the document type schema `schema` names, +/// found where the core parse finds it, in the schema with the contract's +/// `$defs` added ([`DocumentType::enrich_with_base_schema`]): a +/// `#/$defs/` reference, and a path below one, in `schema_defs`; any +/// other in `schema`, which holds no `$defs` of its own. Every document +/// meta-schema names a definition with letters, digits, `-` and `_` only, so +/// the name ends at the next `/`. +fn resolve_schema_ref<'a>( + schema: &'a Value, + schema_defs: Option<&'a BTreeMap>, + uri: &str, +) -> Result<&'a Value, DataContractError> { + let Some(definition_path) = uri.strip_prefix(DEFINITIONS_REF_PREFIX) else { + return resolve_uri(schema, uri); + }; + let (name, below) = match definition_path.split_once('/') { + Some((name, below)) => (name, Some(below)), + None => (definition_path, None), + }; + let definition = schema_defs + .and_then(|definitions| definitions.get(name)) + .ok_or_else(|| { + DataContractError::InvalidURI(format!( + "{uri} names no definition in the contract's $defs" + )) + })?; + match below { + Some(below) => resolve_uri(definition, &format!("#/{below}")), + None => Ok(definition), + } +} + /// Whether the property at the dotted `path` of `schema` is declared as an /// integer with `minimum` at least 0 and `maximum` at most `u32::MAX`, read /// from the schema rather than from the parsed type so that the answer does -/// not depend on the contract's `sizedIntegerTypes`. `$ref`s are followed. -fn is_key_id_schema(schema: &Value, path: &str) -> Result { - let Some(current) = schema_at_path(schema, path)? else { +/// not depend on the contract's `sizedIntegerTypes`. `$ref`s are followed into +/// `schema_defs`, the contract's `$defs`. +fn is_key_id_schema( + schema: &Value, + schema_defs: Option<&BTreeMap>, + path: &str, +) -> Result { + let Some(current) = schema_at_path(schema, schema_defs, path)? else { return Ok(false); }; let is_integer = current.get_optional_str(property_names::TYPE)? == Some("integer"); @@ -5037,6 +5096,160 @@ mod tests { } } + /// The document type of `schema` parsed at the latest platform version + /// with the contract's `$defs`, which a `$ref` in it resolves against. + fn try_document_type_from_schema_with_defs( + schema: serde_json::Value, + schema_defs: &BTreeMap, + full_validation: bool, + ) -> Result { + let platform_version = PlatformVersion::latest(); + let config = + DataContractConfig::default_for_version(platform_version).expect("config should build"); + + let value = platform_value::to_value(schema).expect("schema should convert"); + + DocumentType::try_from_schema( + Identifier::random(), + 0, + config.version(), + "msg", + value, + Some(schema_defs), + &BTreeMap::new(), + &config, + full_validation, + &mut vec![], + platform_version, + ) + } + + /// A key id property whose schema is a `$ref` to one of the contract's + /// `$defs` is read from the definition, as the core parse reads it: a + /// bounded integer there registers, and an unbounded one is refused as + /// it is inline. On both paths. Each key has a definition of its own: + /// the schema depth check refuses two references to one definition. + #[test] + fn should_read_an_encrypted_for_key_id_through_a_ref() { + let key_id = || { + platform_value::to_value(json!({ + "type": "integer", + "minimum": 0, + "maximum": 4294967295_u64 + })) + .expect("the definition converts") + }; + let schema_defs = BTreeMap::from([ + ("recipientKey".to_string(), key_id()), + ("senderKey".to_string(), key_id()), + ( + "anyInteger".to_string(), + platform_value::to_value(json!({ "type": "integer", "minimum": 0 })) + .expect("the definition converts"), + ), + ]); + let mut schema = encrypted_schema(encrypted_for_declaration()); + schema["properties"]["recipientKeyId"] = + json!({ "$ref": "#/$defs/recipientKey", "position": 1 }); + schema["properties"]["senderKeyId"] = json!({ "$ref": "#/$defs/senderKey", "position": 2 }); + for full_validation in [true, false] { + let document_type = try_document_type_from_schema_with_defs( + schema.clone(), + &schema_defs, + full_validation, + ) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let encrypted_for = + encrypted_for_of(&document_type, "encryptedMessage").expect("should be declared"); + assert_eq!(encrypted_for.recipient_key, "recipientKeyId"); + assert_eq!(encrypted_for.sender_key, "senderKeyId"); + } + + schema["properties"]["senderKeyId"] = + json!({ "$ref": "#/$defs/anyInteger", "position": 2 }); + for full_validation in [true, false] { + let err = try_document_type_from_schema_with_defs( + schema.clone(), + &schema_defs, + full_validation, + ) + .expect_err("should be refused"); + assert!( + err.to_string().contains( + "senderKey \"senderKeyId\" must be an integer property with minimum at least 0" + ), + "full_validation {full_validation}: got {err}" + ); + } + } + + /// A `$ref` may name a schema below one of the contract's `$defs`, as + /// `#/$defs/keys/properties/recipient` does, and a key id property is then + /// read from the schema found there, as the core parse reads it: a bounded + /// integer registers, and an unbounded one is refused as it is inline. On + /// both paths. Each key names a schema of its own: the schema depth check + /// refuses two references to one schema. + #[test] + fn should_read_an_encrypted_for_key_id_through_a_ref_below_a_definition() { + let schema_defs = BTreeMap::from([( + "keys".to_string(), + platform_value::to_value(json!({ + "type": "object", + "properties": { + "recipient": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295_u64, + "position": 0 + }, + "sender": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295_u64, + "position": 1 + }, + "unbounded": { "type": "integer", "minimum": 0, "position": 2 } + }, + "additionalProperties": false + })) + .expect("the definition converts"), + )]); + let mut schema = encrypted_schema(encrypted_for_declaration()); + schema["properties"]["recipientKeyId"] = + json!({ "$ref": "#/$defs/keys/properties/recipient", "position": 1 }); + schema["properties"]["senderKeyId"] = + json!({ "$ref": "#/$defs/keys/properties/sender", "position": 2 }); + for full_validation in [true, false] { + let document_type = try_document_type_from_schema_with_defs( + schema.clone(), + &schema_defs, + full_validation, + ) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let encrypted_for = + encrypted_for_of(&document_type, "encryptedMessage").expect("should be declared"); + assert_eq!(encrypted_for.recipient_key, "recipientKeyId"); + assert_eq!(encrypted_for.sender_key, "senderKeyId"); + } + + schema["properties"]["senderKeyId"] = + json!({ "$ref": "#/$defs/keys/properties/unbounded", "position": 2 }); + for full_validation in [true, false] { + let err = try_document_type_from_schema_with_defs( + schema.clone(), + &schema_defs, + full_validation, + ) + .expect_err("should be refused"); + assert!( + err.to_string().contains( + "senderKey \"senderKeyId\" must be an integer property with minimum at least 0" + ), + "full_validation {full_validation}: got {err}" + ); + } + } + #[test] fn should_refuse_encrypted_for_below_platform_version_14_and_accept_it_at_14() { let schema = encrypted_schema(encrypted_for_declaration()); diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/immutable_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/immutable_tests.rs index 66ed8a78cc3..2ae3557f4a7 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/immutable_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/immutable_tests.rs @@ -52,6 +52,17 @@ pub(super) fn parse_dispatched( schema: Value, platform_version: &PlatformVersion, full_validation: bool, +) -> Result { + parse_dispatched_with_defs(schema, None, platform_version, full_validation) +} + +/// [`parse_dispatched`] with the contract's `$defs`, which a `$ref` in +/// `schema` resolves against. +pub(super) fn parse_dispatched_with_defs( + schema: Value, + schema_defs: Option<&BTreeMap>, + platform_version: &PlatformVersion, + full_validation: bool, ) -> Result { let config = DataContractConfig::default_for_version(platform_version) .expect("default config available on this platform version"); @@ -61,7 +72,7 @@ pub(super) fn parse_dispatched( config.version(), "post", schema, - None, + schema_defs, &BTreeMap::new(), &config, full_validation, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 3533014bd8b..be23f965b2e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -539,7 +539,9 @@ fn parse_generation_3( // After the core parse: every property, its transient flag and its schema // are known, so each `encryptedFor` declaration can be checked against the // properties it names. Generation 3 is the only one admitting the keyword. - validate_encrypted_for_declarations(&v2, name) + // A schema reached through a `$ref` is read from the contract's `$defs`, as + // the core parse read it. + validate_encrypted_for_declarations(&v2, schema_defs, name) .map_err(consensus_or_protocol_data_contract_error)?; // The same for the properties a `refersTo` lookup reads to assemble its key, // the lookup of the `ownerRefersTo` declaration included. @@ -547,9 +549,17 @@ fn parse_generation_3( .map_err(consensus_or_protocol_data_contract_error)?; // The `propertyConstraints` rules are parsed onto the type here, where the // integer properties they read and their transient flags are known; their - // limits are checked under full validation only. - apply_property_constraints(&mut v2, name, full_validation, platform_version) - .map_err(consensus_or_protocol_data_contract_error)?; + // limits are checked under full validation only. The `enum`s their string + // constants are checked against are read through `$ref`s into the + // contract's `$defs` too. + apply_property_constraints( + &mut v2, + schema_defs, + name, + full_validation, + platform_version, + ) + .map_err(consensus_or_protocol_data_contract_error)?; // After `apply_index_only`: the flag is refused on an indexOnly type, so it // has to see that one already applied. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index b20030790a5..7ad5ab781f0 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -6,7 +6,9 @@ //! with its rules, and any change to them is an incompatible update. Protocol //! version 13 refuses the keyword when registering and ignores it when reading. -use super::immutable_tests::{expect_structure_error, parse_dispatched}; +use super::immutable_tests::{ + expect_structure_error, parse_dispatched, parse_dispatched_with_defs, +}; use super::*; use crate::block::block_info::BlockInfo; use crate::consensus::basic::basic_error::BasicError; @@ -18,6 +20,8 @@ use crate::data_contract::document_type::property_constraints::{ }; use crate::data_contract::methods::validate_update::DataContractUpdateValidationMethodsV0; use crate::data_contract::DataContract; +use crate::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use crate::document::{Document, DocumentV0, DocumentV0Getters}; use crate::serialization::{ PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, PlatformSerializableWithPlatformVersion, @@ -357,6 +361,143 @@ fn should_hold_string_comparisons_to_string_properties_and_their_enums() { } } +/// A string property whose schema is a `$ref` to one of the contract's `$defs` +/// is held to the definition's `enum`, as one declared inline is: a constant, a +/// listed string, a prefix, a default and an element looked for that the enum +/// admits register, and a misspelt one is refused. On both paths. +#[test] +fn should_hold_string_constants_to_the_enum_of_a_referenced_definition() { + let schema_defs = BTreeMap::from([ + ( + "state".to_string(), + schema_value(json!({ + "type": "string", + "enum": ["open", "closed"], + "maxLength": 10 + })), + ), + ( + "labels".to_string(), + schema_value(json!({ + "type": "array", + "maxItems": 5, + "items": { "type": "string", "maxLength": 10, "enum": ["sale", "new"] } + })), + ), + ]); + let referencing = |rules: serde_json::Value| { + let mut schema = order_schema(Some(rules), None); + schema["properties"]["state"] = json!({ "$ref": "#/$defs/state", "position": 10 }); + schema["properties"]["labels"] = json!({ "$ref": "#/$defs/labels", "position": 13 }); + schema_value(schema) + }; + let parse = |rules: serde_json::Value, full_validation: bool| { + parse_dispatched_with_defs( + referencing(rules), + Some(&schema_defs), + PlatformVersion::latest(), + full_validation, + ) + }; + + let rules = json!({ + "closedState": { "equal": ["state", { "const": "closed" }] }, + "listedState": { "in": ["state", ["open", "closed"]] }, + "closingState": { "startsWith": ["state", { "const": "clo" }] }, + "stateDefaultsOpen": { + "equal": [{ "ifAbsent": ["state", "open"] }, { "const": "open" }] + }, + "onSale": { "contains": ["labels", { "const": "sale" }] } + }); + for full_validation in [true, false] { + let document_type = parse(rules.clone(), full_validation) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + assert_eq!(document_type.property_constraints().len(), 5); + } + + for (rules, needle) in [ + ( + json!({ "rule": { "equal": ["state", { "const": "closd" }] } }), + "rule \"rule\" compares \"state\" with \"closd\", which is not one of its enum values", + ), + ( + json!({ "rule": { "in": ["state", ["open", "shut"]] } }), + "rule \"rule\" compares \"state\" with \"shut\", which is not one of its enum values", + ), + ( + json!({ "rule": { "endsWith": ["state", { "const": "ed!" }] } }), + "rule \"rule\" tests whether \"state\" ends with \"ed!\", which none of its enum \ + values does", + ), + ( + json!({ + "rule": { "equal": [{ "ifAbsent": ["state", "opne"] }, { "const": "open" }] } + }), + "rule \"rule\" gives \"state\" the default \"opne\", which is not one of its enum \ + values", + ), + ( + json!({ "rule": { "contains": ["labels", { "const": "old" }] } }), + "rule \"rule\" compares \"labels\" with \"old\", which is not one of its enum values", + ), + ] { + for full_validation in [true, false] { + expect_structure_error(parse(rules.clone(), full_validation), needle); + } + } +} + +/// A `$ref` may name a schema below one of the contract's `$defs`, as +/// `#/$defs/wrapper/properties/inner` does, and the property is then held to +/// the `enum` found there, as the core parse reads it: a constant the enum +/// lists registers, and one it does not is refused. On both paths. +#[test] +fn should_hold_string_constants_to_the_enum_below_a_referenced_definition() { + let schema_defs = BTreeMap::from([( + "wrapper".to_string(), + schema_value(json!({ + "type": "object", + "properties": { + "inner": { + "type": "string", + "enum": ["open", "closed"], + "maxLength": 10, + "position": 0 + } + }, + "additionalProperties": false + })), + )]); + let parse = |rules: serde_json::Value, full_validation: bool| { + let mut schema = order_schema(Some(rules), None); + schema["properties"]["state"] = + json!({ "$ref": "#/$defs/wrapper/properties/inner", "position": 10 }); + parse_dispatched_with_defs( + schema_value(schema), + Some(&schema_defs), + PlatformVersion::latest(), + full_validation, + ) + }; + + for full_validation in [true, false] { + let document_type = parse( + json!({ "closedState": { "equal": ["state", { "const": "closed" }] } }), + full_validation, + ) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + assert_eq!(document_type.property_constraints().len(), 1); + + expect_structure_error( + parse( + json!({ "rule": { "equal": ["state", { "const": "closd" }] } }), + full_validation, + ), + "rule \"rule\" compares \"state\" with \"closd\", which is not one of its enum values", + ); + } +} + /// Two bare paths naming string properties compare the strings, by `equal` or /// `notEqual` only, on both paths; one string and one integer property stay an /// integer comparison, refused for its string. @@ -1249,12 +1390,17 @@ fn should_refuse_property_constraints_before_protocol_version_14_and_ignore_them const CONTRACT_ID: [u8; 32] = [7; 32]; fn order_contract(rules: Option, version: u32) -> DataContract { + contract_with_order_type(order_schema(rules, None), version) +} + +/// A contract whose `order` type is declared by `schema`. +fn contract_with_order_type(schema: serde_json::Value, version: u32) -> DataContract { let contract = json!({ "$formatVersion": "1", "id": Identifier::from(CONTRACT_ID).to_string(Encoding::Base58), "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), "version": version, - "documentSchemas": { "order": order_schema(rules, None) } + "documentSchemas": { "order": schema } }); DataContract::from_value( platform_value::to_value(contract).expect("the contract converts"), @@ -1288,6 +1434,95 @@ fn should_round_trip_a_contract_with_its_rules_through_platform_serialization() assert_eq!(rules(&recovered).len(), 1); } +/// A stored document keeps no object none of whose members it holds: `{}`, +/// and `{ "inner": {} }` around one, are read back as no object at all. So +/// `present` and `absent` judge such an object absent in the data a create +/// carries too, and every rule reaches the same verdict on a document's data +/// as on the document read back from storage, which a transfer, a purchase +/// and a price update are judged on. +#[test] +fn should_judge_presence_alike_on_the_data_and_on_the_stored_document() { + let platform_version = PlatformVersion::latest(); + let mut schema = order_schema( + Some(json!({ + "metaOrSeller": { + "anyOf": [{ "present": "meta" }, { "equal": ["sellerId", "$ownerId"] }] + }, + "noMetaOrSeller": { + "anyOf": [{ "absent": "meta" }, { "equal": ["sellerId", "$ownerId"] }] + }, + "noInner": { "absent": "meta.inner" } + })), + None, + ); + schema["properties"]["meta"]["properties"]["inner"] = json!({ + "type": "object", + "position": 2, + "properties": { "note": { "type": "string", "maxLength": 30, "position": 0 } }, + "additionalProperties": false + }); + let contract = contract_with_order_type(schema, 1); + let order_type = contract + .document_type_for_name("order") + .expect("the order type"); + + for (meta, kept) in [ + (platform_value!({}), false), + (platform_value!({ "inner": {} }), false), + (platform_value!({ "tag": "x" }), true), + ] { + // Owned by someone other than its seller, so `meta` alone decides + let document: Document = DocumentV0 { + id: Identifier::new([5; 32]), + owner_id: Identifier::new([3; 32]), + properties: BTreeMap::from([ + ("price".to_string(), Value::U64(100)), + ("fee".to_string(), Value::U64(10)), + ("quantity".to_string(), Value::U64(2)), + ("deposit".to_string(), Value::U64(220)), + ("sellerId".to_string(), Value::Identifier([4; 32])), + ("meta".to_string(), meta.clone()), + ]), + revision: Some(1), + ..Default::default() + } + .into(); + let bytes = document + .serialize(order_type, &contract, platform_version) + .expect("the document serializes"); + let stored = Document::from_bytes(&bytes, order_type, platform_version) + .expect("the document deserializes"); + assert_eq!( + stored.properties().contains_key("meta"), + kept, + "{meta:?}: the stored document keeps meta" + ); + + let system = DocumentSystemValues::of_document(&document); + let data = Value::from(document.properties().clone()); + let stored_data = Value::from(stored.properties().clone()); + let rules = order_type.property_constraints(); + for (name, rule) in rules { + assert_eq!( + rule.violation(&data, &system), + rule.violation(&stored_data, &system), + "{name} on {meta:?}" + ); + } + assert_eq!( + rules["metaOrSeller"].violation(&data, &system).is_none(), + kept, + "{meta:?}" + ); + assert_eq!( + rules["noMetaOrSeller"].violation(&data, &system).is_none(), + !kept, + "{meta:?}" + ); + assert_eq!(rules["noInner"].violation(&data, &system), None, "{meta:?}"); + } +} + /// Every stored document was judged against the rules, so none may be added, /// removed or changed by an update. #[test] diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index f033bbb2ab1..b3312073af5 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -881,9 +881,10 @@ pub enum PropertyConstraint { needle: ContainsNeedle, }, /// `present`: the document holds the property at the dotted path. One it - /// leaves out, or sets to null, is absent, as it is for an operand. Unlike - /// an operand, it tells a property left out from one set to 0, and it may - /// name a property of any type. + /// leaves out, or sets to null, is absent, as it is for an operand, and so + /// is an object none of whose members is present (`{}`), which a stored + /// document does not keep. Unlike an operand, it tells a property left out + /// from one set to 0, and it may name a property of any type. Present(String), /// `absent`: the document leaves the property at the dotted path out. Absent(String), @@ -2602,15 +2603,41 @@ fn identifier_value(data: &Value, owner_id: Option, path: &str) -> O } } -/// Whether `data` holds the property at `path`: absent exactly where -/// [`property_value`] would take the `if_absent` value. +/// Whether `data` holds the property at `path`: absent where +/// [`property_value`] would take the `if_absent` value, and where it holds an +/// object a stored document does not keep ([`is_kept_in_storage`]). fn is_present(data: &Value, path: &str) -> bool { matches!( data.get_optional_value_at_path(path), - Ok(Some(value)) if !matches!(value, Value::Null) + Ok(Some(value)) if is_kept_in_storage(value) ) } +/// Whether a stored document keeps `value`: anything but null, and an object +/// only when it holds a member it keeps. A document's encoding reads an object +/// with no member back as no object at all, so `{}`, and `{ "inner": {} }` +/// around it, are absent from the stored document. A create or a replace +/// judges the data it carries, and a transfer, a purchase or a price update +/// the stored document, so all of them must see such an object as absent. +/// Null and every value but an object answer at once; only an object's members +/// are walked. A create's data is walked before its schema validation is +/// reported, so the walk is iterative, like the other walks over a document's +/// values, and takes no stack however deep the object nests. +fn is_kept_in_storage(value: &Value) -> bool { + let Value::Map(members) = value else { + return !value.is_null(); + }; + let mut pending: Vec<&Value> = members.iter().map(|(_, member)| member).collect(); + while let Some(value) = pending.pop() { + match value { + Value::Null => {} + Value::Map(members) => pending.extend(members.iter().map(|(_, member)| member)), + _ => return true, + } + } + false +} + /// The value of the property at `path` in `data`, 1 or 0 for a boolean, or /// `if_absent` when the document leaves it out. An intermediate that is not an object reads as /// absent: the schema validation that runs first refuses such a document. diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index 40f3124cb2d..567c1ae43e2 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -2026,6 +2026,8 @@ fn should_tell_a_property_left_out_from_one_set_to_zero() { ("note", Value::Text("hi".to_string())), ("meta", platform_value!({ "count": 9 })), ("flat", Value::U8(1)), + ("hollow", platform_value!({})), + ("nested", platform_value!({ "inner": {}, "gone": null })), ]); for (path, present) in [ ("zero", true), @@ -2037,6 +2039,10 @@ fn should_tell_a_property_left_out_from_one_set_to_zero() { ("meta.missing", false), // An intermediate that is not an object reads as absent ("flat.count", false), + // An object with no member present is not kept in storage + ("hollow", false), + ("nested", false), + ("nested.inner", false), ] { let present_rule = parse_rule_value(platform_value!({ "present": path })); let absent_rule = parse_rule_value(platform_value!({ "absent": path })); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index f664c9bf6bc..d3f7fb79244 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -218,6 +218,35 @@ mod property_constraints_tests { }) } + /// [`owned_offer_schema`] with a `meta` object holding a `tag` and an + /// `inner` object, and one rule instead, `metaOrSeller`: the offer carries + /// `meta`, or its owner is its seller. + fn described_offer_schema() -> Value { + let mut schema = owned_offer_schema(); + schema["properties"]["meta"] = platform_value!({ + "type": "object", + "position": 5, + "properties": { + "tag": { "type": "string", "maxLength": 30, "position": 0 }, + "inner": { + "type": "object", + "position": 1, + "properties": { + "note": { "type": "string", "maxLength": 30, "position": 0 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }); + schema["propertyConstraints"] = platform_value!({ + "metaOrSeller": { + "anyOf": [{ "present": "meta" }, { "equal": ["sellerId", "$ownerId"] }] + } + }); + schema + } + /// An `offer` type with the integers [`set_valid_offer`] fills, a `title`, a /// typed array of `tags` and a byte array `signature`, and four rules on /// their sizes: `shortTitle` (at most 10 characters), `titleBytes` (at most @@ -1343,6 +1372,62 @@ mod property_constraints_tests { assert_eq!(fixture.stored_offers()[0].owner_id(), recipient.id()); } + /// A stored offer does not keep an object none of whose members it holds, + /// so `present` reads `meta: {}` (or `{ "inner": {} }`) as absent on a + /// create, as a transfer later reads the stored offer: a create by an owner + /// who is not the seller is refused, and so is the transfer of an offer + /// stored with `meta: {}` to a recipient who is not the seller. A `meta` + /// holding a member is present on both. + #[tokio::test] + async fn should_judge_an_object_without_members_absent_on_create_and_transfer() { + let mut fixture = OfferFixture::with_schema(described_offer_schema()); + let owner = fixture.identity.id(); + let seller = Value::Identifier([4; 32]); + + for meta in [platform_value!({}), platform_value!({ "inner": {} })] { + let result = fixture + .create(|document| { + document.set("sellerId", seller.clone()); + document.set("meta", meta.clone()); + }) + .await; + expect_violated(result, "metaOrSeller", PropertyConstraintViolation::NotMet); + } + assert!(fixture.stored_offers().is_empty()); + + // Its owner is its seller, so the offer is stored, without `meta` + assert_matches!( + fixture + .create(|document| { + document.set("sellerId", Value::Identifier(owner.to_buffer())); + document.set("meta", platform_value!({})); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers()[0].get("meta"), None); + + // Transferred, it is the offer the creates above were refused for + let (recipient, _, _) = fixture.other_identity(965); + let result = fixture.transfer(recipient.id()).await; + expect_violated(result, "metaOrSeller", PropertyConstraintViolation::NotMet); + assert_eq!(fixture.stored_offers()[0].owner_id(), owner); + + assert_matches!( + fixture + .create(|document| { + document.set("sellerId", seller.clone()); + document.set("meta", platform_value!({ "tag": "x" })); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_matches!( + fixture.transfer(recipient.id()).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + /// A `sellerId` that declares `refersTo` an identity is compared with /// `$ownerId` as any identifier property is: the contract registers, a /// create naming another existing identity as seller is refused, one naming diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 3719040449a..0af64d7c8c4 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1353,6 +1353,18 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// create structure validation 1 leaves the amount to state validation; /// version 0 wants exactly the contest's fund. /// +/// 52. **Property constraints judge what is stored, and read `$defs`**: to +/// `present` and `absent` (item 39), an object none of whose members is +/// present (`{}`, or `{ "inner": {} }` around one) is absent, since a +/// stored document reads it back as no object at all. A create or replace +/// carrying `meta: {}` was judged with `meta` present, and a later +/// transfer, purchase or price update, judged on the stored document, with +/// it absent. The parser (generation 3) reads the schema of a property +/// given as a `$ref` to the contract's `$defs` from the definition, as the +/// core parse does, when it checks a rule's string constants and defaults +/// against the property's `enum` and an `encryptedFor` key id's bounds; it +/// refused every such contract with a decoding error before. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's From eae285950543853f7f5f3c2f1c4cf64437a697b1 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 12:41:14 +0700 Subject: [PATCH 072/113] feat(platform)!: countOf and sumOf totals from count and sum trees in propertyConstraints rules (PV14) (#5109) Co-authored-by: Claude Opus 5.5 --- .../contract-keywords/property-constraints.md | 52 +- book/src/data-model/documents.md | 9 +- packages/js-evo-sdk/README.md | 4 +- .../document/v3/document-meta.json | 78 ++- .../v1/mod.rs | 7 + .../class_methods/try_from_schema/mod.rs | 261 ++++++- .../class_methods/try_from_schema/v3/mod.rs | 2 + .../property_constraint_aggregates_tests.rs | 641 ++++++++++++++++++ .../src/data_contract/document_type/mod.rs | 3 +- .../property_constraints/aggregate.rs | 167 +++++ .../document_type/property_constraints/mod.rs | 434 ++++++++++-- .../property_constraints/tests.rs | 218 +++++- .../src/data_contract/document_type/v2/mod.rs | 3 +- .../advanced_structure_v1/mod.rs | 12 +- .../advanced_structure_v0/mod.rs | 9 +- .../advanced_structure_v0/mod.rs | 4 +- .../advanced_structure_v0/mod.rs | 9 +- .../advanced_structure_v0/mod.rs | 7 +- .../batch/tests/document/distinct_from.rs | 3 + .../batch/tests/document/encrypted_for.rs | 1 + .../batch/tests/document/max_bytes.rs | 1 + .../tests/document/property_constraints.rs | 377 +++++++++- .../batch/transformer/v0/mod.rs | 150 +++- .../v0/property_constraint_aggregates.rs | 134 ++++ .../mod.rs | 67 ++ .../v0/mod.rs | 107 +++ .../drive/document/index_uniqueness/mod.rs | 1 + packages/rs-drive/src/drive/document/mod.rs | 2 + .../document_create_transition_action/mod.rs | 22 + .../v0/mod.rs | 13 + .../v0/transformer.rs | 1 + .../mod.rs | 23 + .../v0/mod.rs | 14 + .../v0/transformer.rs | 1 + .../document_replace_transition_action/mod.rs | 22 + .../v0/mod.rs | 13 + .../v0/transformer.rs | 1 + .../mod.rs | 23 + .../v0/mod.rs | 14 + .../v0/transformer.rs | 1 + .../mod.rs | 23 + .../v0/mod.rs | 14 + .../v0/transformer.rs | 1 + .../state_transition_action/batch/tests.rs | 8 + .../drive_document_method_versions/mod.rs | 5 + .../drive_document_method_versions/v1.rs | 1 + .../drive_document_method_versions/v2.rs | 1 + .../drive_document_method_versions/v3.rs | 1 + .../drive_document_method_versions/v4.rs | 1 + .../src/version/mocks/v2_test.rs | 1 + .../src/version/system_limits/mod.rs | 12 +- .../src/version/system_limits/v1.rs | 1 + .../src/version/system_limits/v2.rs | 1 + .../src/version/system_limits/v3.rs | 1 + .../src/version/system_limits/v4.rs | 10 +- .../rs-platform-version/src/version/v14.rs | 21 +- .../document_type_property_constraints.rs | 29 +- packages/wasm-dpp2/src/data_contract/model.rs | 5 +- .../unit/DocumentPropertyConstraints.spec.ts | 61 ++ 59 files changed, 3008 insertions(+), 100 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraint_aggregates_tests.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/property_constraints/aggregate.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs create mode 100644 packages/rs-drive/src/drive/document/fetch_property_constraint_aggregate/mod.rs create mode 100644 packages/rs-drive/src/drive/document/fetch_property_constraint_aggregate/v0/mod.rs diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index c90cf1daa1d..1105dbfe94e 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -1,6 +1,6 @@ # propertyConstraints -`propertyConstraints` holds named rules that every created or replaced document of a type must meet. JSON Schema bounds one property at a time; these rules relate properties to each other: a deposit that covers price times quantity, percentages that add up to 100, a closed order that carries its closing time, a second party who is not the owner. Each rule is a small tree of comparisons, arithmetic and logic that consensus evaluates against the document, without reading any state. +`propertyConstraints` holds named rules that every created or replaced document of a type must meet. JSON Schema bounds one property at a time; these rules relate properties to each other: a deposit that covers price times quantity, percentages that add up to 100, a closed order that carries its closing time, a second party who is not the owner. Each rule is a small tree of comparisons, arithmetic and logic that consensus evaluates against the document. A rule can also read a total of other documents, how many there are or what an integer property adds up to, from the count and sum trees their indexes keep (see [Totals of other documents](#totals-of-other-documents)). | | | |---|---| @@ -49,10 +49,10 @@ - **Create and replace.** The rules run after the JSON schema validation of the document's properties (and after [maxBytes](max-bytes.md)), so every value a rule reads has passed its property's schema. A replace is judged on the whole new document, not only on what changed. - **Name order, first failure.** Rules are checked in the order of their names, and the first rule the document breaks refuses the transition with `DocumentPropertyConstraintViolatedError` (10422). The error names the document type, the rule, and why it failed (below). -- **Transfer and purchase.** These change only the owner and the transfer's time and heights. Rules that read `$ownerId` or `$transferredAt…` are judged again, against the stored document with its new owner and transfer values; other rules are not, since nothing they read changed. A transfer or purchase that would break such a rule is refused with 10422. +- **Transfer and purchase.** These change only the owner and the transfer's time and heights. Rules that read `$ownerId`, `$transferredAt…` or a total that depends on the owner are judged again, against the stored document with its new owner and transfer values; other rules are not, since nothing they read changed. A transfer or purchase that would break such a rule is refused with 10422. - **Price updates** change only the update's time and heights, so the rules that read `$updatedAt…` are judged again the same way; other rules are not. - **Deletes** are not judged, with one exception: a delete of an [index-only](index-only.md) document carries the row's values, which are validated like a create's, rules included. The delete carries neither the owner nor any time or height, which is why an index-only type may not have a rule reading `$ownerId` or a system time or height. -- **No state, no fee.** A rule reads only the document, its owner and its times and heights. It changes nothing stored and adds no fee; the limits below bound its cost. SDKs that validate a document before sending it apply the same rules. +- **State and fees.** A rule reads the document, its owner and its times and heights, and a `countOf` or `sumOf` reads a total from state. Each such total is a state read billed with the write; nothing else a rule does adds a fee, and it changes nothing stored. The limits below bound its cost. SDKs that validate a document before sending it apply the same rules, except those reading a total, which they cannot read. Why a rule fails, as the error reports it: @@ -117,6 +117,7 @@ An integer expression is one of: | `length`, `byteLength` | `{ "length": "title" }` | The characters (as `maxLength` counts them) or UTF-8 bytes (as `maxBytes` counts them) of a string property, 0 when the document leaves it out | | `count` | `{ "count": "tags" }` | The items of an array property, or the bytes of a byte array property, 0 when the document leaves it out | | system time or height | `"$createdAt"`, `"$updatedAtBlockHeight"` | A time or height the document records (see [Times and heights](#times-and-heights)) | +| `countOf`, `sumOf` | `{ "countOf": ["listing", { "$ownerId": "$ownerId" }] }` | A total of documents of a type of the same contract, read from state (see [Totals of other documents](#totals-of-other-documents)) | Where `maxLength`, `maxBytes` and `maxItems` bound one property by a fixed number, a size can be compared with another property or bounded only under a condition: `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit. A size never breaks a rule by itself: a property left out or null has size 0, and so would a value of another type, which the schema validation refuses first. @@ -172,6 +173,37 @@ A rule may read one only when the document type records it by listing it in `req SDK pre-checks run before the block exists: they use the device clock for the times a write records, and do not judge a rule reading a block height, which is unknown until the block. +## Totals of other documents + +`countOf` and `sumOf` read a total from state: how many documents of a type of the same contract match a filter, or what one of their integer properties adds up to. The total is the one a count or sum tree keeps ([Count Trees](../drive/document-count-trees.md), [Sum Trees](../drive/document-sum-trees.md)), so reading it costs about the same however many documents match. + +| Form | Value | The counted type needs | +|---|---|---| +| `{ "countOf": ["listing"] }` | How many `listing` documents there are | `documentsCountable` | +| `{ "countOf": ["listing", { "$ownerId": "$ownerId" }] }` | How many of them match the filter | A countable index whose properties are exactly the filter's keys | +| `{ "sumOf": ["pledge", "amount"] }` | The total `amount` over every `pledge` | `documentsSummable: "amount"` | +| `{ "sumOf": ["pledge", "amount", { "campaignId": "campaignRef" }] }` | The total over those matching the filter | An index with `summable: "amount"` whose properties are exactly the filter's keys | + +A filter maps each key, a property of the counted type or `$ownerId`, to the value it must take, read from the document being written: one of its properties (`"campaignRef"`), `$ownerId`, an integer, or a `{ "const": ... }` string or base58 identifier. The counted type may be the rule's own. + +```json +"propertyConstraints": { + "atMostTenListings": { + "lessThanOrEqual": [{ "countOf": ["listing", { "$ownerId": "$ownerId" }] }, 10] + }, + "pledgesWithinGoal": { + "lessThanOrEqual": [{ "sumOf": ["pledge", "amount", { "campaignId": "campaignRef" }] }, "goal"] + } +} +``` + +- **As it will be after the write.** The total is the stored one with the write applied. When the counted type is the rule's own, a create adds the document, a replace swaps its stored version for the new one, and a transfer or purchase moves it to its new owner. So `atMostTenListings`, declared on `listing`, keeps every owner at ten or fewer, and a replace of one of ten is allowed. +- **Judged when the rule's own type is written.** A rule is never judged on writes of the type it counts. On its own type it holds for good, since every write that could raise the total is judged. On another type it is only checked when its own type is written, and can go stale later: deleting a `profile` does not undo a `post` that needed one. Deletes are not judged, so a lower bound can be broken by deleting documents. +- **Transfers, purchases and price updates.** A total that depends on the owner (a filter value of `$ownerId`, or a `$ownerId` key on the rule's own type) is read again for a transfer or purchase, the document counted toward its new owner. A rule a price update judges, one reading `$updatedAt…`, reads its totals too. +- **Billed.** Each total is a state read billed with the write. A total two rules read alike is read once. +- **Every earlier write counts.** A document batch carries one transition, and each state transition of a block is applied before the next is validated, so a total includes every write before it. +- **SDK pre-checks** cannot read state, so they do not judge a rule reading a total. + ## Evaluation order and short-circuiting Conditions are checked in declared order and no further than the outcome needs. A comparison evaluates its left side, then its right. `anyOf` stops at the first condition that holds, `allOf` at the first that fails. Operands are evaluated left to right. @@ -199,7 +231,8 @@ The meta-schema checks the shape (`JsonSchemaError`, 10101): - every condition and every operator object has exactly one key; - a comparison, `subtract`, `divide`, `modulo` and `power` take exactly two operands; `add` and `multiply` two or more; `anyOf` and `allOf` two or more conditions, no two alike; an `in` two or more distinct values, all integers or all strings; - no `anyOf` or `allOf` holds its own kind directly, and no `not` holds a `not` or a `notIn`; -- a path matches `$ownerId`, one of the nine [times and heights](#times-and-heights), or dotted names of 1 to 64 letters, digits or underscores, so `$revision` and other system properties are refused. +- a path matches `$ownerId`, one of the nine [times and heights](#times-and-heights), or dotted names of 1 to 64 letters, digits or underscores, so `$revision` and other system properties are refused; +- a `countOf` lists a type name and optionally a filter, and a `sumOf` a type name, a property and optionally a filter; a filter has one or more keys, each `$ownerId` or a dotted path, and each value is a path, `$ownerId`, an integer or a `{ "const": ... }` string. The parser then checks the rules against the document type (`InvalidContractStructure`, 10231): @@ -212,12 +245,16 @@ The parser then checks the rules against the document type (`InvalidContractStru - every time or height a rule reads is one the type lists in `required`, and takes no `ifAbsent` default; - `present` and `absent` do not name `$ownerId` or a time or height, and an index-only type has no rule reading any of them; - no `anyOf` or `allOf` lists two conditions that parse alike, such as `1` and `1.0`, or two `in` conditions listing the same values in another order, and no `ifThen` or `ifThenElse` holds two alike conditions; -- no condition or operand nests more than 64 levels deep. +- no condition or operand nests more than 64 levels deep; +- once every document type of the contract is parsed, every `countOf` and `sumOf` counts a type of the contract that is not index-only, with a tree that keeps the total as set out in [Totals of other documents](#totals-of-other-documents). A unique, contested, ranked, time-range or index-only-terminal index keeps no such total, nor does one with more properties than the filter has keys; +- every key of a filter is `$ownerId` or an integer, string or identifier property of the counted type, and its value is of the same kind; a string constant is in the key's `enum` when it has one, and an identifier constant is base58; +- every property a filter value reads is listed in `required`, with every object around it, so a write always has the value; an index-only type has no rule reading a total. -Two limits come from the protocol version 14 `SystemLimits`, and a rule over one is refused the same way: +Three limits come from the protocol version 14 `SystemLimits`, and a rule over one is refused the same way: - at most 16 rules per document type (`max_property_constraints`); -- at most 32 nodes per rule (`max_property_constraint_nodes`). +- at most 32 nodes per rule (`max_property_constraint_nodes`); +- at most 4 distinct `countOf` and `sumOf` totals read by one document type's rules, a total read twice counting once (`max_property_constraint_aggregates`). A rule within 32 nodes is never deep enough to reach the 64-level bound. Nodes are counted like this: @@ -235,6 +272,7 @@ A rule within 32 nodes is never deep enough to reach the 64-level bound. Nodes a | `notIn` | as the `in` it negates | | An integer, a path, an `ifAbsent`, a size (`length`, `byteLength`, `count`) or a time or height | 1 | | `add`, `multiply`, `subtract`, `divide`, `modulo`, `power`, `min`, `max`, `abs` | 1, plus their operands | +| `countOf`, `sumOf` | 1, plus 1 per filter key | `depositCoversOrder` above is 7 nodes (the comparison, `multiply`, `add` and four paths), and `closedNeedsClosedAt` is 5. An `in` fits up to 30 values in 32 nodes. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index fd683f1d9de..80df3b5f9f8 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -732,7 +732,8 @@ Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan - `{ "min": [...] }` or `{ "max": [...] }`, the least or greatest of two or more operands, every one evaluated (a fault in any breaks the rule), and `{ "abs": a }`, the absolute value of one: `{ "lessThanOrEqual": ["fee", { "max": [10, { "divide": ["price", 10] }] }] }` caps a fee at 10 or a tenth of the price, whichever is more, and `{ "lessThanOrEqual": [{ "abs": { "subtract": ["a", "b"] } }, 5] }` keeps two values within 5; - `{ "subtract": [a, b] }`, `{ "divide": [a, b] }`, `{ "modulo": [a, b] }` or `{ "power": [a, b] }`; - a size: `{ "length": path }`, the characters of a string property (counted as `maxLength` counts them), `{ "byteLength": path }`, its UTF-8 bytes (as `maxBytes` counts them), or `{ "count": path }`, the items of an array property or the bytes of a byte array property. Where `maxLength`, `maxBytes` and `maxItems` bound one property by a fixed number, a size can be compared with another property or bounded only under a condition: `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit, and `{ "anyOf": [{ "greaterThan": ["fee", 0] }, { "lessThanOrEqual": [{ "length": "title" }, 20] }] }` keeps a free listing's title short. A property the document leaves out or sets to null has size 0, and a size never breaks a rule by itself (a value of another type would read as 0 too, but the schema validation refuses it first); -- a system time or height: `"$createdAt"`, `"$updatedAt"` and `"$transferredAt"`, block times in milliseconds, and each with `BlockHeight` or `CoreBlockHeight` appended, the Platform and Core block heights: those of the document's creation, of its last create, replace or price update, and of its last create, transfer or purchase. A rule may read one only on a type that records it by listing it in `required`, so every stored document holds it; it takes no `ifAbsent`, and an indexOnly type, whose deletes carry none, reads none. `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week from its creation, and `{ "lessThanOrEqual": ["$updatedAt", "endsAt"] }` refuses a replace or a price update after it ends. +- a system time or height: `"$createdAt"`, `"$updatedAt"` and `"$transferredAt"`, block times in milliseconds, and each with `BlockHeight` or `CoreBlockHeight` appended, the Platform and Core block heights: those of the document's creation, of its last create, replace or price update, and of its last create, transfer or purchase. A rule may read one only on a type that records it by listing it in `required`, so every stored document holds it; it takes no `ifAbsent`, and an indexOnly type, whose deletes carry none, reads none. `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week from its creation, and `{ "lessThanOrEqual": ["$updatedAt", "endsAt"] }` refuses a replace or a price update after it ends; +- a total read from state: `{ "countOf": [type] }` or `{ "countOf": [type, filter] }`, how many documents of a type of the same contract there are, or how many match the filter, and `{ "sumOf": [type, property] }` or `{ "sumOf": [type, property, filter] }`, the total of an integer property over them. A filter maps each key, a property of the counted type or `$ownerId`, to the value it must take, read from the document being written: one of its properties, `$ownerId`, an integer or a `{ "const": ... }`. The total is the one a count or sum tree keeps, as it will be once the write is done (the document itself counted when the type is its own, at its new values, and moved to its new owner by a transfer or purchase), so `{ "lessThanOrEqual": [{ "countOf": ["listing", { "$ownerId": "$ownerId" }] }, 10] }` on `listing` keeps every owner at ten listings or fewer. A whole-type total needs `documentsCountable` or `documentsSummable`, and a filtered one an index of the counted type whose properties are exactly the filter's keys, countable or summing the property. A rule is judged only when its own type is written, so a fact about another type can go stale after the write. A JSON number is always a value and a string always a path, so a property named `100` is not confused with the number, and the rule is a tree the meta-schema can check rather than a string with precedence rules to parse. Consensus holds nothing but this tree; an SDK may offer an infix spelling that compiles to it. @@ -740,11 +741,11 @@ The arithmetic is exact over `i128`. Operands are evaluated left to right, and e Conditions are checked in declared order and no further than the outcome needs: a comparison evaluates its left side, then its right; `anyOf` stops at the first condition that holds and `allOf` at the first that fails. A fault in a condition that is checked breaks the rule whatever the others would say, and `not` does not turn it into a pass. So an earlier condition guards a later one: `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` holds for a `b` of 0 without dividing by it, while the same two conditions the other way round divide by zero and break the rule. -The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type, every path `length` or `byteLength` measures a string property, every path `count` counts an array or byte array property, every system time or height a rule reads is one the type lists in `required` (and an indexOnly type reads none), every path compared with a string names a string property (and every constant compared with one that declares an `enum` is one of its values) (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `const`, every `present` or `absent`, every arithmetic operator and every operand (a size is one), and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. +The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type, every path `length` or `byteLength` measures a string property, every path `count` counts an array or byte array property, every system time or height a rule reads is one the type lists in `required` (and an indexOnly type reads none), every path compared with a string names a string property (and every constant compared with one that declares an `enum` is one of its values) (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `const`, every `present` or `absent`, every arithmetic operator and every operand (a size is one), that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value), and that the rules of one type read at most `max_property_constraint_aggregates` (4) distinct totals. Once every document type of the contract is parsed, a registration also checks every `countOf` and `sumOf`: the type it counts is one of the contract's and not indexOnly, a tree of it keeps the total, the filter's keys and values are integers, strings or identifiers of the same kind, and every property a value reads is required. The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. -Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. `$ownerId` and the system times and heights read the values of the document version being written (`validate_document_properties` takes them as `DocumentSystemValues`): on a create the writer and the block's time and heights, on a replace the writer, the stored creation and transfer values and the block's as the update. A client passes what it knows: an owner it does not know equals no identifier, and a rule reading a time or height it does not know is not judged. The SDK pre-checks use the device clock for the times a write records, and leave a rule reading a block height unjudged, since the height is unknown until the block. Transfers and purchases change no property, only the owner and the transfer's time and heights, so only the rules reading those are judged again, with the new values (`DocumentTypeV0Methods::validate_property_constraints_for_system_change`, next to the `distinctFrom` check). Price updates change only the update's time and heights, and are judged against the rules reading those the same way. +Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check changes nothing stored. It reads state only for the `countOf` and `sumOf` totals, which the document batch transformer reads when it builds the action (`Drive::fetch_property_constraint_aggregate`, billed with the write) and hands the check in `DocumentSystemValues::aggregates`; the limits bound its cost. `$ownerId` and the system times and heights read the values of the document version being written (`validate_document_properties` takes them as `DocumentSystemValues`): on a create the writer and the block's time and heights, on a replace the writer, the stored creation and transfer values and the block's as the update. A client passes what it knows: an owner it does not know equals no identifier, and a rule reading a time, a height or a total it is not given is not judged. The SDK pre-checks use the device clock for the times a write records, and leave a rule reading a block height unjudged, since the height is unknown until the block. Transfers and purchases change no property, only the owner and the transfer's time and heights, so only the rules reading those are judged again, with the new values (`DocumentTypeV0Methods::validate_property_constraints_for_system_change`, next to the `distinctFrom` check). Price updates change only the update's time and heights, and are judged against the rules reading those the same way. -In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, an `in`, a string comparison (`TextCompare`, `TextIn`), a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and how: by value, by presence, by size (`Length`, `Count`) or in a comparison of strings or identifiers, and `system_reads` the system times and heights it reads), each rule's `holds` and `violation` evaluate it against a document's data and `DocumentSystemValues`, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. +In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, an `in`, a string comparison (`TextCompare`, `TextIn`), a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and how: by value, by presence, by size (`Length`, `Count`) or in a comparison of strings or identifiers, `system_reads` the system times and heights it reads, and `aggregate_reads` the totals, each an `AggregateRead`), each rule's `holds` and `violation` evaluate it against a document's data and `DocumentSystemValues`, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. ## Rules and Guidelines diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index d6c7672b7a1..b54bb1e153f 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -425,7 +425,7 @@ From protocol version 14 a document type can declare rules its documents' proper } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `{ "startsWith": ["url", { "const": "https://" }] }` and `endsWith` test a string property's start or end, byte for byte, against a constant or another string property. `{ "contains": ["participants", "$ownerId"] }` holds when a typed array property has an element equal to the value, looked for as the array's elements are (an integer expression, a string or an identifier), so `{ "not": { "contains": ["labels", { "const": "used" }] } }` refuses a label; the array is reported as a read of kind `elements`. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, `not` if its one condition does not, `{ "ifThen": [a, b] }` if `b` holds whenever `a` does (evaluating `b` only then), and `{ "ifThenElse": [a, b, c] }` if `b` holds when `a` does and `c` when it does not; `{ "notIn": [expression, [values]] }` is an `in` negated, and `min`, `max` (two or more operands) and `abs` (one) join the arithmetic; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0), or a size: `{ "length": path }` and `{ "byteLength": path }` give the characters and UTF-8 bytes of a string property, and `{ "count": path }` the items of an array or the bytes of a byte array, so `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit (a size read is reported with kind `length` or `count`). A type that lists `$createdAt`, `$updatedAt` or `$transferredAt` (or any of them with `BlockHeight` or `CoreBlockHeight` appended) in `required` may read it too: `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week, and since a price update sets `$updatedAt` and a transfer or purchase `$transferredAt`, each is judged against the rules reading those. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `{ "in": [expression, [values]] }` holds if the expression takes one of two or more distinct integer values. A string property (an enum, say) is compared with `{ "equal": ["status", { "const": "closed" }] }` or `notEqual`, with another string property (`{ "notEqual": ["fromCurrency", "toCurrency"] }`), or listed with `{ "in": ["status", ["open", "pending"]] }`; a constant must be one of the property's `enum` values, and a string the document leaves out equals none, unless `{ "ifAbsent": ["status", "open"] }` gives it a default. Identifier properties compare the same way, with base58 constants: `{ "equal": ["paymentToken", { "const": "" }] }`, `{ "notEqual": ["buyerId", "sellerId"] }`, or `{ "in": ["paymentToken", ["", ""]] }`. `$ownerId`, the document's owner, is an identifier operand as well (`{ "equal": ["authorId", "$ownerId"] }`), and a transfer or purchase that would break such a rule is refused. `{ "startsWith": ["url", { "const": "https://" }] }` and `endsWith` test a string property's start or end, byte for byte, against a constant or another string property. `{ "contains": ["participants", "$ownerId"] }` holds when a typed array property has an element equal to the value, looked for as the array's elements are (an integer expression, a string or an identifier), so `{ "not": { "contains": ["labels", { "const": "used" }] } }` refuses a label; the array is reported as a read of kind `elements`. `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, `not` if its one condition does not, `{ "ifThen": [a, b] }` if `b` holds whenever `a` does (evaluating `b` only then), and `{ "ifThenElse": [a, b, c] }` if `b` holds when `a` does and `c` when it does not; `{ "notIn": [expression, [values]] }` is an `in` negated, and `min`, `max` (two or more operands) and `abs` (one) join the arithmetic; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. An operand may read an integer or a boolean property (true as 1, false as 0), or a size: `{ "length": path }` and `{ "byteLength": path }` give the characters and UTF-8 bytes of a string property, and `{ "count": path }` the items of an array or the bytes of a byte array, so `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit (a size read is reported with kind `length` or `count`). A type that lists `$createdAt`, `$updatedAt` or `$transferredAt` (or any of them with `BlockHeight` or `CoreBlockHeight` appended) in `required` may read it too: `{ "lessThanOrEqual": [{ "subtract": ["endsAt", "$createdAt"] }, 604800000] }` keeps a listing to a week, and since a price update sets `$updatedAt` and a transfer or purchase `$transferredAt`, each is judged against the rules reading those. `{ "countOf": [type, filter] }` and `{ "sumOf": [type, property, filter] }` read a total from state, how many documents of a type of the same contract match the filter or what an integer property adds up to over them, as the type's count or sum trees keep it once the write is done: `{ "lessThanOrEqual": [{ "countOf": ["listing", { "$ownerId": "$ownerId" }] }, 10] }` on `listing` keeps every owner at ten listings or fewer. The filter maps keys of the counted type (or `$ownerId`) to values read from the document being written, and may be left out for a whole-type total; the type needs `documentsCountable` or `documentsSummable` for a whole-type total, and an index whose properties are exactly the filter's keys (countable, or summing the property) otherwise. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: @@ -441,7 +441,7 @@ try { } ``` -To find a broken rule before paying for a refused transition, a contract lists a document type's rules and checks a document against them with the code consensus runs. The check covers the rules alone, not the JSON schema. It reads the document's owner for `$ownerId`, and the device clock for the times the write will record (`readsSystem` lists the ones a rule reads); a rule reading a block height is not checked, since the height is unknown until the block: +To find a broken rule before paying for a refused transition, a contract lists a document type's rules and checks a document against them with the code consensus runs. The check covers the rules alone, not the JSON schema. It reads the document's owner for `$ownerId`, and the device clock for the times the write will record (`readsSystem` lists the ones a rule reads); a rule reading a block height is not checked, since the height is unknown until the block, and neither is a rule reading a `countOf` or `sumOf` total, which only the platform reads from state: ```ts contract.documentTypePropertyConstraints('offer'); diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 736cc5a4278..6548aa57dfa 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -218,7 +218,7 @@ "uniqueItems": true }, "propertyConstraintExpression": { - "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; a system time or height the document type records ($createdAt, $updatedAtBlockHeight, ...); or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out, an integer, or a string for a string property compared with strings; add, multiply, min or max, two or more operands; subtract, divide, modulo or power, exactly two; abs, one; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out; const, a string constant compared with a string property", + "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; a system time or height the document type records ($createdAt, $updatedAtBlockHeight, ...); or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out, an integer, or a string for a string property compared with strings; add, multiply, min or max, two or more operands; subtract, divide, modulo or power, exactly two; abs, one; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out; countOf or sumOf, how many documents of a document type of this contract match a filter or the total of an integer property over them, as its count or sum trees keep it; const, a string constant compared with a string property", "type": [ "integer", "string", @@ -295,6 +295,39 @@ "description": "The number of items of the array property at this path, or of bytes of the byte array property, as maxItems counts them, or 0 when the document leaves it out", "$ref": "#/$defs/propertyConstraintPath" }, + "countOf": { + "description": "How many documents of a document type of this contract there are, as its count trees will keep it once the write is done: every one, which needs documentsCountable on that type, or those matching a filter, which needs a countable index of that type whose properties are exactly the filter's keys", + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/propertyConstraintAggregateType" + }, + { + "$ref": "#/$defs/propertyConstraintAggregateFilter" + } + ], + "items": false, + "minItems": 1 + }, + "sumOf": { + "description": "The total of an integer property over the documents of a document type of this contract, as its sum trees will keep it once the write is done: over every one, which needs documentsSummable naming the property on that type, or over those matching a filter, which needs an index of that type summable by the property whose properties are exactly the filter's keys", + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/propertyConstraintAggregateType" + }, + { + "description": "The integer property of that type to total", + "type": "string", + "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" + }, + { + "$ref": "#/$defs/propertyConstraintAggregateFilter" + } + ], + "items": false, + "minItems": 2 + }, "const": { "description": "A constant, only as one side of an equal or notEqual whose other side is the path of a string property (a string), or of an identifier property or $ownerId (a base58 identifier): a string on its own is a path, and an integer is written as itself", "type": "string" @@ -306,6 +339,49 @@ } } }, + "propertyConstraintAggregateType": { + "description": "The name of the document type of this contract a countOf or sumOf totals, the declaring type included", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "propertyConstraintAggregateFilter": { + "description": "Which documents a countOf or sumOf totals: those whose value at each key, a property path of the totalled type or $ownerId, equals the value given for it, read from the document being written: a property path of it, $ownerId, an integer, or a { const } string or base58 identifier", + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^(\\$ownerId|[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*)$" + }, + "additionalProperties": { + "type": [ + "integer", + "string", + "object" + ], + "if": { + "type": "string" + }, + "then": { + "pattern": "^(\\$ownerId|[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*)$" + }, + "else": { + "if": { + "type": "object" + }, + "then": { + "properties": { + "const": { + "type": "string" + } + }, + "required": [ + "const" + ], + "additionalProperties": false + } + } + } + }, "propertyConstraintPath": { "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer or boolean property when an operand reads its value, a string property when length or byteLength measures it, an array or byte array property when count counts its items, any property when present or absent tests it. Or $ownerId, the document's owner, which only a comparison of identifiers reads, or a system time or height an integer operand reads ($createdAt, $updatedAt, $transferredAt, and each with BlockHeight or CoreBlockHeight appended), one the document type lists in required", "type": "string", diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs index 35970b2fca0..dff9729ee45 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs @@ -4,6 +4,7 @@ use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; use crate::data_contract::document_type::class_methods::consensus_or_protocol_data_contract_error; +use crate::data_contract::document_type::class_methods::try_from_schema::validate_property_constraint_aggregates; use crate::data_contract::document_type::{ DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, DocumentType, @@ -285,6 +286,12 @@ impl DocumentType { } } + // What a `countOf` or `sumOf` totals is another document type of the contract, so it + // is checked once all are parsed. Inert for every protocol version before 14: only + // the tables carrying `parse_property_constraints: Some(_)` parse a rule at all. + validate_property_constraint_aggregates(&contract_document_types) + .map_err(consensus_or_protocol_data_contract_error)?; + Ok(contract_document_types) } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 8e85e3e8ab4..aee295c76ae 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -1,8 +1,12 @@ use crate::data_contract::config::DataContractConfig; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; use crate::data_contract::document_type::class_methods::apply_required_since::apply_required_since; use crate::data_contract::document_type::class_methods::parse_typed_array::parse_typed_array; use crate::data_contract::document_type::property_constraints::{ - parse_property_constraints, AffixPosition, ElementKind, EqualityKind, PropertyRead, + parse_property_constraints, AffixPosition, AggregateBinding, AggregateKind, ElementKind, + EqualityKind, PropertyConstraint, PropertyRead, }; use crate::data_contract::document_type::reference_lookup::{ MAX_LOOKUP_INDEX_NAME_LENGTH, MAX_LOOKUP_KEYS, MAX_LOOKUP_PATH_LENGTH, @@ -22,13 +26,14 @@ use crate::data_contract::document_type::{ }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; -use crate::document::property_names::ID; +use crate::document::property_names::{ID, OWNER_ID}; use crate::identity::Purpose; use crate::util::json_schema::resolve_uri; use crate::validation::operations::ProtocolValidationOperation; use crate::ProtocolError; use indexmap::IndexMap; use platform_value::btreemap_extensions::BTreeValueMapHelper; +use platform_value::string_encoding::Encoding; use platform_value::{Identifier, Value, ValueMapHelper}; use platform_version::version::PlatformVersion; use std::collections::{BTreeMap, BTreeSet}; @@ -2227,6 +2232,40 @@ fn apply_property_constraints_v0( ))); } } + // Which type an aggregate totals, and by which of its keys, is checked once + // every document type of the contract is parsed + for read in constraint.aggregate_reads() { + let operator = read.wire_name(); + // Nor a total read from state, which its delete is not given + if document_type.index_only { + return Err(structure_error(format!( + "rule \"{name}\" reads a {operator}, which a delete of an indexOnly \ + document is not given" + ))); + } + // The value a key is matched by is always there, so that every write reads + // the total of the documents matching it: a property the document could leave + // out, or whose enclosing object it could, would match nothing + for binding in read.filter.values() { + let AggregateBinding::Property { path, .. } = binding else { + continue; + }; + let mut prefix = String::new(); + for segment in path.split('.') { + if !prefix.is_empty() { + prefix.push('.'); + } + prefix.push_str(segment); + if !document_type.required_fields.contains(&prefix) { + return Err(structure_error(format!( + "rule \"{name}\" matches a {operator} by \"{path}\", but the \ + document type does not require \"{prefix}\": a value a \ + {operator} matches by must always be there, so list it in required" + ))); + } + } + } + } // A constant or a default a string property's `enum` does not list is a // typo: the property could never hold it for (path, constant) in constraint.text_constants() { @@ -2272,6 +2311,18 @@ fn apply_property_constraints_v0( constraints.len() ))); } + let max_aggregates = limits.max_property_constraint_aggregates; + let aggregates = constraints + .values() + .flat_map(PropertyConstraint::aggregate_reads) + .collect::>() + .len(); + if aggregates > usize::from(max_aggregates) { + return Err(structure_error(format!( + "reads {aggregates} distinct countOf and sumOf totals, above the maximum of \ + {max_aggregates}" + ))); + } let max_nodes = limits.max_property_constraint_nodes; for (name, constraint) in &constraints { let nodes = constraint.node_count(); @@ -2292,6 +2343,212 @@ fn apply_property_constraints_v0( Ok(()) } +/// How the key of an aggregate's filter, and the value it is matched against, +/// compare. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AggregateKeyKind { + Integer, + Text, + Identifier, +} + +impl AggregateKeyKind { + /// How a property of `property_type` compares as a key or a bound value: + /// `None` for one that cannot (a boolean, a byte array, an object, ...). + fn of(property_type: &DocumentPropertyType) -> Option { + match property_type { + DocumentPropertyType::String(_) => Some(AggregateKeyKind::Text), + property_type if property_type.is_identifier() => Some(AggregateKeyKind::Identifier), + property_type + if property_type.is_integer() + || matches!( + property_type, + DocumentPropertyType::U128 | DocumentPropertyType::I128 + ) => + { + Some(AggregateKeyKind::Integer) + } + _ => None, + } + } + + fn describe(self) -> &'static str { + match self { + AggregateKeyKind::Integer => "an integer", + AggregateKeyKind::Text => "a string", + AggregateKeyKind::Identifier => "an identifier", + } + } +} + +/// Checks every `countOf` and `sumOf` the `propertyConstraints` rules of +/// `document_types`, one contract's, read, once all of them are parsed: the +/// type it totals is one of them, and not an indexOnly one; a key of its filter +/// is `$ownerId` or an integer, string or identifier property of that type, +/// and the value matched against it is of the same kind: a property of the +/// declaring type, `$ownerId`, an integer, a string the key's `enum` lists, or +/// a base58 identifier; and a tree of that type keeps the total +/// ([`AggregateRead::whole_type_kept`], [`AggregateRead::answering_index`]), +/// so that a rule never reads a total a count or sum tree does not keep. +/// Registration only: the index and property settings it relies on do not +/// change on a contract update. +pub(in crate::data_contract::document_type::class_methods) fn validate_property_constraint_aggregates( + document_types: &BTreeMap, +) -> Result<(), DataContractError> { + for (type_name, document_type) in document_types { + let declaring = document_type.as_ref(); + for (rule, constraint) in declaring.property_constraints() { + let error = |message: String| { + DataContractError::InvalidContractStructure(format!( + "document type \"{type_name}\" propertyConstraints rule \"{rule}\" {message}" + )) + }; + for read in constraint.aggregate_reads() { + let counted_name = &read.document_type; + let totals = match &read.kind { + AggregateKind::Count => format!("counts \"{counted_name}\""), + AggregateKind::Sum { property } => { + format!("totals \"{property}\" of \"{counted_name}\"") + } + }; + let Some(counted) = document_types.get(counted_name) else { + return Err(error(format!( + "{totals}, which is no document type of this contract" + ))); + }; + let counted = counted.as_ref(); + if counted.index_only() { + return Err(error(format!( + "{totals}, an indexOnly type, whose rows a countOf or sumOf does not \ + total" + ))); + } + if read.filter.is_empty() { + if !read.whole_type_kept(counted) { + return Err(error(match &read.kind { + AggregateKind::Count => format!( + "counts every \"{counted_name}\" document, which needs \ + documentsCountable on \"{counted_name}\"" + ), + AggregateKind::Sum { property } => format!( + "totals \"{property}\" over every \"{counted_name}\" document, \ + which needs documentsSummable: \"{property}\" on \ + \"{counted_name}\"" + ), + })); + } + continue; + } + for (key, binding) in &read.filter { + let key_kind = if key == OWNER_ID { + AggregateKeyKind::Identifier + } else { + let Some(property) = counted.flattened_properties().get(key) else { + return Err(error(format!( + "{totals} by \"{key}\", which is not a property of \ + \"{counted_name}\" (a nested one is named by its dotted path)" + ))); + }; + let Some(kind) = AggregateKeyKind::of(&property.property_type) else { + return Err(error(format!( + "{totals} by \"{key}\", which has type {}: a key is an integer, \ + string or identifier property, or $ownerId", + property.property_type.name() + ))); + }; + kind + }; + let (bound, bound_kind) = match binding { + AggregateBinding::Owner => { + (OWNER_ID.to_string(), AggregateKeyKind::Identifier) + } + AggregateBinding::Integer(integer) => { + (integer.to_string(), AggregateKeyKind::Integer) + } + AggregateBinding::Constant(constant) => { + let kind = match key_kind { + AggregateKeyKind::Integer => { + return Err(error(format!( + "{totals} with \"{key}\" at the constant \ + \"{constant}\", but \"{key}\" is an integer: write \ + the integer bare" + ))); + } + AggregateKeyKind::Identifier => { + if Identifier::from_string(constant, Encoding::Base58).is_err() + { + return Err(error(format!( + "{totals} with \"{key}\" at \"{constant}\", which is \ + not a base58 identifier" + ))); + } + AggregateKeyKind::Identifier + } + AggregateKeyKind::Text => { + // A constant the key's `enum` does not list is a typo: + // no document could match it + if !enum_admits(counted.schema(), key, constant)? { + return Err(error(format!( + "{totals} with \"{key}\" at \"{constant}\", which is \ + not one of its enum values" + ))); + } + AggregateKeyKind::Text + } + }; + (format!("the constant \"{constant}\""), kind) + } + AggregateBinding::Property { path, .. } => { + let property_type = declaring + .flattened_properties() + .get(path) + .map(|property| &property.property_type); + let Some(kind) = property_type.and_then(AggregateKeyKind::of) else { + return Err(error(format!( + "{totals} with \"{key}\" at \"{path}\", which has type {}: \ + a value matched is an integer, string or identifier \ + property, $ownerId, an integer or a {{ \"const\": ... }}", + property_type.map_or("none".to_string(), |property_type| { + property_type.name() + }) + ))); + }; + (format!("\"{path}\""), kind) + } + }; + if bound_kind != key_kind { + return Err(error(format!( + "{totals} with \"{key}\", {}, at {bound}, {}", + key_kind.describe(), + bound_kind.describe() + ))); + } + } + if read.answering_index(&counted).is_none() { + let keys = read + .filter + .keys() + .map(|key| format!("\"{key}\"")) + .collect::>() + .join(", "); + let keeping = match &read.kind { + AggregateKind::Count => "countable index".to_string(), + AggregateKind::Sum { property } => { + format!("index with summable: \"{property}\"") + } + }; + return Err(error(format!( + "{totals} by {keys}, which no {keeping} of \"{counted_name}\" whose \ + properties are exactly those keys answers (a unique, contested, ranked, \ + time-range or indexOnly-terminal index answers none)" + ))); + } + } + } + } + Ok(()) +} + /// Whether the string property at the dotted `path` of `schema`, a document /// type's, may hold `value`: always, unless it declares an `enum` that does not /// list it. `$ref`s are followed into `schema_defs`, the contract's `$defs`. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index be23f965b2e..3453d1eaebe 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -1101,6 +1101,8 @@ mod name_rules_tests; #[cfg(all(test, feature = "validation"))] mod owner_reference_tests; #[cfg(all(test, feature = "validation"))] +mod property_constraint_aggregates_tests; +#[cfg(all(test, feature = "validation"))] mod property_constraints_tests; #[cfg(all(test, feature = "validation"))] mod reference_expression_tests; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraint_aggregates_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraint_aggregates_tests.rs new file mode 100644 index 00000000000..246ad0a0c4e --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraint_aggregates_tests.rs @@ -0,0 +1,641 @@ +//! `countOf` and `sumOf` in `propertyConstraints` rules (protocol version 14): +//! a rule may read a total another document type of the contract keeps in a +//! count or sum tree, which registration checks once every type is parsed. A +//! stored contract is not checked again. + +use super::immutable_tests::expect_structure_error; +use super::*; +use crate::consensus::basic::basic_error::BasicError; +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; +use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; +use crate::data_contract::document_type::property_constraints::{ + AggregateBinding, AggregateKind, AggregateRead, EqualityKind, PropertyRead, +}; +use crate::data_contract::DataContract; +use platform_value::string_encoding::Encoding; +use serde_json::json; + +/// A contract of four types: +/// * `listing`, whose trees keep its count and the total of its prices (at +/// most 10^9 each, so that a sum tree takes them), the +/// count of each owner's listings (`byOwner`) and of each owner's listings +/// by status (`byOwnerStatus`), and the total price of each category +/// (`byCategory`); +/// * `pledge`, whose `byCampaign` index keeps the count and the total amount +/// of each campaign's pledges; +/// * `profile`, with a unique index by owner; +/// * `campaign`, declaring `campaign_rules`, with required integer, identifier, +/// string and boolean properties to match by and an optional identifier. +/// +/// `listing_rules` go on `listing`. +fn contract( + campaign_rules: Option, + listing_rules: Option, + full_validation: bool, +) -> Result { + let identifier = |position: u32| { + json!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position + }) + }; + let mut listing = json!({ + "type": "object", + "documentsMutable": true, + "documentsCountable": true, + "documentsSummable": "price", + "properties": { + "price": { "type": "integer", "minimum": 0, "maximum": 1000000000, "position": 0 }, + "status": { + "type": "string", + "enum": ["open", "closed"], + "maxLength": 10, + "position": 1 + }, + "category": { "type": "integer", "minimum": 0, "maximum": 100, "position": 2 }, + "featured": { "type": "boolean", "position": 3 }, + "sellerRef": identifier(4) + }, + "required": ["price", "status", "category"], + "indices": [ + { + "name": "byOwner", + "properties": [{ "$ownerId": "asc" }], + "countable": "countable" + }, + { + "name": "byOwnerStatus", + "properties": [{ "$ownerId": "asc" }, { "status": "asc" }], + "countable": "countable" + }, + { + "name": "byCategory", + "properties": [{ "category": "asc" }], + "summable": "price" + } + ], + "additionalProperties": false + }); + if let Some(rules) = listing_rules { + listing["propertyConstraints"] = rules; + } + let mut campaign = json!({ + "type": "object", + "documentsMutable": true, + "properties": { + "goal": { "type": "integer", "minimum": 0, "position": 0 }, + "campaignRef": identifier(1), + "tier": { "type": "integer", "minimum": 0, "maximum": 9, "position": 2 }, + "kind": { + "type": "string", + "enum": ["open", "closed"], + "maxLength": 10, + "position": 3 + }, + "flag": { "type": "boolean", "position": 4 }, + "maybeRef": identifier(5) + }, + "required": ["goal", "campaignRef", "tier", "kind", "flag"], + "additionalProperties": false + }); + if let Some(rules) = campaign_rules { + campaign["propertyConstraints"] = rules; + } + let contract = json!({ + "$formatVersion": "1", + "id": Identifier::from([7; 32]).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { + "listing": listing, + "pledge": { + "type": "object", + "properties": { + "campaignId": identifier(0), + "amount": { "type": "integer", "minimum": 0, "maximum": 1000000000, "position": 1 }, + "tier": { "type": "integer", "minimum": 0, "maximum": 9, "position": 2 } + }, + "required": ["campaignId", "amount", "tier"], + "indices": [{ + "name": "byCampaign", + "properties": [{ "campaignId": "asc" }], + "countable": "countable", + "summable": "amount" + }], + "additionalProperties": false + }, + "profile": { + "type": "object", + "properties": { + "handle": { "type": "string", "maxLength": 20, "position": 0 } + }, + "required": ["handle"], + "indices": [{ + "name": "byOwner", + "properties": [{ "$ownerId": "asc" }], + "unique": true, + "countable": "countable" + }], + "additionalProperties": false + }, + "campaign": campaign + } + }); + DataContract::from_value( + platform_value::to_value(contract).expect("the contract converts"), + full_validation, + PlatformVersion::latest(), + ) +} + +/// A contract whose `campaign` type declares one rule, `rule`. +fn campaign_rule(rule: serde_json::Value) -> Result { + contract(Some(json!({ "rule": rule })), None, true) +} + +#[test] +fn should_register_totals_a_count_or_sum_tree_keeps() { + let contract = contract( + Some(json!({ + "pledgedWithinGoal": { + "lessThanOrEqual": [ + { "sumOf": ["pledge", "amount", { "campaignId": "campaignRef" }] }, + "goal" + ] + }, + "fewPledges": { + "lessThan": [{ "countOf": ["pledge", { "campaignId": "campaignRef" }] }, 100] + }, + "ownerHasListingsOrThereAreMany": { + "greaterThan": [ + { "add": [ + { "countOf": ["listing", { "$ownerId": "$ownerId" }] }, + { "countOf": ["listing"] } + ] }, + 0 + ] + } + })), + Some(json!({ + "atMostTenPerOwner": { + "lessThanOrEqual": [{ "countOf": ["listing", { "$ownerId": "$ownerId" }] }, 10] + }, + "openListingsOfOwner": { + "lessThan": [ + { + "countOf": [ + "listing", + { "$ownerId": "$ownerId", "status": { "const": "open" } } + ] + }, + 5 + ] + }, + "categoryTotal": { + "lessThan": [ + { "sumOf": ["listing", "price", { "category": "category" }] }, + 1000000 + ] + }, + "allListingPrices": { + "lessThan": [{ "sumOf": ["listing", "price"] }, 1000000000] + } + })), + true, + ) + .expect("every total is kept by a tree"); + + let campaign = contract + .document_type_for_name("campaign") + .expect("campaign"); + let rules = campaign.property_constraints(); + assert_eq!(rules.len(), 3); + assert_eq!( + rules["pledgedWithinGoal"].aggregate_reads(), + [&AggregateRead { + kind: AggregateKind::Sum { + property: "amount".to_string() + }, + document_type: "pledge".to_string(), + filter: [( + "campaignId".to_string(), + AggregateBinding::Property { + path: "campaignRef".to_string(), + kind: Some(EqualityKind::Identifier), + } + )] + .into(), + of_own_type: false, + }] + ); + assert_eq!( + rules["pledgedWithinGoal"].property_reads(), + [ + ("campaignRef", PropertyRead::Identifier), + ("goal", PropertyRead::Value) + ] + ); + assert!(rules["ownerHasListingsOrThereAreMany"].reads_owner()); + assert!(!rules["fewPledges"].reads_owner()); + + let listing = contract.document_type_for_name("listing").expect("listing"); + let own = listing.property_constraints(); + assert_eq!(own.len(), 4); + assert!(own["atMostTenPerOwner"].aggregate_reads()[0].of_own_type); + assert!(own["atMostTenPerOwner"].reads_owner()); + // The category the document counts toward is its own, whoever owns it + assert!(!own["categoryTotal"].reads_owner()); +} + +/// A total no tree keeps, or a filter whose keys and values do not match in +/// kind, is refused when the contract registers. +#[test] +fn should_refuse_a_total_no_tree_keeps() { + let refused = |rule: serde_json::Value, needle: &str| { + expect_structure_error(campaign_rule(rule), needle); + }; + let at_most = |operand: serde_json::Value| json!({ "lessThanOrEqual": [operand, 10] }); + + refused( + at_most(json!({ "countOf": ["order"] })), + "document type \"campaign\" propertyConstraints rule \"rule\" counts \"order\", which is \ + no document type of this contract", + ); + refused( + at_most(json!({ "countOf": ["pledge"] })), + "counts every \"pledge\" document, which needs documentsCountable on \"pledge\"", + ); + refused( + at_most(json!({ "sumOf": ["listing", "category"] })), + "totals \"category\" over every \"listing\" document, which needs documentsSummable: \ + \"category\" on \"listing\"", + ); + // `byOwnerStatus` keeps the count by owner and status, not by status alone + refused( + at_most(json!({ "countOf": ["listing", { "status": "kind" }] })), + "counts \"listing\" by \"status\", which no countable index of \"listing\" whose \ + properties are exactly those keys answers", + ); + refused( + at_most(json!({ "sumOf": ["listing", "price", { "$ownerId": "$ownerId" }] })), + "totals \"price\" of \"listing\" by \"$ownerId\", which no index with summable: \ + \"price\" of \"listing\"", + ); + // A unique index keeps no count + refused( + at_most(json!({ "countOf": ["profile", { "$ownerId": "$ownerId" }] })), + "counts \"profile\" by \"$ownerId\", which no countable index of \"profile\"", + ); + refused( + at_most(json!({ "countOf": ["listing", { "shade": "tier" }] })), + "counts \"listing\" by \"shade\", which is not a property of \"listing\"", + ); + refused( + at_most(json!({ "countOf": ["listing", { "featured": "flag" }] })), + "counts \"listing\" by \"featured\", which has type boolean", + ); + refused( + at_most(json!({ "countOf": ["listing", { "$ownerId": 3 }] })), + "counts \"listing\" with \"$ownerId\", an identifier, at 3, an integer", + ); + refused( + at_most(json!({ "countOf": ["listing", { "$ownerId": "tier" }] })), + "counts \"listing\" with \"$ownerId\", an identifier, at \"tier\", an integer", + ); + refused( + at_most(json!({ "sumOf": ["listing", "price", { "category": "kind" }] })), + "totals \"price\" of \"listing\" with \"category\", an integer, at \"kind\", a string", + ); + refused( + at_most(json!({ "sumOf": ["listing", "price", { "category": { "const": "3" } }] })), + "with \"category\" at the constant \"3\", but \"category\" is an integer: write the \ + integer bare", + ); + refused( + at_most(json!({ "countOf": ["listing", { "$ownerId": { "const": "not-base58!" } }] })), + "with \"$ownerId\" at \"not-base58!\", which is not a base58 identifier", + ); + refused( + at_most(json!({ + "countOf": ["listing", { "$ownerId": "$ownerId", "status": { "const": "opne" } }] + })), + "with \"status\" at \"opne\", which is not one of its enum values", + ); + refused( + at_most(json!({ "sumOf": ["listing", "price", { "category": "flag" }] })), + "with \"category\" at \"flag\", which has type boolean", + ); +} + +/// The value a key is matched by must always be there, so that every write +/// reads the total of the documents matching it. +#[test] +fn should_refuse_a_value_the_document_may_leave_out() { + expect_structure_error( + campaign_rule(json!({ + "lessThan": [{ "countOf": ["listing", { "sellerRef": "maybeRef" }] }, 3] + })), + "rule \"rule\" matches a countOf by \"maybeRef\", but the document type does not \ + require \"maybeRef\"", + ); +} + +/// At most `max_property_constraint_aggregates` distinct totals per type, a +/// total read twice counting once; checked under full validation only. +#[test] +fn should_cap_the_distinct_totals_a_type_reads() { + let limit = PlatformVersion::latest() + .system_limits + .max_property_constraint_aggregates; + assert_eq!(limit, 4); + let total = |tier: u32| json!({ "sumOf": ["listing", "price", { "category": tier }] }); + let rules = |count: u32| { + (0..count) + .map(|tier| { + ( + format!("rule{tier}"), + json!({ "lessThan": [total(tier), 1000] }), + ) + }) + .collect::>() + }; + + contract(Some(rules(4).into()), None, true).expect("four totals are within the limit"); + let mut twice = rules(4); + twice.insert("again".to_string(), json!({ "greaterThan": [total(0), 0] })); + contract(Some(twice.into()), None, true).expect("a total read twice counts once"); + expect_structure_error( + contract(Some(rules(5).into()), None, true), + "reads 5 distinct countOf and sumOf totals, above the maximum of 4", + ); + contract(Some(rules(5).into()), None, false).expect("a stored contract is not re-checked"); +} + +/// A stored contract is not checked again: the checks ran when it registered, +/// and nothing they rely on changes on an update. +#[test] +fn should_not_check_a_stored_contract_again() { + let stored = contract( + Some(json!({ "rule": { "lessThan": [{ "countOf": ["order"] }, 3] } })), + None, + false, + ) + .expect("a stored contract parses"); + assert_eq!( + stored + .document_type_for_name("campaign") + .expect("campaign") + .property_constraints()["rule"] + .aggregate_reads()[0] + .document_type, + "order" + ); +} + +/// The meta-schema checks the shapes of `countOf` and `sumOf` when a contract +/// registers. +#[test] +fn should_refuse_malformed_totals_through_the_meta_schema() { + for operand in [ + json!({ "countOf": [] }), + json!({ "countOf": ["listing", {}] }), + json!({ "countOf": ["listing", { "$ownerId": "$ownerId" }, 1] }), + json!({ "sumOf": ["pledge"] }), + json!({ "sumOf": ["pledge", "amount", { "campaignId": true }] }), + json!({ "countOf": ["listing", { "$createdAt": 1 }] }), + json!({ "countOf": ["listing", { "status": { "const": "open", "extra": 1 } }] }), + ] { + let registered = campaign_rule(json!({ "lessThan": [operand.clone(), 3] })); + assert!( + matches!( + ®istered, + Err(ProtocolError::ConsensusError(boxed)) + if matches!(**boxed, ConsensusError::BasicError(BasicError::JsonSchemaError(_))) + ), + "{operand}: the meta-schema should refuse it, got {registered:?}" + ); + } +} + +/// An indexOnly type neither reads a total, which its delete is not given, +/// nor is totalled. +#[test] +fn should_keep_totals_away_from_index_only_types() { + let rows = |rules: Option| { + let mut rows = json!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "properties": { + "day": { "type": "integer", "minimum": 0, "position": 0 } + }, + "required": ["day"], + "indices": [{ + "name": "byDay", + "properties": [{ "day": "asc" }], + "countable": "countable" + }], + "additionalProperties": false + }); + if let Some(rules) = rules { + rows["propertyConstraints"] = rules; + } + let contract = json!({ + "$formatVersion": "1", + "id": Identifier::from([7; 32]).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { + "row": rows, + "note": { + "type": "object", + "properties": { + "day": { "type": "integer", "minimum": 0, "position": 0 } + }, + "required": ["day"], + "propertyConstraints": { + "rule": { + "lessThan": [{ "countOf": ["row", { "day": "day" }] }, 3] + } + }, + "additionalProperties": false + } + } + }); + DataContract::from_value( + platform_value::to_value(contract).expect("the contract converts"), + true, + PlatformVersion::latest(), + ) + }; + expect_structure_error( + rows(None), + "rule \"rule\" counts \"row\", an indexOnly type, whose rows a countOf or sumOf does not \ + total", + ); + expect_structure_error( + rows(Some(json!({ + "own": { "lessThan": [{ "countOf": ["row", { "day": "day" }] }, 3] } + }))), + "rule \"own\" reads a countOf, which a delete of an indexOnly document is not given", + ); +} + +/// The values a filter takes are typed as the counted type keys its index, so +/// that an integer, a string constant, an identifier constant, a property or +/// the owner each match a document holding the same value; the index the read +/// is answered from is the one whose properties are exactly its keys, and a +/// document adds to a total of its own type only when it matches. +#[test] +fn should_match_a_document_by_the_values_its_filter_takes() { + let platform_version = PlatformVersion::latest(); + let contract = contract(None, None, true).expect("the contract registers"); + let listing = contract.document_type_for_name("listing").expect("listing"); + let owner = Identifier::from([3; 32]); + let other = Identifier::from([4; 32]); + let document = |category: u64, status: &str| { + Value::from(std::collections::BTreeMap::from([ + ("price".to_string(), Value::U64(40)), + ("category".to_string(), Value::U64(category)), + ("status".to_string(), Value::Text(status.to_string())), + ])) + }; + let read = |kind: AggregateKind, filter: Vec<(&str, AggregateBinding)>| AggregateRead { + kind, + document_type: "listing".to_string(), + filter: filter + .into_iter() + .map(|(key, binding)| (key.to_string(), binding)) + .collect(), + of_own_type: true, + }; + let price = || AggregateKind::Sum { + property: "price".to_string(), + }; + + // An integer constant, against the category's own integer type + let by_category = read(price(), vec![("category", AggregateBinding::Integer(3))]); + assert_eq!( + by_category + .answering_index(&listing) + .map(|index| index.name.as_str()), + Some("byCategory") + ); + let values = by_category + .filter_values(listing, &document(9, "open"), owner, platform_version) + .expect("reads") + .expect("an integer the key holds"); + assert_eq!( + by_category + .contribution( + listing, + &values, + &document(3, "open"), + owner, + platform_version + ) + .expect("reads"), + 40 + ); + assert_eq!( + by_category + .contribution( + listing, + &values, + &document(4, "open"), + owner, + platform_version + ) + .expect("reads"), + 0 + ); + + // The owner and a string constant, answered by the two-key index + let open_of_owner = read( + AggregateKind::Count, + vec![ + ("$ownerId", AggregateBinding::Owner), + ("status", AggregateBinding::Constant("open".to_string())), + ], + ); + assert_eq!( + open_of_owner + .answering_index(&listing) + .map(|index| index.name.as_str()), + Some("byOwnerStatus") + ); + let values = open_of_owner + .filter_values(listing, &document(1, "closed"), owner, platform_version) + .expect("reads") + .expect("the owner and a string"); + let counted = |data: Value, owner_id: Identifier| { + open_of_owner + .contribution(listing, &values, &data, owner_id, platform_version) + .expect("reads") + }; + assert_eq!(counted(document(1, "open"), owner), 1); + assert_eq!(counted(document(1, "closed"), owner), 0); + assert_eq!(counted(document(1, "open"), other), 0); + + // An identifier constant for the owner key + let of_one_owner = read( + AggregateKind::Count, + vec![( + "$ownerId", + AggregateBinding::Constant(owner.to_string(Encoding::Base58)), + )], + ); + let values = of_one_owner + .filter_values(listing, &document(1, "open"), other, platform_version) + .expect("reads") + .expect("a base58 identifier"); + assert_eq!( + values, + [("$ownerId".to_string(), Value::Identifier([3; 32]))] + ); + + // A property the document leaves out matches nothing + let by_bound_category = read( + price(), + vec![( + "category", + AggregateBinding::Property { + path: "tier".to_string(), + kind: None, + }, + )], + ); + assert_eq!( + by_bound_category + .filter_values(listing, &document(1, "open"), owner, platform_version) + .expect("reads"), + None + ); + + // Another type's documents never count toward it + let not_own = AggregateRead { + of_own_type: false, + ..by_category.clone() + }; + let values = not_own + .filter_values(listing, &document(3, "open"), owner, platform_version) + .expect("reads") + .expect("an integer"); + assert_eq!( + not_own + .contribution( + listing, + &values, + &document(3, "open"), + owner, + platform_version + ) + .expect("reads"), + 0 + ); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index f86caed09b6..902bc6c3c5e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -124,7 +124,8 @@ pub(crate) mod property_names { /// See `parse_doctype_reference` in `try_from_schema`. pub const CREATOR_REFERS_TO: &str = "creatorRefersTo"; /// Doctype-level object of named rules, each a condition on the document's - /// properties (a comparison of two integer expressions, of a string or an + /// properties (a comparison of two integer expressions, which may read a + /// `countOf` or `sumOf` total of a type of the contract, of a string or an /// identifier property with constants or with another property of its /// kind, an `in` or `notIn` list of values, a `startsWith` or `endsWith`, /// a `contains`, a `present` or `absent` test, or an `anyOf`, `allOf`, diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/aggregate.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/aggregate.rs new file mode 100644 index 00000000000..5edab66d072 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/aggregate.rs @@ -0,0 +1,167 @@ +//! Where the total an [`AggregateRead`] reads is kept, and what the document +//! being written adds to it. Registration checks with these that a tree keeps +//! every total a rule reads; consensus builds its reads with them. + +use super::{AggregateBinding, AggregateKind, AggregateRead}; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; +use crate::data_contract::document_type::index::Index; +use crate::data_contract::document_type::methods::DocumentTypeV0Methods; +use crate::data_contract::document_type::DocumentTypeRef; +use crate::document::property_names::OWNER_ID; +use crate::ProtocolError; +use platform_value::string_encoding::Encoding; +use platform_value::{Identifier, Value}; +use platform_version::version::PlatformVersion; + +impl AggregateRead { + /// Whether `counted`, the type the read totals, keeps the total over all + /// of its documents: `documentsCountable` for a `countOf`, and + /// `documentsSummable` naming the property for a `sumOf`. Only a read + /// with an empty filter reads it. + pub fn whole_type_kept(&self, counted: DocumentTypeRef) -> bool { + match &self.kind { + AggregateKind::Count => counted.documents_countable(), + AggregateKind::Sum { property } => { + counted.documents_summable() == Some(property.as_str()) + } + } + } + + /// The index of `counted`, the type the read totals, whose trees keep the + /// total of the documents matching the filter: the first in name order + /// whose properties are exactly the filter's keys, countable for a + /// `countOf` and summing the property for a `sumOf`, and plain: not unique, + /// contested, ranked, over a time range or with an indexOnly terminal, + /// whose trees keep their totals elsewhere or not at all. `None` for an + /// empty filter, and when no index answers, which registration refuses. + pub fn answering_index<'a>(&self, counted: &'a DocumentTypeRef) -> Option<&'a Index> { + if self.filter.is_empty() { + return None; + } + counted.indexes().values().find(|index| { + let plain = !index.unique + && index.contested_index.is_none() + && index.time_range.is_none() + && index.terminal.is_none() + && !index.ranked_countable + && index.ranked_countable_at.is_empty() + && !index.ranked_summable + && !index.ranked_averageable; + // An index lists a property once, so equal lengths and every property + // among the keys make the two the same set + let keyed_by_filter = index.properties.len() == self.filter.len() + && index + .properties + .iter() + .all(|property| self.filter.contains_key(&property.name)); + let keeps_total = match &self.kind { + AggregateKind::Count => index.countable.is_countable(), + AggregateKind::Sum { property } => { + index.summable.as_deref() == Some(property.as_str()) + } + }; + plain && keyed_by_filter && keeps_total + }) + } + + /// The filter's keys with the values they must take, for a document being + /// written with properties `data` and owner `owner_id`: each value as the + /// key of `counted`, the type the read totals, holds it, which + /// `counted.serialize_value_for_key` accepts. `None` when a value the + /// document gives is missing or is one the key cannot hold: no document of + /// `counted` can then match, and the total is 0. Registration makes every + /// value always present and of the key's kind, but the read is built before + /// the document's schema is validated, which refuses such a document + /// anyway. + pub fn filter_values( + &self, + counted: DocumentTypeRef, + data: &Value, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result>, ProtocolError> { + let mut values = Vec::with_capacity(self.filter.len()); + for (key, binding) in &self.filter { + let value = match binding { + AggregateBinding::Owner => Value::Identifier(owner_id.to_buffer()), + AggregateBinding::Integer(integer) => Value::I128(*integer), + AggregateBinding::Property { path, .. } => { + match data.get_optional_value_at_path(path) { + Ok(Some(value)) if !value.is_null() => value.clone(), + _ => return Ok(None), + } + } + AggregateBinding::Constant(constant) => { + let identifier_key = key == OWNER_ID + || counted + .flattened_properties() + .get(key) + .is_some_and(|property| property.property_type.is_identifier()); + if identifier_key { + match Identifier::from_string(constant, Encoding::Base58) { + Ok(identifier) => Value::Identifier(identifier.to_buffer()), + Err(_) => return Ok(None), + } + } else { + Value::Text(constant.clone()) + } + } + }; + if counted + .serialize_value_for_key(key, &value, platform_version) + .is_err() + { + return Ok(None); + } + values.push((key.clone(), value)); + } + Ok(Some(values)) + } + + /// What the document with properties `data` and owner `owner_id` adds to + /// the total the read takes over `counted` with `filter_values` + /// ([`Self::filter_values`]): 0 unless the read totals the writer's own + /// type and the document matches every key, then 1 for a `countOf` and its + /// value of the property for a `sumOf`. Consensus reads the stored total + /// before the write, then takes off what the document added as it was + /// stored and adds what it adds as it is written. + pub fn contribution( + &self, + counted: DocumentTypeRef, + filter_values: &[(String, Value)], + data: &Value, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result { + if !self.of_own_type { + return Ok(0); + } + for (key, value) in filter_values { + let held = if key == OWNER_ID { + Value::Identifier(owner_id.to_buffer()) + } else { + match data.get_optional_value_at_path(key) { + Ok(Some(held)) if !held.is_null() => held.clone(), + _ => return Ok(0), + } + }; + let Ok(held) = counted.serialize_value_for_key(key, &held, platform_version) else { + return Ok(0); + }; + if held != counted.serialize_value_for_key(key, value, platform_version)? { + return Ok(0); + } + } + match &self.kind { + AggregateKind::Count => Ok(1), + // A value that is no integer adds nothing: the read is built before the + // document's schema is validated, which refuses such a document anyway + AggregateKind::Sum { property } => match data.get_optional_value_at_path(property) { + Ok(Some(value)) => Ok(value.to_integer::().unwrap_or(0)), + _ => Ok(0), + }, + } + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index b3312073af5..99cd1943f29 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -57,6 +57,13 @@ //! [`SystemProperty`]), on a document type that records them, so a price update //! and a transfer or a purchase, which change some of them, are judged against //! the rules reading those. +//! `countOf` and `sumOf` are integer operands read from state, [`AggregateRead`]: +//! how many documents of a type of the same contract match, or the total of an +//! integer property over them, from the count and sum trees their indexes keep +//! (`{ "countOf": ["listing", { "$ownerId": "$ownerId" }] }`), as the total will +//! be once the write is done. Consensus reads them before judging the rules +//! ([`DocumentSystemValues::aggregates`]); a rule reading one it is not given is +//! not judged. //! How the arithmetic treats overflow, division and powers is set out on //! [`ConstraintExpression::evaluate`], and how conditions combine on //! [`PropertyConstraint::holds`]. @@ -69,6 +76,7 @@ //! Nothing here is serialized: a document type rebuilds its rules from its //! stored schema whenever the contract is loaded. +mod aggregate; #[cfg(test)] mod tests; @@ -106,6 +114,8 @@ const NOT_IN: &str = "notIn"; const MIN: &str = "min"; const MAX: &str = "max"; const ABS: &str = "abs"; +const COUNT_OF: &str = "countOf"; +const SUM_OF: &str = "sumOf"; const PRESENT: &str = "present"; const ABSENT: &str = "absent"; const IN: &str = "in"; @@ -120,7 +130,7 @@ const CONST: &str = "const"; /// Every key an operand object may hold, for the errors. const OPERAND_KEYS: &str = "add, subtract, multiply, divide, modulo, power, min, max, abs, \ - ifAbsent, length, byteLength or count"; + ifAbsent, length, byteLength, count, countOf or sumOf"; /// The deepest a condition or an operand may sit in its rule: the rule's own /// condition at depth 0, and each operand of a comparison, and each condition @@ -288,8 +298,8 @@ pub enum SystemChange { /// names. Consensus passes every one the document type records; a client /// passes those it knows. `$ownerId` equals no identifier when the owner is /// unknown, and [`PropertyConstraint::violation`] does not judge a rule reading -/// a time or a height it is not given. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +/// a time, a height or an aggregate it is not given. +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct DocumentSystemValues { pub owner_id: Option, pub created_at: Option, @@ -301,6 +311,10 @@ pub struct DocumentSystemValues { pub created_at_core_block_height: Option, pub updated_at_core_block_height: Option, pub transferred_at_core_block_height: Option, + /// The `countOf` and `sumOf` totals the rules read, each as it will be once + /// the write is done ([`AggregateRead`]). Consensus reads every one the + /// rules judged against the write read; a client gives none. + pub aggregates: BTreeMap, } impl DocumentSystemValues { @@ -327,6 +341,7 @@ impl DocumentSystemValues { created_at_core_block_height: Some(block_info.core_height), updated_at_core_block_height: Some(block_info.core_height), transferred_at_core_block_height: Some(block_info.core_height), + aggregates: BTreeMap::new(), } } @@ -343,6 +358,7 @@ impl DocumentSystemValues { created_at_core_block_height: document.created_at_core_block_height(), updated_at_core_block_height: document.updated_at_core_block_height(), transferred_at_core_block_height: document.transferred_at_core_block_height(), + aggregates: BTreeMap::new(), } } @@ -403,6 +419,79 @@ impl SizeMeasure { } } +/// What an aggregate operand totals over the documents it matches. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AggregateKind { + /// `countOf`: how many there are. + Count, + /// `sumOf`: the total of the integer property at `property` over them. + Sum { property: String }, +} + +/// The value a key of an aggregate's filter must take, read from the document +/// being written. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AggregateBinding { + /// The document's property at the dotted `path`; `kind` is how it + /// compares, `None` for an integer. + Property { + path: String, + kind: Option, + }, + /// `"$ownerId"`: the document's owner. + Owner, + /// An integer. + Integer(i128), + /// `{ "const": ... }`: a string, or a base58 identifier where the key is + /// an identifier. + Constant(String), +} + +/// A `countOf` or `sumOf` operand: the documents of the type +/// `document_type`, of the same contract, whose values at the keys of +/// `filter` (a property path of that type, or `$ownerId`) equal the values +/// the bindings read from the document being written, counted or with an +/// integer property totalled; every document of the type when the filter is +/// empty. The total is the one a count or sum tree of that type keeps, as it +/// will be once the write is done: when the type is the writer's own, the +/// document being written counts as it will be stored, and no longer as it +/// was. A registered rule reads only totals a tree keeps: `documentsCountable` +/// or `documentsSummable` for a whole type, and otherwise an index whose +/// properties are exactly the filter's keys ([`Self::answering_index`]). +/// +/// `{ "countOf": ["listing", { "$ownerId": "$ownerId" }] }` counts the +/// writer's listings; `{ "sumOf": ["pledge", "amount", { "campaignId": "campaignId" }] }` +/// totals the pledges to the document's campaign. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AggregateRead { + pub kind: AggregateKind, + pub document_type: String, + pub filter: BTreeMap, + /// Whether `document_type` is the type declaring the rule, so that the + /// document being written is among those it totals. + pub of_own_type: bool, +} + +impl AggregateRead { + /// The operand key declaring it. + pub fn wire_name(&self) -> &'static str { + match self.kind { + AggregateKind::Count => COUNT_OF, + AggregateKind::Sum { .. } => SUM_OF, + } + } + + /// Whether the total depends on the document's owner: a binding reads + /// `$ownerId`, or the type is the writer's own and a key is `$ownerId`, so + /// that the document counts toward another owner once it changes hands. + pub fn reads_owner(&self) -> bool { + self.filter + .values() + .any(|binding| *binding == AggregateBinding::Owner) + || (self.of_own_type && self.filter.contains_key(OWNER_ID)) + } +} + /// An integer expression, one side of a rule or an operand inside one. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConstraintExpression { @@ -438,6 +527,9 @@ pub enum ConstraintExpression { Max(Vec), /// `abs`: the absolute value of its one operand. Abs(Box), + /// `countOf` or `sumOf`: a total read from state, its value in the + /// [`DocumentSystemValues`] the rule is judged with. + Aggregate(AggregateRead), } impl ConstraintExpression { @@ -458,7 +550,7 @@ impl ConstraintExpression { /// one measured, which the schema validation reported first refuses; /// * a system property takes its value in `system`, 0 when not given /// ([`PropertyConstraint::violation`] does not judge a rule reading one it - /// is not given); + /// is not given), and so does an aggregate; /// * `add` and `multiply` fold their operands from the left, so an overflow /// on the way is a fault even when a later operand would bring the result /// back in range; @@ -481,6 +573,9 @@ impl ConstraintExpression { match self { ConstraintExpression::Value(value) => Ok(*value), ConstraintExpression::System(property) => Ok(system.value(*property).unwrap_or(0)), + ConstraintExpression::Aggregate(read) => { + Ok(system.aggregates.get(read).copied().unwrap_or(0)) + } ConstraintExpression::Property { path, if_absent } => { property_value(data, path, *if_absent) } @@ -562,6 +657,8 @@ impl ConstraintExpression { | ConstraintExpression::Property { .. } | ConstraintExpression::Size { .. } | ConstraintExpression::System(_) => 0, + // One for each key and the value it takes + ConstraintExpression::Aggregate(read) => read.filter.len(), ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) | ConstraintExpression::Min(operands) @@ -576,13 +673,15 @@ impl ConstraintExpression { } } - /// Whether the expression reads at least one property. + /// Whether the expression reads at least one property, or a value read + /// from state, so that it is no constant. fn reads_property(&self) -> bool { match self { ConstraintExpression::Value(_) => false, ConstraintExpression::Property { .. } | ConstraintExpression::Size { .. } - | ConstraintExpression::System(_) => true, + | ConstraintExpression::System(_) + | ConstraintExpression::Aggregate(_) => true, ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) | ConstraintExpression::Min(operands) @@ -605,6 +704,19 @@ impl ConstraintExpression { match self { ConstraintExpression::Value(_) | ConstraintExpression::System(_) => {} ConstraintExpression::Property { path, .. } => reads.push((path, PropertyRead::Value)), + // The properties of the document being written its filter reads + ConstraintExpression::Aggregate(read) => { + for binding in read.filter.values() { + if let AggregateBinding::Property { path, kind } = binding { + let read = match kind { + None => PropertyRead::Value, + Some(EqualityKind::Text) => PropertyRead::Text, + Some(EqualityKind::Identifier) => PropertyRead::Identifier, + }; + reads.push((path, read)); + } + } + } ConstraintExpression::Size { measure, path } => reads.push((path, measure.read())), ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) @@ -632,7 +744,8 @@ impl ConstraintExpression { ConstraintExpression::System(property) => reads.push(*property), ConstraintExpression::Value(_) | ConstraintExpression::Property { .. } - | ConstraintExpression::Size { .. } => {} + | ConstraintExpression::Size { .. } + | ConstraintExpression::Aggregate(_) => {} ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) | ConstraintExpression::Min(operands) @@ -651,6 +764,42 @@ impl ConstraintExpression { } } } + + /// Appends the aggregates the expression reads to `reads`, in the order it + /// reads them. + fn collect_aggregate_reads<'a>(&'a self, reads: &mut Vec<&'a AggregateRead>) { + match self { + ConstraintExpression::Aggregate(read) => reads.push(read), + ConstraintExpression::Value(_) + | ConstraintExpression::Property { .. } + | ConstraintExpression::Size { .. } + | ConstraintExpression::System(_) => {} + ConstraintExpression::Add(operands) + | ConstraintExpression::Multiply(operands) + | ConstraintExpression::Min(operands) + | ConstraintExpression::Max(operands) => { + for operand in operands { + operand.collect_aggregate_reads(reads); + } + } + ConstraintExpression::Abs(operand) => operand.collect_aggregate_reads(reads), + ConstraintExpression::Subtract(left, right) + | ConstraintExpression::Divide(left, right) + | ConstraintExpression::Modulo(left, right) + | ConstraintExpression::Power(left, right) => { + left.collect_aggregate_reads(reads); + right.collect_aggregate_reads(reads); + } + } + } + + /// Whether an aggregate the expression reads depends on the document's + /// owner ([`AggregateRead::reads_owner`]). + fn reads_owner(&self) -> bool { + let mut reads = Vec::new(); + self.collect_aggregate_reads(&mut reads); + reads.into_iter().any(AggregateRead::reads_owner) + } } /// How a rule reads a property, which decides the properties it may name. @@ -693,7 +842,7 @@ pub enum ElementKind { /// array a `contains` looks in (the kind of its elements), since the /// declaration alone does not tell a string property, an identifier property /// or an integer one apart. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum EqualityKind { /// A string property. Text, @@ -1078,7 +1227,9 @@ impl PropertyConstraint { /// when the rule evaluates to false. A rule reading a system property /// `system` does not give is not judged: consensus gives every one the /// document type records, the only ones a rule may read, so only a client - /// that does not know one skips the rule. + /// that does not know one skips the rule. So is a rule reading an aggregate + /// `system` does not give: consensus reads every one before judging the + /// write, and a client, which cannot read state here, gives none. pub fn violation( &self, data: &Value, @@ -1088,6 +1239,10 @@ impl PropertyConstraint { .system_reads() .into_iter() .any(|property| system.value(property).is_none()) + || self + .aggregate_reads() + .into_iter() + .any(|read| !system.aggregates.contains_key(read)) { return None; } @@ -1167,11 +1322,20 @@ impl PropertyConstraint { reads } - /// Whether the rule compares the document's owner, `$ownerId`: then a + /// Whether the rule compares the document's owner, `$ownerId`, or reads an + /// aggregate depending on it ([`AggregateRead::reads_owner`]): then a /// transfer or a purchase, which changes the owner and nothing else, is /// judged against it too. pub fn reads_owner(&self) -> bool { match self { + PropertyConstraint::Compare { left, right, .. } => { + left.reads_owner() || right.reads_owner() + } + PropertyConstraint::In { operand, .. } => operand.reads_owner(), + PropertyConstraint::Contains { + needle: ContainsNeedle::Integer(expression), + .. + } => expression.reads_owner(), PropertyConstraint::Contains { needle: ContainsNeedle::IdentifierProperty(path), .. @@ -1195,9 +1359,7 @@ impl PropertyConstraint { .into_iter() .flatten() .any(|part| part.reads_owner()), - PropertyConstraint::Compare { .. } - | PropertyConstraint::In { .. } - | PropertyConstraint::TextCompare { .. } + PropertyConstraint::TextCompare { .. } | PropertyConstraint::TextCompareProperties { .. } | PropertyConstraint::TextIn { .. } | PropertyConstraint::TextAffix { .. } @@ -1207,6 +1369,58 @@ impl PropertyConstraint { } } + /// The aggregates the rule reads (`countOf`, `sumOf`), in declared order, + /// one read twice listed twice. + pub fn aggregate_reads(&self) -> Vec<&AggregateRead> { + let mut reads = Vec::new(); + self.collect_aggregate_reads(&mut reads); + reads + } + + fn collect_aggregate_reads<'a>(&'a self, reads: &mut Vec<&'a AggregateRead>) { + match self { + PropertyConstraint::Compare { left, right, .. } => { + left.collect_aggregate_reads(reads); + right.collect_aggregate_reads(reads); + } + PropertyConstraint::In { operand, .. } => operand.collect_aggregate_reads(reads), + PropertyConstraint::Contains { + needle: ContainsNeedle::Integer(expression), + .. + } => expression.collect_aggregate_reads(reads), + PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { + for condition in conditions { + condition.collect_aggregate_reads(reads); + } + } + PropertyConstraint::Not(condition) | PropertyConstraint::NotIn(condition) => { + condition.collect_aggregate_reads(reads) + } + PropertyConstraint::IfThen { + condition, + then, + otherwise, + } => { + for part in [Some(condition), Some(then), otherwise.as_ref()] + .into_iter() + .flatten() + { + part.collect_aggregate_reads(reads); + } + } + PropertyConstraint::TextCompare { .. } + | PropertyConstraint::TextCompareProperties { .. } + | PropertyConstraint::TextIn { .. } + | PropertyConstraint::TextAffix { .. } + | PropertyConstraint::IdentifierCompare { .. } + | PropertyConstraint::IdentifierCompareProperties { .. } + | PropertyConstraint::IdentifierIn { .. } + | PropertyConstraint::Contains { .. } + | PropertyConstraint::Present(_) + | PropertyConstraint::Absent(_) => {} + } + } + /// The system properties the rule reads (`"$createdAt"`, ...), in /// declared order, one read twice listed twice. `$ownerId` is /// [`Self::reads_owner`]'s. @@ -1622,6 +1836,14 @@ impl PropertyConstraint { } } +/// What the parse of one document type's rules knows of the type: its name, +/// which tells an aggregate of the type's own documents from one of another +/// type's, and `property_kind` ([`parse_property_constraints`]). +struct ParseContext<'a> { + document_type_name: &'a str, + property_kind: &'a dyn Fn(&str) -> Option, +} + /// Reads the `propertyConstraints` keyword of a document type's `schema`: /// every rule by its name, in name order, the order a document is checked /// against them. Empty when the schema declares none. `property_kind` tells @@ -1684,6 +1906,10 @@ pub fn parse_property_constraints( )); } + let context = ParseContext { + document_type_name, + property_kind, + }; let mut constraints = BTreeMap::new(); for (name, rule) in rules { let Some(name) = name.as_text().filter(|name| is_rule_name(name)) else { @@ -1694,7 +1920,7 @@ pub fn parse_property_constraints( }; // Where a condition or an operand sits in the rule (`anyOf[1].lessThan[0]`), // grown and trimmed in place as the parse descends and only read into an error - let constraint = parse_condition(rule, &mut String::new(), 0, property_kind) + let constraint = parse_condition(rule, &mut String::new(), 0, &context) .map_err(|message| structure_error(format!("rule \"{name}\" {message}")))?; if constraints.insert(name.to_string(), constraint).is_some() { return Err(structure_error(format!("declares rule \"{name}\" twice"))); @@ -1766,7 +1992,7 @@ fn parse_condition( value: &Value, at: &mut String, depth: usize, - property_kind: &dyn Fn(&str) -> Option, + context: &ParseContext, ) -> Result { if depth > MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH { return Err(format!( @@ -1787,16 +2013,12 @@ fn parse_condition( if path == OWNER_ID { Some(EqualityKind::Identifier) } else { - property_kind(path) + (context.property_kind)(path) } }; let condition = match key { - ANY_OF => { - PropertyConstraint::AnyOf(condition_list(body, key, at, depth + 1, property_kind)?) - } - ALL_OF => { - PropertyConstraint::AllOf(condition_list(body, key, at, depth + 1, property_kind)?) - } + ANY_OF => PropertyConstraint::AnyOf(condition_list(body, key, at, depth + 1, context)?), + ALL_OF => PropertyConstraint::AllOf(condition_list(body, key, at, depth + 1, context)?), NOT => { if single_entry(body).is_some_and(|(inner, _)| inner == NOT) { return Err(format!( @@ -1810,19 +2032,14 @@ fn parse_condition( of the same values says: declare that in" )); } - PropertyConstraint::Not(Box::new(parse_condition( - body, - at, - depth + 1, - property_kind, - )?)) + PropertyConstraint::Not(Box::new(parse_condition(body, at, depth + 1, context)?)) } IF_THEN | IF_THEN_ELSE => { let base = at.len(); let mut part = |index: usize, value: &Value| { // Writing to a `String` cannot fail let _ = write!(at, "[{index}]"); - let parsed = parse_condition(value, at, depth + 1, property_kind); + let parsed = parse_condition(value, at, depth + 1, context); at.truncate(base); parsed.map(Box::new) }; @@ -1892,7 +2109,7 @@ fn parse_condition( PropertyConstraint::TextIn { property, values } } else { at.push_str("[0]"); - let operand = parse_expression(operand, at, depth + 1)?; + let operand = parse_expression(operand, at, depth + 1, context)?; at.truncate(base); if !operand.reads_property() { at.truncate(parent); @@ -1966,7 +2183,7 @@ fn parse_condition( at.push_str("[1]"); // The kind of the array's elements decides what a const spells, and // what is checked against the parsed document type - let needle = match property_kind(array) { + let needle = match (context.property_kind)(array) { Some(EqualityKind::Text) => match text_side(needle, at)? { TextSide::Constant(value) => ContainsNeedle::TextConstant(value), TextSide::Property(property) => ContainsNeedle::TextProperty(property), @@ -1981,7 +2198,7 @@ fn parse_condition( integer is written as itself" )); } - None => ContainsNeedle::Integer(parse_expression(needle, at, depth + 1)?), + None => ContainsNeedle::Integer(parse_expression(needle, at, depth + 1, context)?), }; at.truncate(base); PropertyConstraint::Contains { @@ -2046,7 +2263,7 @@ fn parse_condition( }); } } - let (left, right) = operand_pair(body, at, depth + 1)?; + let (left, right) = operand_pair(body, at, depth + 1, context)?; if !left.reads_property() && !right.reads_property() { at.truncate(parent); return Err(format!( @@ -2074,7 +2291,7 @@ fn condition_list( key: &str, at: &mut String, depth: usize, - property_kind: &dyn Fn(&str) -> Option, + context: &ParseContext, ) -> Result, String> { let Some(values) = conditions.as_array().filter(|values| values.len() >= 2) else { return Err(format!("at {at} must list two or more conditions")); @@ -2090,7 +2307,7 @@ fn condition_list( says: list its conditions in the outer {key}" )); } - parsed.push(parse_condition(value, at, depth, property_kind)?); + parsed.push(parse_condition(value, at, depth, context)?); at.truncate(base); } Ok(parsed) @@ -2389,6 +2606,7 @@ fn parse_expression( value: &Value, at: &mut String, depth: usize, + context: &ParseContext, ) -> Result { if depth > MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH { return Err(format!( @@ -2448,24 +2666,29 @@ fn parse_expression( if_absent: integer_value(if_absent, at)?, } } - ADD => ConstraintExpression::Add(operand_list(operands, at, depth + 1)?), - MIN => ConstraintExpression::Min(operand_list(operands, at, depth + 1)?), - MAX => ConstraintExpression::Max(operand_list(operands, at, depth + 1)?), + ADD => ConstraintExpression::Add(operand_list(operands, at, depth + 1, context)?), + MIN => ConstraintExpression::Min(operand_list(operands, at, depth + 1, context)?), + MAX => ConstraintExpression::Max(operand_list(operands, at, depth + 1, context)?), ABS => { if operands.as_array().is_some() { return Err(format!( "at {at} must be one operand, not a list: abs takes a single operand" )); } - ConstraintExpression::Abs(Box::new(parse_expression(operands, at, depth + 1)?)) + ConstraintExpression::Abs(Box::new(parse_expression( + operands, + at, + depth + 1, + context, + )?)) } - MULTIPLY => ConstraintExpression::Multiply(operand_list(operands, at, depth + 1)?), + MULTIPLY => ConstraintExpression::Multiply(operand_list(operands, at, depth + 1, context)?), SUBTRACT => { - let (left, right) = operand_pair(operands, at, depth + 1)?; + let (left, right) = operand_pair(operands, at, depth + 1, context)?; ConstraintExpression::Subtract(Box::new(left), Box::new(right)) } DIVIDE | MODULO => { - let (dividend, divisor) = operand_pair(operands, at, depth + 1)?; + let (dividend, divisor) = operand_pair(operands, at, depth + 1, context)?; if divisor == ConstraintExpression::Value(0) { return Err(format!("at {at} divides by 0")); } @@ -2476,7 +2699,7 @@ fn parse_expression( } } POWER => { - let (base, exponent) = operand_pair(operands, at, depth + 1)?; + let (base, exponent) = operand_pair(operands, at, depth + 1, context)?; if let ConstraintExpression::Value(exponent) = exponent { if exponent < 0 { return Err(format!( @@ -2502,6 +2725,11 @@ fn parse_expression( path: path.to_string(), } } + // Which document type it names, and what its keys name, is checked + // once every document type of the contract is parsed + COUNT_OF | SUM_OF => { + ConstraintExpression::Aggregate(parse_aggregate(key == SUM_OF, operands, at, context)?) + } CONST => { at.truncate(parent); return Err(format!( @@ -2520,21 +2748,134 @@ fn parse_expression( Ok(expression) } +/// A `countOf` (`sum` false) or `sumOf` at `at`, listing a document type, +/// for a `sumOf` the integer property to total, and optionally the filter its +/// documents must match: an object of one or more keys, each a property path +/// of that type or `$ownerId`, and the value it must take, a property path of +/// the document being written, `$ownerId`, an integer or a `{ "const": ... }`. +fn parse_aggregate( + sum: bool, + operands: &Value, + at: &mut String, + context: &ParseContext, +) -> Result { + let parts = operands.as_array().map(Vec::as_slice).unwrap_or_default(); + let (document_type, property, filter) = match (sum, parts) { + (false, [document_type]) => (document_type, None, None), + (false, [document_type, filter]) => (document_type, None, Some(filter)), + (true, [document_type, property]) => (document_type, Some(property), None), + (true, [document_type, property, filter]) => (document_type, Some(property), Some(filter)), + (false, _) => { + return Err(format!( + "at {at} must list the document type to count, then optionally the values \ + its documents must match: [type] or [type, {{ key: value, ... }}]" + )) + } + (true, _) => { + return Err(format!( + "at {at} must list the document type, the integer property of it to total, \ + then optionally the values its documents must match: [type, property] or \ + [type, property, {{ key: value, ... }}]" + )) + } + }; + let Some(document_type) = document_type.as_text().filter(|name| !name.is_empty()) else { + return Err(format!("at {at} must name a document type first")); + }; + let kind = match property { + None => AggregateKind::Count, + Some(property) => { + let Some(property) = property.as_text().filter(|path| !path.is_empty()) else { + return Err(format!("at {at} must name the property to total second")); + }; + AggregateKind::Sum { + property: property.to_string(), + } + } + }; + let mut bindings = BTreeMap::new(); + if let Some(filter) = filter { + let base = at.len(); + // Writing to a `String` cannot fail + let _ = write!(at, "[{}]", if sum { 2 } else { 1 }); + let entries = match filter { + Value::Map(entries) if !entries.is_empty() => entries, + _ => { + return Err(format!( + "at {at} must match its documents by one or more keys: {{ key: value, ... }}" + )) + } + }; + for (key, binding) in entries { + let Some(key) = key.as_text().filter(|key| !key.is_empty()) else { + return Err(format!( + "at {at} holds the key {}, but a key is a property path of the type or \ + $ownerId", + key.non_qualified_string_representation() + )); + }; + if key.starts_with('$') && key != OWNER_ID { + return Err(format!( + "at {at} matches by {key}, but the one system value a key names is $ownerId" + )); + } + let binding = match binding { + Value::Text(path) if path == OWNER_ID => AggregateBinding::Owner, + Value::Text(path) if path.starts_with('$') => { + return Err(format!( + "at {at}.{key} takes {path}, but the one system value a key takes is \ + $ownerId" + )) + } + Value::Text(path) => AggregateBinding::Property { + kind: (context.property_kind)(path), + path: path.clone(), + }, + binding if is_number(binding) => { + AggregateBinding::Integer(integer_value(binding, &format!("{at}.{key}"))?) + } + binding => match single_entry(binding) { + Some((CONST, Value::Text(constant))) => { + AggregateBinding::Constant(constant.clone()) + } + _ => { + return Err(format!( + "at {at}.{key} must be a property path of the document, $ownerId, \ + an integer or a {{ \"const\": ... }} string or base58 identifier" + )) + } + }, + }; + if bindings.insert(key.to_string(), binding).is_some() { + return Err(format!("at {at} matches by {key} twice")); + } + } + at.truncate(base); + } + Ok(AggregateRead { + kind, + of_own_type: document_type == context.document_type_name, + document_type: document_type.to_string(), + filter: bindings, + }) +} + /// The exactly two operands listed at `at`, `depth` levels into their rule. fn operand_pair( operands: &Value, at: &mut String, depth: usize, + context: &ParseContext, ) -> Result<(ConstraintExpression, ConstraintExpression), String> { let Some([left, right]) = operands.as_array().map(Vec::as_slice) else { return Err(format!("at {at} must list exactly two operands")); }; let base = at.len(); at.push_str("[0]"); - let left = parse_expression(left, at, depth)?; + let left = parse_expression(left, at, depth, context)?; at.truncate(base); at.push_str("[1]"); - let right = parse_expression(right, at, depth)?; + let right = parse_expression(right, at, depth, context)?; at.truncate(base); Ok((left, right)) } @@ -2544,6 +2885,7 @@ fn operand_list( operands: &Value, at: &mut String, depth: usize, + context: &ParseContext, ) -> Result, String> { let Some(values) = operands.as_array().filter(|values| values.len() >= 2) else { return Err(format!("at {at} must list two or more operands")); @@ -2553,7 +2895,7 @@ fn operand_list( for (index, value) in values.iter().enumerate() { // Writing to a `String` cannot fail let _ = write!(at, "[{index}]"); - expressions.push(parse_expression(value, at, depth)?); + expressions.push(parse_expression(value, at, depth, context)?); at.truncate(base); } Ok(expressions) diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index 567c1ae43e2..9e4cac9dca0 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -2203,7 +2203,7 @@ fn should_refuse_a_malformed_size_operand() { ( platform_value!({ "size": "title" }), "names \"size\", which is not one of add, subtract, multiply, divide, modulo, \ - power, min, max, abs, ifAbsent, length, byteLength or count", + power, min, max, abs, ifAbsent, length, byteLength, count, countOf or sumOf", ), ] { expect_refusal( @@ -2438,6 +2438,7 @@ fn should_read_the_system_values_the_rule_is_judged_with() { created_at_core_block_height: Some(7), updated_at_core_block_height: Some(8), transferred_at_core_block_height: Some(9), + aggregates: BTreeMap::new(), }; for (index, property) in SystemProperty::ALL.into_iter().enumerate() { let expected = i128::try_from(index + 1).expect("small"); @@ -3387,3 +3388,218 @@ fn should_negate_an_in_with_not_in() { "at notIn[1]", ); } + +/// `countOf` and `sumOf` parse to an [`AggregateRead`]: the type they total, +/// what a `sumOf` totals, and their filter, each key bound to a property of the +/// document (read as its kind compares), `$ownerId`, an integer or a constant. +/// The document's own type is marked, and a filter costs a node per key. +#[test] +fn should_parse_count_of_and_sum_of_with_their_filters() { + let per_owner = parse_rule_value(platform_value!({ + "lessThanOrEqual": [{ "countOf": ["order", { "$ownerId": "$ownerId" }] }, 10] + })); + let owner_read = AggregateRead { + kind: AggregateKind::Count, + document_type: "order".to_string(), + filter: BTreeMap::from([(OWNER_ID.to_string(), AggregateBinding::Owner)]), + of_own_type: true, + }; + assert_eq!(per_owner.aggregate_reads(), [&owner_read]); + assert_eq!(per_owner.node_count(), 1 + 2 + 1); + assert!(per_owner.property_reads().is_empty()); + assert!(per_owner.reads_owner()); + assert!(per_owner.reads_change(SystemChange::Transfer)); + assert!(!per_owner.reads_change(SystemChange::PriceUpdate)); + + let pledged = parse_rule_value(platform_value!({ + "lessThanOrEqual": [ + { + "sumOf": [ + "pledge", + "amount", + { "campaignId": "sellerId", "status": { "const": "open" }, "tier": 2 } + ] + }, + "deposit" + ] + })); + assert_eq!( + pledged.aggregate_reads(), + [&AggregateRead { + kind: AggregateKind::Sum { + property: "amount".to_string() + }, + document_type: "pledge".to_string(), + filter: BTreeMap::from([ + ( + "campaignId".to_string(), + AggregateBinding::Property { + path: "sellerId".to_string(), + kind: Some(EqualityKind::Identifier), + } + ), + ( + "status".to_string(), + AggregateBinding::Constant("open".to_string()) + ), + ("tier".to_string(), AggregateBinding::Integer(2)), + ]), + of_own_type: false, + }] + ); + assert_eq!(pledged.node_count(), 1 + 4 + 1); + assert_eq!( + pledged.property_reads(), + [ + ("sellerId", PropertyRead::Identifier), + ("deposit", PropertyRead::Value) + ] + ); + assert!(!pledged.reads_owner()); + + // A total over a whole type reads no property, but is no constant either + let listed = parse_rule_value(platform_value!({ + "greaterThan": [{ "countOf": ["listing"] }, 0] + })); + assert_eq!(listed.node_count(), 3); + assert_eq!(listed.aggregate_reads()[0].filter, BTreeMap::new()); + assert!(!listed.reads_owner()); + + // The owner matters when a binding reads it, or when the type is the + // writer's own and the document counts by its owner + for (rule, reads_owner) in [ + ( + platform_value!({ "countOf": ["listing", { "sellerId": "$ownerId" }] }), + true, + ), + ( + platform_value!({ "countOf": ["listing", { "$ownerId": "sellerId" }] }), + false, + ), + ( + platform_value!({ "countOf": ["order", { "$ownerId": "sellerId" }] }), + true, + ), + ( + platform_value!({ "countOf": ["order", { "status": "status" }] }), + false, + ), + ] { + let parsed = parse_rule_value(platform_value!({ "lessThan": [rule.clone(), 5] })); + assert_eq!(parsed.reads_owner(), reads_owner, "{rule:?}"); + } +} + +#[test] +fn should_refuse_a_malformed_aggregate() { + for (operand, needle) in [ + ( + platform_value!({ "countOf": "listing" }), + "at lessThan[0].countOf must list the document type to count", + ), + ( + platform_value!({ "countOf": [] }), + "must list the document type to count", + ), + ( + platform_value!({ "countOf": ["listing", { "a": 1 }, 2] }), + "must list the document type to count", + ), + ( + platform_value!({ "sumOf": ["pledge"] }), + "at lessThan[0].sumOf must list the document type, the integer property of it to \ + total", + ), + ( + platform_value!({ "countOf": [7] }), + "at lessThan[0].countOf must name a document type first", + ), + ( + platform_value!({ "countOf": [""] }), + "must name a document type first", + ), + ( + platform_value!({ "sumOf": ["pledge", 3] }), + "at lessThan[0].sumOf must name the property to total second", + ), + ( + platform_value!({ "countOf": ["listing", {}] }), + "at lessThan[0].countOf[1] must match its documents by one or more keys", + ), + ( + platform_value!({ "sumOf": ["pledge", "amount", [1]] }), + "at lessThan[0].sumOf[2] must match its documents by one or more keys", + ), + ( + platform_value!({ "countOf": ["listing", { "$createdAt": 1 }] }), + "matches by $createdAt, but the one system value a key names is $ownerId", + ), + ( + platform_value!({ "countOf": ["listing", { "a": "$createdAt" }] }), + "at lessThan[0].countOf[1].a takes $createdAt, but the one system value a key takes \ + is $ownerId", + ), + ( + platform_value!({ "countOf": ["listing", { "a": true }] }), + "at lessThan[0].countOf[1].a must be a property path of the document, $ownerId, an \ + integer or a { \"const\": ... }", + ), + ( + platform_value!({ "countOf": ["listing", { "a": { "const": 3 } }] }), + "must be a property path of the document", + ), + ( + platform_value!({ "countOf": ["listing", { "a": 1.5 }] }), + "at lessThan[0].countOf[1].a holds 1.5, which is not an integer", + ), + ] { + expect_refusal( + platform_value!({ "rule": { "lessThan": [operand, 10] } }), + needle, + ); + } +} + +/// An aggregate takes its value in the system values: consensus gives each one +/// a rule reads, and a rule reading one it is not given is not judged, as a +/// client, which reads no state, gives none. +#[test] +fn should_read_an_aggregate_from_the_system_values_and_skip_a_rule_not_given_one() { + let rule = parse_rule_value(platform_value!({ + "anyOf": [ + { "greaterThan": ["price", 1000] }, + { "lessThanOrEqual": [{ "countOf": ["order", { "$ownerId": "$ownerId" }] }, 10] } + ] + })); + let read = rule.aggregate_reads()[0].clone(); + let cheap = data(&[("price", Value::U64(5))]); + let with_total = |total: i128| DocumentSystemValues { + aggregates: BTreeMap::from([(read.clone(), total)]), + ..DocumentSystemValues::default() + }; + + assert_eq!( + rule.violation(&cheap, &DocumentSystemValues::default()), + None + ); + assert_eq!(rule.violation(&cheap, &with_total(10)), None); + assert_eq!( + rule.violation(&cheap, &with_total(11)), + Some(PropertyConstraintViolation::NotMet) + ); + // The first condition holds, so the total is never compared + let dear = data(&[("price", Value::U64(5000))]); + assert_eq!(rule.violation(&dear, &with_total(11)), None); + // A total given for another read leaves this rule unjudged + let other = DocumentSystemValues { + aggregates: BTreeMap::from([( + AggregateRead { + document_type: "listing".to_string(), + ..read.clone() + }, + 99, + )]), + ..DocumentSystemValues::default() + }; + assert_eq!(rule.violation(&cheap, &other), None); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index ef0529ca9ce..e18aee31c3d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -169,7 +169,8 @@ pub struct DocumentTypeV2 { /// The rules every created or replaced document must meet, by name, in the /// order they are checked (`propertyConstraints` keyword, protocol version /// 14): each a condition on the document's properties, a comparison of two - /// integer expressions, of a string or an identifier property with + /// integer expressions (which may read a `countOf` or `sumOf` total of a + /// type of the contract), of a string or an identifier property with /// constants or with another property of its kind, an `in` or `notIn` list /// of values, a `startsWith` or `endsWith`, a `contains`, a `present` or /// `absent` test, or an `anyOf`, `allOf`, `not`, `ifThen` or `ifThenElse` of diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs index 318fa760077..878cfddcc43 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs @@ -138,13 +138,17 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans )); } } - // Validate user defined properties - + // Validate user defined properties. The rules read the writer, the block the + // create is recorded in, and the `countOf` and `sumOf` totals the action read from + // state as they will be once the document is stored. let result = data_contract .validate_document_properties( document_type_name, self.data().into(), - &DocumentSystemValues::created_in_block(owner_id, &self.block_info()), + &DocumentSystemValues { + aggregates: self.property_constraint_aggregates().clone(), + ..DocumentSystemValues::created_in_block(owner_id, &self.block_info()) + }, platform_version, ) .map_err(Error::Protocol)?; @@ -280,6 +284,7 @@ mod tests { prefunded_voting_balance, current_store_contest_info: None, should_store_contest_info: None, + property_constraint_aggregates: Default::default(), }) } @@ -555,6 +560,7 @@ mod tests { prefunded_voting_balance, current_store_contest_info: None, should_store_contest_info: None, + property_constraint_aggregates: Default::default(), }) } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs index e154128f389..627c41a169f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs @@ -82,11 +82,16 @@ impl DocumentPurchaseTransitionActionStructureValidationV0 for DocumentPurchaseT // returns an empty result. From 14, the document as it changes hands (its new owner, // and the transfer's time and heights) is judged against the rules of // `propertyConstraints` that read them: the stored properties met every rule when - // they were written, and these are all this action changes that a rule reads. + // they were written, and these are all this action changes that a rule reads. A + // `countOf` or `sumOf` that depends on the owner reads the total the action read + // from state, as it will be once the document changes hands. document_type .validate_property_constraints_for_system_change( self.document().properties(), - &DocumentSystemValues::of_document(self.document()), + &DocumentSystemValues { + aggregates: self.property_constraint_aggregates().clone(), + ..DocumentSystemValues::of_document(self.document()) + }, SystemChange::Transfer, platform_version, ) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs index 3314713fc22..f4f0ba27fc7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs @@ -49,7 +49,8 @@ impl DocumentReplaceTransitionActionStructureValidationV0 for DocumentReplaceTra // Validate user defined properties. The rules read the writer, the times and // heights the replace keeps (creation, last transfer) and the ones it sets (the - // update), as the stored document will hold them. + // update), as the stored document will hold them, and the `countOf` and `sumOf` + // totals the action read from state as they will be once it is stored. let system = DocumentSystemValues { owner_id: Some(owner_id), created_at: self.created_at(), @@ -61,6 +62,7 @@ impl DocumentReplaceTransitionActionStructureValidationV0 for DocumentReplaceTra created_at_core_block_height: self.created_at_core_block_height(), updated_at_core_block_height: self.updated_at_core_block_height(), transferred_at_core_block_height: self.transferred_at_core_block_height(), + aggregates: self.property_constraint_aggregates().clone(), }; let result = data_contract .validate_document_properties( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs index 088e1f84c5c..1388d0b272f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs @@ -68,11 +68,16 @@ impl DocumentTransferTransitionActionStructureValidationV0 for DocumentTransferT // returns an empty result. From 14, the document as it changes hands (its new owner, // and the transfer's time and heights) is judged against the rules of // `propertyConstraints` that read them: the stored properties met every rule when - // they were written, and these are all this action changes that a rule reads. + // they were written, and these are all this action changes that a rule reads. A + // `countOf` or `sumOf` that depends on the owner reads the total the action read + // from state, as it will be once the document changes hands. document_type .validate_property_constraints_for_system_change( self.document().properties(), - &DocumentSystemValues::of_document(self.document()), + &DocumentSystemValues { + aggregates: self.property_constraint_aggregates().clone(), + ..DocumentSystemValues::of_document(self.document()) + }, SystemChange::Transfer, platform_version, ) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/advanced_structure_v0/mod.rs index 45080459e5f..0c1cad5e300 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/advanced_structure_v0/mod.rs @@ -52,10 +52,15 @@ impl DocumentUpdatePriceTransitionActionStructureValidationV0 // time and heights, and is judged against the rules of `propertyConstraints` // reading them: the stored properties met every rule when they were written, // and those times and heights are all this action changes that a rule reads. + // Such a rule reading a `countOf` or `sumOf` too reads the total the action + // read from state. document_type .validate_property_constraints_for_system_change( self.document().properties(), - &DocumentSystemValues::of_document(self.document()), + &DocumentSystemValues { + aggregates: self.property_constraint_aggregates().clone(), + ..DocumentSystemValues::of_document(self.document()) + }, SystemChange::PriceUpdate, platform_version, ) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/distinct_from.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/distinct_from.rs index 47076f2a490..fa04d24aee1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/distinct_from.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/distinct_from.rs @@ -530,6 +530,7 @@ mod distinct_from_tests { removed_identifier_fields: BTreeMap::new(), stored_changed_values: BTreeMap::new(), creator_id: None, + property_constraint_aggregates: Default::default(), }); let before = action @@ -609,12 +610,14 @@ mod distinct_from_tests { let transfer = DocumentTransferTransitionAction::V0(DocumentTransferTransitionActionV0 { base: base(), document: transferred.clone(), + property_constraint_aggregates: Default::default(), }); let purchase = DocumentPurchaseTransitionAction::V0(DocumentPurchaseTransitionActionV0 { base: base(), document: transferred.clone(), original_owner_id: fixture.identity.id(), price: 1, + property_constraint_aggregates: Default::default(), }); for (name, before, at) in [ diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/encrypted_for.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/encrypted_for.rs index a4cd5c079e2..1a2eea8ab6a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/encrypted_for.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/encrypted_for.rs @@ -459,6 +459,7 @@ mod encrypted_for_tests { removed_identifier_fields: BTreeMap::new(), stored_changed_values: BTreeMap::new(), creator_id: None, + property_constraint_aggregates: Default::default(), }); let before = action diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes.rs index ef11df52322..6e8ecf2e7d7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes.rs @@ -395,6 +395,7 @@ mod max_bytes_tests { removed_identifier_fields: BTreeMap::new(), stored_changed_values: BTreeMap::new(), creator_id: None, + property_constraint_aggregates: Default::default(), }) }; let platform_version_13 = diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index d3f7fb79244..8fc7624fa8e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -539,6 +539,12 @@ mod property_constraints_tests { /// The fixture with its `offer` type declared by `schema`. fn with_schema(schema: Value) -> Self { + Self::with_schemas(schema, []) + } + + /// The fixture with its `offer` type declared by `schema`, beside the + /// `others`, each by its name and schema. + fn with_schemas(schema: Value, others: [(&str, Value); N]) -> Self { let platform_version = PlatformVersion::latest(); let mut platform = TestPlatformBuilder::new() .build_with_mock_rpc() @@ -555,6 +561,11 @@ mod property_constraints_tests { contract .set_document_schema("offer", schema, true, &mut Vec::new(), platform_version) .expect("expected to add the offer document type"); + for (name, schema) in others { + contract + .set_document_schema(name, schema, true, &mut Vec::new(), platform_version) + .expect("expected to add the document type"); + } platform .drive .apply_contract( @@ -613,12 +624,26 @@ mod property_constraints_tests { async fn create( &mut self, fill: impl FnOnce(&mut Document), + ) -> StateTransitionExecutionResult { + self.create_of("offer", |document| { + set_valid_offer(document); + fill(document); + }) + .await + } + + /// Creates a document of the type `document_type` changed by `fill`. On + /// success an offer becomes the fixture's document. + async fn create_of( + &mut self, + document_type: &str, + fill: impl FnOnce(&mut Document), ) -> StateTransitionExecutionResult { let platform_version = PlatformVersion::latest(); let offer_type = self .contract - .document_type_for_name("offer") - .expect("expected the offer document type"); + .document_type_for_name(document_type) + .expect("expected the document type"); let mut rng = StdRng::seed_from_u64(434); let entropy = Bytes32::random_with_rng(&mut rng); @@ -635,7 +660,6 @@ mod property_constraints_tests { document .set_id_for_creation(offer_type, &entropy.0, self.next_nonce, platform_version) .expect("expected to set the document id"); - set_valid_offer(&mut document); fill(&mut document); let transition = BatchTransition::new_document_creation_transition_from_document( @@ -655,10 +679,12 @@ mod property_constraints_tests { self.next_nonce += 1; let result = self.process(&transition); - if matches!( - result, - StateTransitionExecutionResult::SuccessfulExecution { .. } - ) { + if document_type == "offer" + && matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ) + { self.document = Some(document); } result @@ -1709,6 +1735,7 @@ mod property_constraints_tests { removed_identifier_fields: BTreeMap::new(), stored_changed_values: BTreeMap::new(), creator_id: None, + property_constraint_aggregates: Default::default(), }); let before = action @@ -2110,4 +2137,340 @@ mod property_constraints_tests { ); assert_eq!(fixture.stored_offers().len(), 2); } + + /// A mutable, transferable and purchasable `offer` type with the integers + /// [`set_valid_offer`] fills (a price of at most 10^9, which a sum tree + /// takes) and a required `category`, whose trees keep the count of each + /// owner's offers (`byOwner`) and the total price of each category + /// (`byCategory`), declaring `rules`. + fn counted_offer_schema(rules: Value) -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "transferable": 1, + "tradeMode": 1, + "properties": { + "price": { "type": "integer", "minimum": 0, "maximum": 1000000000, "position": 0 }, + "fee": { "type": "integer", "minimum": 0, "position": 1 }, + "quantity": { "type": "integer", "minimum": 0, "position": 2 }, + "deposit": { "type": "integer", "minimum": 0, "position": 3 }, + "category": { "type": "integer", "minimum": 0, "maximum": 100, "position": 4 } + }, + "required": ["price", "fee", "quantity", "deposit", "category"], + "indices": [ + { + "name": "byOwner", + "properties": [{ "$ownerId": "asc" }], + "countable": "countable" + }, + { + "name": "byCategory", + "properties": [{ "category": "asc" }], + "summable": "price" + } + ], + "propertyConstraints": rules, + "additionalProperties": false + }) + } + + /// Sets an offer's `category` and `price`. + fn priced_in(category: u64, price: u64) -> impl FnOnce(&mut Document) { + move |document: &mut Document| { + document.set("category", Value::U64(category)); + document.set("price", Value::U64(price)); + } + } + + /// `countOf` over the writer's own type by `$ownerId`: an identity owns at + /// most two offers. The total is the one the tree keeps once the write is + /// done, so a replace of one of the two, which leaves the count as it was, + /// is judged by 2. + #[tokio::test] + async fn should_cap_the_offers_of_each_owner_on_create_and_replace() { + let mut fixture = OfferFixture::with_schema(counted_offer_schema(platform_value!({ + "atMostTwoPerOwner": { + "lessThanOrEqual": [{ "countOf": ["offer", { "$ownerId": "$ownerId" }] }, 2] + } + }))); + for _ in 0..2 { + assert_matches!( + fixture.create(priced_in(1, 100)).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + let result = fixture.create(priced_in(1, 100)).await; + expect_violated( + result, + "atMostTwoPerOwner", + PropertyConstraintViolation::NotMet, + ); + + assert_matches!( + fixture.replace(priced_in(2, 150)).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 2); + } + + /// `sumOf` over the offers of one category: their prices total at most 250. + /// A create adds its price; a replace takes out the price it stored and adds + /// the new one, and one moving the offer to another category takes its + /// price out of the first. + #[tokio::test] + async fn should_total_the_prices_of_a_category_on_create_and_replace() { + let mut fixture = OfferFixture::with_schema(counted_offer_schema(platform_value!({ + "categoryBudget": { + "lessThanOrEqual": [ + { "sumOf": ["offer", "price", { "category": "category" }] }, + 250 + ] + } + }))); + for price in [100, 100] { + assert_matches!( + fixture.create(priced_in(1, price)).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + // 200 + 60 is above 250 + let result = fixture.create(priced_in(1, 60)).await; + expect_violated( + result, + "categoryBudget", + PropertyConstraintViolation::NotMet, + ); + // 200 + 50 is 250 + assert_matches!( + fixture.create(priced_in(1, 50)).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + // 250 - 50 + 60 + let result = fixture.replace(priced_in(1, 60)).await; + expect_violated( + result, + "categoryBudget", + PropertyConstraintViolation::NotMet, + ); + // Moved to category 2, the offer leaves 200 in category 1 + assert_matches!( + fixture.replace(priced_in(2, 200)).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_matches!( + fixture.create(priced_in(1, 50)).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + // Category 2 holds 200 + let result = fixture.create(priced_in(2, 51)).await; + expect_violated( + result, + "categoryBudget", + PropertyConstraintViolation::NotMet, + ); + assert_eq!(fixture.stored_offers().len(), 4); + } + + /// A transfer or a purchase counts the offer toward its new owner, so one + /// reaching an owner at the cap is refused. + #[tokio::test] + async fn should_count_a_transferred_or_bought_offer_toward_its_new_owner() { + let mut fixture = OfferFixture::with_schema(counted_offer_schema(platform_value!({ + "atMostOnePerOwner": { + "lessThanOrEqual": [{ "countOf": ["offer", { "$ownerId": "$ownerId" }] }, 1] + } + }))); + let recipient = fixture.other_identity(971); + assert_matches!( + fixture.create(priced_in(1, 100)).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_matches!( + fixture.transfer(recipient.0.id()).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + // The writer owns none again, so it may list another + assert_matches!( + fixture.create(priced_in(1, 100)).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let result = fixture.transfer(recipient.0.id()).await; + expect_violated( + result, + "atMostOnePerOwner", + PropertyConstraintViolation::NotMet, + ); + + fixture.set_price(1000).await; + let result = fixture.purchase_by(&recipient, 1000).await; + expect_violated( + result, + "atMostOnePerOwner", + PropertyConstraintViolation::NotMet, + ); + let buyer = fixture.other_identity(972); + assert_matches!( + fixture.purchase_by(&buyer, 1000).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let mut owners = fixture + .stored_offers() + .iter() + .map(|offer| offer.owner_id()) + .collect::>(); + owners.sort(); + let mut expected = vec![recipient.0.id(), buyer.0.id()]; + expected.sort(); + assert_eq!(owners, expected); + } + + /// `countOf` over another type of the contract: an identity lists an offer + /// only once it has a profile. + #[tokio::test] + async fn should_count_the_documents_of_another_type() { + let mut fixture = OfferFixture::with_schemas( + counted_offer_schema(platform_value!({ + "hasProfile": { + "greaterThanOrEqual": [ + { "countOf": ["profile", { "$ownerId": "$ownerId" }] }, + 1 + ] + } + })), + [( + "profile", + platform_value!({ + "type": "object", + "properties": { + "handle": { "type": "string", "maxLength": 20, "position": 0 } + }, + "required": ["handle"], + "indices": [{ + "name": "byOwner", + "properties": [{ "$ownerId": "asc" }], + "countable": "countable" + }], + "additionalProperties": false + }), + )], + ); + let result = fixture.create(priced_in(1, 100)).await; + expect_violated(result, "hasProfile", PropertyConstraintViolation::NotMet); + + assert_matches!( + fixture + .create_of("profile", |document| { + document.set("handle", Value::Text("sam".to_string())) + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_matches!( + fixture.create(priced_in(1, 100)).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 1); + } + + /// The totals a rule reads are state reads, billed with the write: the same + /// create costs more when a rule reads a total than when it reads a + /// property. + #[tokio::test] + async fn should_bill_the_totals_a_rule_reads() { + let processing_fee = |result: StateTransitionExecutionResult| { + let StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. } = result + else { + panic!("expected the create to succeed, got {result:?}"); + }; + fee_result.processing_fee + }; + let mut plain = OfferFixture::with_schema(counted_offer_schema(platform_value!({ + "rule": { "lessThanOrEqual": ["price", 1000] } + }))); + let mut counted = OfferFixture::with_schema(counted_offer_schema(platform_value!({ + "rule": { + "lessThanOrEqual": [{ "countOf": ["offer", { "$ownerId": "$ownerId" }] }, 1000] + } + }))); + let plain_fee = processing_fee(plain.create(priced_in(1, 100)).await); + let counted_fee = processing_fee(counted.create(priced_in(1, 100)).await); + assert!( + counted_fee > plain_fee, + "reading the count must be billed: {counted_fee} <= {plain_fee}" + ); + } + + /// A price update is judged against the rules reading its update time; one + /// that reads a total too reads it, so the time is judged, not skipped. + #[tokio::test] + async fn should_judge_a_price_update_by_a_rule_reading_a_total() { + let mut schema = counted_offer_schema(platform_value!({ + "updatedBeforeEndAndFewOffers": { + "allOf": [ + { "lessThanOrEqual": ["$updatedAt", "endsAt"] }, + { + "lessThanOrEqual": [ + { "countOf": ["offer", { "$ownerId": "$ownerId" }] }, + 5 + ] + } + ] + } + })); + let Value::Map(properties) = schema + .get_mut("properties") + .expect("properties") + .expect("properties are set") + else { + panic!("properties is an object"); + }; + properties.push(( + Value::Text("endsAt".to_string()), + platform_value!({ "type": "integer", "minimum": 0, "position": 5 }), + )); + let Value::Array(required) = schema + .get_mut("required") + .expect("required") + .expect("required is set") + else { + panic!("required is an array"); + }; + required.push(Value::Text("endsAt".to_string())); + required.push(Value::Text("$updatedAt".to_string())); + let mut fixture = OfferFixture::with_schema(schema); + fixture.block_info = at_block(NOW, 20); + assert_matches!( + fixture + .create(|document| { + priced_in(1, 100)(document); + document.set("endsAt", Value::U64(NOW + DAY_MS)); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + fixture.block_info = at_block(NOW + 2 * DAY_MS, 30); + let result = fixture.try_set_price(1000).await; + expect_violated( + result, + "updatedBeforeEndAndFewOffers", + PropertyConstraintViolation::NotMet, + ); + fixture.block_info = at_block(NOW + DAY_MS / 2, 30); + fixture.set_price(1000).await; + } + + /// The totals a rule reads leave out the other writes of its batch, which is + /// sound only while a document batch carries one transition: raising the + /// limit needs the batch's own writes added to them. + #[test] + fn should_keep_one_transition_per_document_batch_while_rules_read_totals() { + assert_eq!( + PlatformVersion::latest() + .system_limits + .max_transitions_in_documents_batch, + 1 + ); + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index cf5458e9034..ea6aed45f1d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -23,8 +23,17 @@ // fields rather than rename this file. mod contract_moderation_gate; +mod property_constraint_aggregates; use contract_moderation_gate::{BatchTransitionContractModerationGate, ContractModerationRefusal}; +use dpp::data_contract::document_type::property_constraints::SystemChange; +use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentCreateTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_purchase_transition_action::DocumentPurchaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::DocumentReplaceTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_transfer_transition_action::DocumentTransferTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_update_price_transition_action::DocumentUpdatePriceTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; +use property_constraint_aggregates::{read_property_constraint_aggregates, DocumentVersion}; use std::borrow::Cow; use std::collections::btree_map::Entry; use std::collections::{BTreeMap, BTreeSet}; @@ -805,7 +814,7 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { match transition { DocumentTransition::Create(document_create_transition) => { - let (document_create_action, fee_result) = DocumentCreateTransitionAction::try_from_document_borrowed_create_transition_with_contract_lookup( + let (mut document_create_action, fee_result) = DocumentCreateTransitionAction::try_from_document_borrowed_create_transition_with_contract_lookup( drive, owner_id, transaction, document_create_transition, block_info, user_fee_increase, |_identifier| { Ok(data_contract_fetch_info.clone()) @@ -813,6 +822,29 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + // The `countOf` and `sumOf` totals the rules read, the new document counted + if let Some(BatchedTransitionAction::DocumentAction( + DocumentTransitionAction::CreateAction(action), + )) = document_create_action.data.as_mut() + { + let aggregates = read_property_constraint_aggregates( + drive, + &data_contract_fetch_info.contract, + document_create_transition.base().document_type_name(), + DocumentVersion { + properties: action.data(), + owner_id, + }, + None, + None, + block_info, + execution_context, + transaction, + platform_version, + )?; + action.set_property_constraint_aggregates(aggregates); + } Ok(document_create_action) } DocumentTransition::Replace(document_replace_transition) => { @@ -872,7 +904,7 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { } } - let (document_replace_action, fee_result) = + let (mut document_replace_action, fee_result) = DocumentReplaceTransitionAction::try_from_borrowed_document_replace_transition( document_replace_transition, owner_id, @@ -885,6 +917,33 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + // The `countOf` and `sumOf` totals the rules read, the document counted as it will be + // stored and no longer as it was + if let Some(BatchedTransitionAction::DocumentAction( + DocumentTransitionAction::ReplaceAction(action), + )) = document_replace_action.data.as_mut() + { + let aggregates = read_property_constraint_aggregates( + drive, + &data_contract_fetch_info.contract, + document_replace_transition.base().document_type_name(), + DocumentVersion { + properties: action.data(), + owner_id, + }, + Some(DocumentVersion { + properties: original_document.properties(), + owner_id: original_document.owner_id(), + }), + None, + block_info, + execution_context, + transaction, + platform_version, + )?; + action.set_property_constraint_aggregates(aggregates); + } + Ok(document_replace_action) } DocumentTransition::Delete(document_delete_transition) => { @@ -976,7 +1035,7 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); } - let (document_transfer_action, fee_result) = + let (mut document_transfer_action, fee_result) = DocumentTransferTransitionAction::try_from_borrowed_document_transfer_transition( document_transfer_transition, owner_id, @@ -989,6 +1048,33 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + // The `countOf` and `sumOf` totals of the rules the new owner can break, the document + // counted as its new owner's and no longer as the old one's + if let Some(BatchedTransitionAction::DocumentAction( + DocumentTransitionAction::TransferAction(action), + )) = document_transfer_action.data.as_mut() + { + let aggregates = read_property_constraint_aggregates( + drive, + &data_contract_fetch_info.contract, + document_transfer_transition.base().document_type_name(), + DocumentVersion { + properties: action.document().properties(), + owner_id: action.document().owner_id(), + }, + Some(DocumentVersion { + properties: original_document.properties(), + owner_id: original_document.owner_id(), + }), + Some(SystemChange::Transfer), + block_info, + execution_context, + transaction, + platform_version, + )?; + action.set_property_constraint_aggregates(aggregates); + } + Ok(document_transfer_action) } DocumentTransition::UpdatePrice(document_update_price_transition) => { @@ -1041,7 +1127,7 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { } } - let (document_update_price_action, fee_result) = + let (mut document_update_price_action, fee_result) = DocumentUpdatePriceTransitionAction::try_from_borrowed_document_update_price_transition( document_update_price_transition, owner_id, @@ -1054,6 +1140,33 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + // The `countOf` and `sumOf` totals of the rules the update's time can break, + // which the document's own count leaves as they are + if let Some(BatchedTransitionAction::DocumentAction( + DocumentTransitionAction::UpdatePriceAction(action), + )) = document_update_price_action.data.as_mut() + { + let aggregates = read_property_constraint_aggregates( + drive, + &data_contract_fetch_info.contract, + document_update_price_transition.base().document_type_name(), + DocumentVersion { + properties: action.document().properties(), + owner_id: action.document().owner_id(), + }, + Some(DocumentVersion { + properties: original_document.properties(), + owner_id: original_document.owner_id(), + }), + Some(SystemChange::PriceUpdate), + block_info, + execution_context, + transaction, + platform_version, + )?; + action.set_property_constraint_aggregates(aggregates); + } + Ok(document_update_price_action) } DocumentTransition::Purchase(document_purchase_transition) => { @@ -1143,7 +1256,7 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); } - let (document_purchase_action, fee_result) = + let (mut document_purchase_action, fee_result) = DocumentPurchaseTransitionAction::try_from_borrowed_document_purchase_transition( document_purchase_transition, owner_id, @@ -1157,6 +1270,33 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + // The `countOf` and `sumOf` totals of the rules the buyer can break, the document + // counted as the buyer's and no longer as the seller's + if let Some(BatchedTransitionAction::DocumentAction( + DocumentTransitionAction::PurchaseAction(action), + )) = document_purchase_action.data.as_mut() + { + let aggregates = read_property_constraint_aggregates( + drive, + &data_contract_fetch_info.contract, + document_purchase_transition.base().document_type_name(), + DocumentVersion { + properties: action.document().properties(), + owner_id: action.document().owner_id(), + }, + Some(DocumentVersion { + properties: original_document.properties(), + owner_id: original_document.owner_id(), + }), + Some(SystemChange::Transfer), + block_info, + execution_context, + transaction, + platform_version, + )?; + action.set_property_constraint_aggregates(aggregates); + } + Ok(document_purchase_action) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs new file mode 100644 index 00000000000..106e35c6d6e --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs @@ -0,0 +1,134 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; +use dpp::data_contract::document_type::property_constraints::{AggregateRead, SystemChange}; +use dpp::data_contract::DataContract; +use dpp::platform_value::{Identifier, Value}; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use std::collections::{BTreeMap, BTreeSet}; + +/// A document version a write stores or replaces: its properties and its owner. +pub(super) struct DocumentVersion<'a> { + pub properties: &'a BTreeMap, + pub owner_id: Identifier, +} + +/// Reads from state the `countOf` and `sumOf` totals the `propertyConstraints` rules of +/// the document type `document_type_name` read, for a write storing `written` in place of +/// `stored` (`None` for a create), each as it will be once the write is done: the total the +/// count or sum tree keeps now, less what `stored` adds to it, plus what `written` adds. For +/// a transfer, a purchase or a price update (`change`), only the rules the change can break +/// are judged, so only their totals are read. The reads are billed to `execution_context`. +/// +/// Consensus reads them when it builds the action, before judging the rules +/// ([`DocumentSystemValues::aggregates`]), so that the rules stay a structure check. Added +/// in place to the shipped transformer at protocol version 14 and inert before it: a rule +/// reads a total only where `parse_property_constraints` is `Some(_)`, so earlier versions +/// read nothing and bill nothing here. +/// +/// The document batch carries one transition (`SystemLimits::max_transitions_in_documents_batch`), +/// and every state transition of a block is applied before the next is validated, so no +/// write of the same block is missing from the totals read here. Raising that limit needs +/// the batch's own earlier writes added to them. +/// +/// [`DocumentSystemValues::aggregates`]: dpp::data_contract::document_type::property_constraints::DocumentSystemValues::aggregates +#[allow(clippy::too_many_arguments)] +pub(super) fn read_property_constraint_aggregates( + drive: &Drive, + contract: &DataContract, + document_type_name: &str, + written: DocumentVersion, + stored: Option, + change: Option, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result, Error> { + let Some(document_type) = contract.document_type_optional_for_name(document_type_name) else { + // The action refuses a document type the contract lacks + return Ok(BTreeMap::new()); + }; + let reads = document_type + .property_constraints() + .values() + .filter(|rule| change.is_none_or(|change| rule.reads_change(change))) + .flat_map(|rule| rule.aggregate_reads()) + .collect::>(); + if reads.is_empty() { + return Ok(BTreeMap::new()); + } + + let written_data = Value::from(written.properties.clone()); + let stored_data = stored + .as_ref() + .map(|stored| (Value::from(stored.properties.clone()), stored.owner_id)); + let mut drive_operations = vec![]; + let mut aggregates = BTreeMap::new(); + for read in reads { + let Some(counted) = contract.document_type_optional_for_name(&read.document_type) else { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "a propertyConstraints rule totals a document type its contract lacks, which \ + registration refuses", + ))); + }; + let total = + match read.filter_values(counted, &written_data, written.owner_id, platform_version)? { + // No document can match a value its key cannot hold + None => 0, + Some(filter_values) => { + let kept = drive.fetch_property_constraint_aggregate( + contract.id().to_buffer(), + counted, + read, + &filter_values, + transaction, + &mut drive_operations, + platform_version, + )?; + let removed = match &stored_data { + Some((data, owner_id)) => read.contribution( + counted, + &filter_values, + data, + *owner_id, + platform_version, + )?, + None => 0, + }; + let added = read.contribution( + counted, + &filter_values, + &written_data, + written.owner_id, + platform_version, + )?; + kept.checked_sub(removed) + .and_then(|total| total.checked_add(added)) + .ok_or(Error::Execution(ExecutionError::Overflow( + "a propertyConstraints total overflowed an i128", + )))? + } + }; + aggregates.insert(read.clone(), total); + } + + let fee = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + Ok(aggregates) +} diff --git a/packages/rs-drive/src/drive/document/fetch_property_constraint_aggregate/mod.rs b/packages/rs-drive/src/drive/document/fetch_property_constraint_aggregate/mod.rs new file mode 100644 index 00000000000..28908714257 --- /dev/null +++ b/packages/rs-drive/src/drive/document/fetch_property_constraint_aggregate/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::data_contract::document_type::property_constraints::AggregateRead; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::platform_value::Value; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// The total a `countOf` or `sumOf` of a `propertyConstraints` rule reads, as the + /// count or sum tree of `counted` keeps it before the write being judged: how many + /// documents of `counted`, a type of the contract `contract_id`, match `filter_values` + /// (from [`AggregateRead::filter_values`]), or the total of the summed property over + /// them; over every document of the type when the read has no filter. The reads are + /// added to `drive_operations`, so that consensus can bill them. + /// + /// Registration makes a tree keep every total a rule reads + /// ([`AggregateRead::whole_type_kept`], [`AggregateRead::answering_index`]); a read + /// no tree keeps is a corrupted contract. + /// + /// # Parameters + /// + /// * `contract_id`: The contract declaring both types. + /// * `counted`: The document type the read totals. + /// * `read`: The `countOf` or `sumOf`. + /// * `filter_values`: The values its filter's keys must take. + /// * `transaction`: The GroveDB transaction. + /// * `drive_operations`: The operations the reads add to. + /// * `platform_version`: The platform version. + #[allow(clippy::too_many_arguments)] + pub fn fetch_property_constraint_aggregate( + &self, + contract_id: [u8; 32], + counted: DocumentTypeRef, + read: &AggregateRead, + filter_values: &[(String, Value)], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .document + .fetch_property_constraint_aggregate + { + 0 => self.fetch_property_constraint_aggregate_v0( + contract_id, + counted, + read, + filter_values, + transaction, + drive_operations, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_property_constraint_aggregate".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/document/fetch_property_constraint_aggregate/v0/mod.rs b/packages/rs-drive/src/drive/document/fetch_property_constraint_aggregate/v0/mod.rs new file mode 100644 index 00000000000..97a81e201ba --- /dev/null +++ b/packages/rs-drive/src/drive/document/fetch_property_constraint_aggregate/v0/mod.rs @@ -0,0 +1,107 @@ +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::query::drive_document_count_query::DriveDocumentCountQuery; +use crate::query::drive_document_sum_query::DriveDocumentSumQuery; +use crate::query::{WhereClause, WhereOperator}; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::property_constraints::{AggregateKind, AggregateRead}; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::platform_value::Value; +use dpp::version::PlatformVersion; +use grovedb::query_result_type::{QueryResultElement, QueryResultType}; +use grovedb::TransactionArg; + +impl Drive { + /// Version 0 of [`Drive::fetch_property_constraint_aggregate`]. It reads the elements + /// the count and sum queries read for the same total (the primary-key tree of a whole + /// type, or the value tree of the answering index a point lookup reaches), through the + /// same path-query builders, so the total is the one those queries report and prove. A + /// branch no document reached yet is absent and adds 0. + #[allow(clippy::too_many_arguments)] + #[inline(always)] + pub(super) fn fetch_property_constraint_aggregate_v0( + &self, + contract_id: [u8; 32], + counted: DocumentTypeRef, + read: &AggregateRead, + filter_values: &[(String, Value)], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + let document_type_name = counted.name().clone(); + let path_query = if read.filter.is_empty() { + match &read.kind { + AggregateKind::Count => DriveDocumentCountQuery::primary_key_count_tree_path_query( + contract_id, + &document_type_name, + ), + AggregateKind::Sum { .. } => DriveDocumentSumQuery::primary_key_sum_path_query( + contract_id, + &document_type_name, + ), + } + } else { + let Some(index) = read.answering_index(&counted) else { + return Err(Error::Drive(DriveError::CorruptedContractIndexes(format!( + "no index of document type {document_type_name} keeps the {} a registered \ + propertyConstraints rule reads", + read.wire_name() + )))); + }; + let where_clauses = filter_values + .iter() + .map(|(field, value)| WhereClause { + field: field.clone(), + operator: WhereOperator::Equal, + value: value.clone(), + }) + .collect(); + match &read.kind { + AggregateKind::Count => DriveDocumentCountQuery { + document_type: counted, + contract_id, + document_type_name, + index, + where_clauses, + } + .point_lookup_count_path_query(platform_version)?, + AggregateKind::Sum { property } => DriveDocumentSumQuery { + document_type: counted, + contract_id, + document_type_name, + index, + where_clauses, + sum_property: property.clone(), + } + .point_lookup_sum_path_query(platform_version)?, + } + }; + let (results, _) = self.grove_get_path_query( + &path_query, + transaction, + QueryResultType::QueryElementResultType, + drive_operations, + &platform_version.drive, + )?; + let mut total = 0i128; + for result in results.elements { + let QueryResultElement::ElementResultItem(element) = result else { + continue; + }; + let value = match &read.kind { + AggregateKind::Count => i128::from(element.count_value_or_default()), + AggregateKind::Sum { .. } => i128::from(element.sum_value_or_default()), + }; + // Each element holds at most a `u64` or an `i64`, and a point lookup reaches one + total = total.checked_add(value).ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState( + "a propertyConstraints total overflowed an i128".to_string(), + )) + })?; + } + Ok(total) + } +} diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs index 064f1dde49f..75f59056201 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs @@ -144,6 +144,7 @@ mod tests { prefunded_voting_balance: None, current_store_contest_info: None, should_store_contest_info: None, + property_constraint_aggregates: Default::default(), }) } diff --git a/packages/rs-drive/src/drive/document/mod.rs b/packages/rs-drive/src/drive/document/mod.rs index 8d796d44800..658d0166d88 100644 --- a/packages/rs-drive/src/drive/document/mod.rs +++ b/packages/rs-drive/src/drive/document/mod.rs @@ -29,6 +29,8 @@ mod estimation_costs; /// pricing, and the cleanup that deletes them once expired #[cfg(feature = "server")] pub mod expiration; +#[cfg(feature = "server")] +mod fetch_property_constraint_aggregate; #[cfg(any(feature = "server", feature = "fixtures-and-mocks"))] mod get_fetch; #[cfg(feature = "server")] diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs index c56aa110ca0..f601bcce931 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs @@ -4,6 +4,7 @@ mod v0; mod v1; use derive_more::From; +use dpp::data_contract::document_type::property_constraints::AggregateRead; use dpp::block::block_info::BlockInfo; use dpp::platform_value::{Identifier, Value}; @@ -28,6 +29,9 @@ pub enum DocumentCreateTransitionAction { V0(DocumentCreateTransitionActionV0), } +/// What `property_constraint_aggregates` returns for an action holding none. +static NO_PROPERTY_CONSTRAINT_AGGREGATES: BTreeMap = BTreeMap::new(); + impl DocumentCreateTransitionActionAccessorsV0 for DocumentCreateTransitionAction { fn base(&self) -> &DocumentBaseTransitionAction { match self { @@ -114,6 +118,24 @@ impl DocumentCreateTransitionActionAccessorsV0 for DocumentCreateTransitionActio DocumentCreateTransitionAction::V0(v0) => v0.current_store_contest_info.take(), } } + + fn property_constraint_aggregates(&self) -> &BTreeMap { + match self { + DocumentCreateTransitionAction::V0(v0) => v0 + .property_constraint_aggregates + .as_deref() + .unwrap_or(&NO_PROPERTY_CONSTRAINT_AGGREGATES), + } + } + + fn set_property_constraint_aggregates(&mut self, aggregates: BTreeMap) { + match self { + DocumentCreateTransitionAction::V0(v0) => { + v0.property_constraint_aggregates = + (!aggregates.is_empty()).then(|| Box::new(aggregates)) + } + } + } } /// document from create transition diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs index aadd2dc3948..301edbae4fb 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs @@ -1,6 +1,7 @@ pub mod transformer; use dpp::block::block_info::BlockInfo; +use dpp::data_contract::document_type::property_constraints::AggregateRead; use dpp::document::{Document, DocumentV0}; use dpp::platform_value::{Identifier, Value}; use std::collections::BTreeMap; @@ -42,6 +43,11 @@ pub struct DocumentCreateTransitionActionV0 { pub current_store_contest_info: Option, /// We store contest info only in the case of a new contested document that creates a new contest pub should_store_contest_info: Option, + /// The `countOf` and `sumOf` totals the document type's `propertyConstraints` rules + /// read, each as it will be once this write is done, read from state when the action is + /// built; `None` when the rules judging the write read none, and boxed, since only + /// such a write holds any and the action is one variant of a large enum. + pub property_constraint_aggregates: Option>>, } /// document create transition action accessors v0 @@ -86,6 +92,13 @@ pub trait DocumentCreateTransitionActionAccessorsV0 { /// Take the current store contest info (if it should be stored) and replace it with None. fn take_current_store_contest_info(&mut self) -> Option; + + /// The `countOf` and `sumOf` totals the rules judging this write read, each as it will + /// be once the write is done + fn property_constraint_aggregates(&self) -> &BTreeMap; + + /// Sets the totals the rules judging this write read, once they are read from state + fn set_property_constraint_aggregates(&mut self, aggregates: BTreeMap); } /// documents from create transition v0 diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs index 7ed705fbc66..c8c0fdf8d73 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs @@ -156,6 +156,7 @@ impl DocumentCreateTransitionActionV0 { prefunded_voting_balance: prefunded_voting_balances_by_vote_poll, current_store_contest_info, should_store_contest_info, + property_constraint_aggregates: Default::default(), } .into(), )) diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/mod.rs index e91b681f104..54c683dccd3 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/mod.rs @@ -1,8 +1,10 @@ mod v0; use derive_more::From; +use dpp::data_contract::document_type::property_constraints::AggregateRead; use dpp::document::Document; use dpp::fee::Credits; +use std::collections::BTreeMap; use dpp::platform_value::Identifier; use dpp::ProtocolError; @@ -21,6 +23,9 @@ pub enum DocumentPurchaseTransitionAction { V0(DocumentPurchaseTransitionActionV0), } +/// What `property_constraint_aggregates` returns for an action holding none. +static NO_PROPERTY_CONSTRAINT_AGGREGATES: BTreeMap = BTreeMap::new(); + impl DocumentPurchaseTransitionActionAccessorsV0 for DocumentPurchaseTransitionAction { fn base(&self) -> &DocumentBaseTransitionAction { match self { @@ -57,6 +62,24 @@ impl DocumentPurchaseTransitionActionAccessorsV0 for DocumentPurchaseTransitionA DocumentPurchaseTransitionAction::V0(v0) => v0.price, } } + + fn property_constraint_aggregates(&self) -> &BTreeMap { + match self { + DocumentPurchaseTransitionAction::V0(v0) => v0 + .property_constraint_aggregates + .as_deref() + .unwrap_or(&NO_PROPERTY_CONSTRAINT_AGGREGATES), + } + } + + fn set_property_constraint_aggregates(&mut self, aggregates: BTreeMap) { + match self { + DocumentPurchaseTransitionAction::V0(v0) => { + v0.property_constraint_aggregates = + (!aggregates.is_empty()).then(|| Box::new(aggregates)) + } + } + } } /// document from purchase transition diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/v0/mod.rs index dd0fe6acb8d..a96d5a9fc33 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/v0/mod.rs @@ -1,8 +1,10 @@ pub mod transformer; +use dpp::data_contract::document_type::property_constraints::AggregateRead; use dpp::document::Document; use dpp::fee::Credits; use dpp::identifier::Identifier; +use std::collections::BTreeMap; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionAction; @@ -17,6 +19,11 @@ pub struct DocumentPurchaseTransitionActionV0 { pub original_owner_id: Identifier, /// Price pub price: Credits, + /// The `countOf` and `sumOf` totals the document type's `propertyConstraints` rules + /// read, each as it will be once this write is done, read from state when the action is + /// built; `None` when the rules judging the write read none, and boxed, since only + /// such a write holds any and the action is one variant of a large enum. + pub property_constraint_aggregates: Option>>, } /// document purchase transition action accessors v0 @@ -34,4 +41,11 @@ pub trait DocumentPurchaseTransitionActionAccessorsV0 { fn original_owner_id(&self) -> Identifier; /// Price fn price(&self) -> Credits; + + /// The `countOf` and `sumOf` totals the rules judging this write read, each as it will + /// be once the write is done + fn property_constraint_aggregates(&self) -> &BTreeMap; + + /// Sets the totals the rules judging this write read, once they are read from state + fn set_property_constraint_aggregates(&mut self, aggregates: BTreeMap); } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/v0/transformer.rs index 187ab56eca1..ebd01a37791 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/v0/transformer.rs @@ -94,6 +94,7 @@ impl DocumentPurchaseTransitionActionV0 { document: modified_document, original_owner_id, price: *price, + property_constraint_aggregates: Default::default(), } .into(), )) diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs index d3f8c122502..00195571b3c 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs @@ -1,6 +1,7 @@ mod v0; mod v1; +use dpp::data_contract::document_type::property_constraints::AggregateRead; use std::collections::{BTreeMap, BTreeSet}; use derive_more::From; @@ -26,6 +27,9 @@ pub enum DocumentReplaceTransitionAction { V0(DocumentReplaceTransitionActionV0), } +/// What `property_constraint_aggregates` returns for an action holding none. +static NO_PROPERTY_CONSTRAINT_AGGREGATES: BTreeMap = BTreeMap::new(); + impl DocumentReplaceTransitionActionAccessorsV0 for DocumentReplaceTransitionAction { fn base(&self) -> &DocumentBaseTransitionAction { match self { @@ -144,6 +148,24 @@ impl DocumentReplaceTransitionActionAccessorsV0 for DocumentReplaceTransitionAct DocumentReplaceTransitionAction::V0(v0) => v0.creator_id, } } + + fn property_constraint_aggregates(&self) -> &BTreeMap { + match self { + DocumentReplaceTransitionAction::V0(v0) => v0 + .property_constraint_aggregates + .as_deref() + .unwrap_or(&NO_PROPERTY_CONSTRAINT_AGGREGATES), + } + } + + fn set_property_constraint_aggregates(&mut self, aggregates: BTreeMap) { + match self { + DocumentReplaceTransitionAction::V0(v0) => { + v0.property_constraint_aggregates = + (!aggregates.is_empty()).then(|| Box::new(aggregates)) + } + } + } } /// document from replace transition diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs index 92e2f917f54..d8cb30b1287 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs @@ -1,5 +1,6 @@ pub mod transformer; +use dpp::data_contract::document_type::property_constraints::AggregateRead; use dpp::document::{Document, DocumentV0}; use dpp::identity::TimestampMillis; use dpp::platform_value::{Identifier, Value}; @@ -61,6 +62,11 @@ pub struct DocumentReplaceTransitionActionV0 { pub stored_changed_values: BTreeMap, /// Creator id pub creator_id: Option, + /// The `countOf` and `sumOf` totals the document type's `propertyConstraints` rules + /// read, each as it will be once this write is done, read from state when the action is + /// built; `None` when the rules judging the write read none, and boxed, since only + /// such a write holds any and the action is one variant of a large enum. + pub property_constraint_aggregates: Option>>, } /// document replace transition action accessors v0 @@ -113,6 +119,13 @@ pub trait DocumentReplaceTransitionActionAccessorsV0 { /// creator id fn creator_id(&self) -> Option; + + /// The `countOf` and `sumOf` totals the rules judging this write read, each as it will + /// be once the write is done + fn property_constraint_aggregates(&self) -> &BTreeMap; + + /// Sets the totals the rules judging this write read, once they are read from state + fn set_property_constraint_aggregates(&mut self, aggregates: BTreeMap); } /// document from replace transition v0 diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs index 89e6b4f31c5..cdd28954d77 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs @@ -197,6 +197,7 @@ impl DocumentReplaceTransitionActionV0 { removed_identifier_fields, stored_changed_values, creator_id: original_creator_id, + property_constraint_aggregates: Default::default(), } .into(), )) diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/mod.rs index ea7861a70ed..004408e1d72 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/mod.rs @@ -1,7 +1,9 @@ mod v0; use derive_more::From; +use dpp::data_contract::document_type::property_constraints::AggregateRead; use dpp::document::Document; +use std::collections::BTreeMap; use dpp::platform_value::Identifier; use dpp::ProtocolError; @@ -20,6 +22,9 @@ pub enum DocumentTransferTransitionAction { V0(DocumentTransferTransitionActionV0), } +/// What `property_constraint_aggregates` returns for an action holding none. +static NO_PROPERTY_CONSTRAINT_AGGREGATES: BTreeMap = BTreeMap::new(); + impl DocumentTransferTransitionActionAccessorsV0 for DocumentTransferTransitionAction { fn base(&self) -> &DocumentBaseTransitionAction { match self { @@ -44,6 +49,24 @@ impl DocumentTransferTransitionActionAccessorsV0 for DocumentTransferTransitionA DocumentTransferTransitionAction::V0(v0) => v0.document, } } + + fn property_constraint_aggregates(&self) -> &BTreeMap { + match self { + DocumentTransferTransitionAction::V0(v0) => v0 + .property_constraint_aggregates + .as_deref() + .unwrap_or(&NO_PROPERTY_CONSTRAINT_AGGREGATES), + } + } + + fn set_property_constraint_aggregates(&mut self, aggregates: BTreeMap) { + match self { + DocumentTransferTransitionAction::V0(v0) => { + v0.property_constraint_aggregates = + (!aggregates.is_empty()).then(|| Box::new(aggregates)) + } + } + } } /// document from transfer transition diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/v0/mod.rs index f87c75b6226..e3492172fb3 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/v0/mod.rs @@ -1,6 +1,8 @@ pub mod transformer; +use dpp::data_contract::document_type::property_constraints::AggregateRead; use dpp::document::Document; +use std::collections::BTreeMap; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionAction; @@ -11,6 +13,11 @@ pub struct DocumentTransferTransitionActionV0 { pub base: DocumentBaseTransitionAction, /// The new document to be inserted pub document: Document, + /// The `countOf` and `sumOf` totals the document type's `propertyConstraints` rules + /// read, each as it will be once this write is done, read from state when the action is + /// built; `None` when the rules judging the write read none, and boxed, since only + /// such a write holds any and the action is one variant of a large enum. + pub property_constraint_aggregates: Option>>, } /// document transfer transition action accessors v0 @@ -23,4 +30,11 @@ pub trait DocumentTransferTransitionActionAccessorsV0 { fn document(&self) -> &Document; /// the document to be inserted as owned fn document_owned(self) -> Document; + + /// The `countOf` and `sumOf` totals the rules judging this write read, each as it will + /// be once the write is done + fn property_constraint_aggregates(&self) -> &BTreeMap; + + /// Sets the totals the rules judging this write read, once they are read from state + fn set_property_constraint_aggregates(&mut self, aggregates: BTreeMap); } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/v0/transformer.rs index ed742f378f0..5529ee0eba6 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/v0/transformer.rs @@ -94,6 +94,7 @@ impl DocumentTransferTransitionActionV0 { DocumentTransferTransitionActionV0 { base, document: modified_document, + property_constraint_aggregates: Default::default(), } .into(), )) diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/mod.rs index 254e3f803bf..9da45febf5c 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/mod.rs @@ -1,7 +1,9 @@ mod v0; use derive_more::From; +use dpp::data_contract::document_type::property_constraints::AggregateRead; use dpp::document::Document; +use std::collections::BTreeMap; use dpp::platform_value::Identifier; use dpp::ProtocolError; @@ -20,6 +22,9 @@ pub enum DocumentUpdatePriceTransitionAction { V0(DocumentUpdatePriceTransitionActionV0), } +/// What `property_constraint_aggregates` returns for an action holding none. +static NO_PROPERTY_CONSTRAINT_AGGREGATES: BTreeMap = BTreeMap::new(); + impl DocumentUpdatePriceTransitionActionAccessorsV0 for DocumentUpdatePriceTransitionAction { fn base(&self) -> &DocumentBaseTransitionAction { match self { @@ -44,6 +49,24 @@ impl DocumentUpdatePriceTransitionActionAccessorsV0 for DocumentUpdatePriceTrans DocumentUpdatePriceTransitionAction::V0(v0) => v0.document, } } + + fn property_constraint_aggregates(&self) -> &BTreeMap { + match self { + DocumentUpdatePriceTransitionAction::V0(v0) => v0 + .property_constraint_aggregates + .as_deref() + .unwrap_or(&NO_PROPERTY_CONSTRAINT_AGGREGATES), + } + } + + fn set_property_constraint_aggregates(&mut self, aggregates: BTreeMap) { + match self { + DocumentUpdatePriceTransitionAction::V0(v0) => { + v0.property_constraint_aggregates = + (!aggregates.is_empty()).then(|| Box::new(aggregates)) + } + } + } } /// document from update price transition diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/v0/mod.rs index 493af12a6d2..d35d2c14a14 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/v0/mod.rs @@ -1,6 +1,8 @@ pub mod transformer; +use dpp::data_contract::document_type::property_constraints::AggregateRead; use dpp::document::Document; +use std::collections::BTreeMap; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionAction; @@ -11,6 +13,11 @@ pub struct DocumentUpdatePriceTransitionActionV0 { pub base: DocumentBaseTransitionAction, /// The new document to be updated pub document: Document, + /// The `countOf` and `sumOf` totals the document type's `propertyConstraints` rules + /// read, each as it will be once this write is done, read from state when the action is + /// built; `None` when the rules judging the write read none, and boxed, since only + /// such a write holds any and the action is one variant of a large enum. + pub property_constraint_aggregates: Option>>, } /// document transfer transition action accessors v0 @@ -23,4 +30,11 @@ pub trait DocumentUpdatePriceTransitionActionAccessorsV0 { fn document(&self) -> &Document; /// the document with updated price to be reinserted as owned fn document_owned(self) -> Document; + + /// The `countOf` and `sumOf` totals the rules judging this write read, each as it will + /// be once the write is done + fn property_constraint_aggregates(&self) -> &BTreeMap; + + /// Sets the totals the rules judging this write read, once they are read from state + fn set_property_constraint_aggregates(&mut self, aggregates: BTreeMap); } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/v0/transformer.rs index 928c9b0a078..311918c38af 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/v0/transformer.rs @@ -87,6 +87,7 @@ impl DocumentUpdatePriceTransitionActionV0 { DocumentUpdatePriceTransitionActionV0 { base, document: modified_document, + property_constraint_aggregates: Default::default(), } .into(), )) diff --git a/packages/rs-drive/src/state_transition_action/batch/tests.rs b/packages/rs-drive/src/state_transition_action/batch/tests.rs index 3334430a0de..49ae1ee2206 100644 --- a/packages/rs-drive/src/state_transition_action/batch/tests.rs +++ b/packages/rs-drive/src/state_transition_action/batch/tests.rs @@ -254,6 +254,7 @@ fn make_create_v0() -> DocumentCreateTransitionActionV0 { prefunded_voting_balance: None, current_store_contest_info: None, should_store_contest_info: None, + property_constraint_aggregates: Default::default(), } } @@ -404,6 +405,7 @@ fn make_replace_v0() -> DocumentReplaceTransitionActionV0 { removed_identifier_fields: BTreeMap::new(), stored_changed_values: BTreeMap::new(), creator_id: Some(Identifier::from([0xCC; 32])), + property_constraint_aggregates: Default::default(), } } @@ -517,6 +519,7 @@ fn make_transfer_v0() -> DocumentTransferTransitionActionV0 { DocumentTransferTransitionActionV0 { base: test_document_base(), document: test_document(), + property_constraint_aggregates: Default::default(), } } @@ -559,6 +562,7 @@ fn make_purchase_v0() -> DocumentPurchaseTransitionActionV0 { document: test_document(), original_owner_id: Identifier::from([0xDD; 32]), price: 5000, + property_constraint_aggregates: Default::default(), } } @@ -611,6 +615,7 @@ fn make_update_price_v0() -> DocumentUpdatePriceTransitionActionV0 { DocumentUpdatePriceTransitionActionV0 { base: test_document_base(), document: test_document(), + property_constraint_aggregates: Default::default(), } } @@ -2649,6 +2654,7 @@ fn test_all_purchases_amount_multiple_document_purchases() { document: test_document(), original_owner_id: Identifier::from([0xDD; 32]), price: 3000, + property_constraint_aggregates: Default::default(), }) .into(); batch.set_transitions(vec![ @@ -2948,6 +2954,7 @@ fn stamp_test_create_action(protocol_version: u32) -> DocumentCreateTransitionAc prefunded_voting_balance: None, current_store_contest_info: None, should_store_contest_info: None, + property_constraint_aggregates: Default::default(), }) } @@ -2980,6 +2987,7 @@ fn stamp_test_replace_action(protocol_version: u32) -> DocumentReplaceTransition removed_identifier_fields: BTreeMap::new(), stored_changed_values: BTreeMap::new(), creator_id: Some(Identifier::from([0xCC; 32])), + property_constraint_aggregates: Default::default(), }) } diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs index 3a5e75b0d83..71addd4e1f3 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs @@ -16,6 +16,11 @@ pub struct DriveDocumentMethodVersions { pub index_uniqueness: DriveDocumentIndexUniquenessMethodVersions, pub primary_key_tree_type: FeatureVersion, pub expiration: DriveDocumentExpirationMethodVersions, + /// `Drive::fetch_property_constraint_aggregate`: a `countOf` or `sumOf` total a + /// `propertyConstraints` rule reads, from the count or sum tree keeping it. Reachable + /// from protocol version 14 only, the first version whose parser reads a rule; the slot + /// is 0 in every table. + pub fetch_property_constraint_aggregate: FeatureVersion, } /// Drive methods of document expiry: the expirations tree under `Misc` that indexes every diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs index 007da0f795c..392d1c7eb21 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs @@ -87,6 +87,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V1: DriveDocumentMethodVersions = validate_restored_document_uniqueness: 0, }, primary_key_tree_type: 0, + fetch_property_constraint_aggregate: 0, expiration: DriveDocumentExpirationMethodVersions { insert_document_ttl_trees: 0, add_document_expiration_operations: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs index f0977e27dba..3353ed72101 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs @@ -121,6 +121,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V2: DriveDocumentMethodVersions = // stop, so a future change to the v1 arm doesn't need to // re-prove v0 ≡ v1 for every pre-v12 corner case. primary_key_tree_type: 0, + fetch_property_constraint_aggregate: 0, expiration: DriveDocumentExpirationMethodVersions { insert_document_ttl_trees: 0, add_document_expiration_operations: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs index cd31af3d96e..e0a408f26e6 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs @@ -118,6 +118,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V3: DriveDocumentMethodVersions = // versions stay on V2's v0 dispatch via their own method // tables (see V2's comment for the freeze rationale). primary_key_tree_type: 1, + fetch_property_constraint_aggregate: 0, expiration: DriveDocumentExpirationMethodVersions { insert_document_ttl_trees: 0, add_document_expiration_operations: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs index 2969e926e75..0b62e55fa94 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs @@ -178,6 +178,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = // Unchanged from V3 — see V3's comment for the v12-gated // count/sum composition rationale. primary_key_tree_type: 1, + fetch_property_constraint_aggregate: 0, expiration: DriveDocumentExpirationMethodVersions { insert_document_ttl_trees: 0, add_document_expiration_operations: 0, 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 a10335f9800..0ce27215cc1 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -573,6 +573,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_reference_expression_depth: 4, max_property_constraints: 16, max_property_constraint_nodes: 32, + max_property_constraint_aggregates: 4, max_state_transition_size: 20000, // Is different in this test version, not sure if this was a mistake // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch. Raising it here 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 d39e3abd60a..22be76822a6 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -47,8 +47,8 @@ pub struct SystemLimits { pub max_reference_expression_depth: u16, /// Maximum number of named rules one document type's `propertyConstraints` may /// declare. Every rule is evaluated on each create and replace of a document of the - /// type, and no rule reads state, so this and `max_property_constraint_nodes` are what - /// bound the arithmetic one document write causes. Refused under full validation only, + /// type, so this and `max_property_constraint_nodes` are what bound the arithmetic one + /// document write causes, and `max_property_constraint_aggregates` the state it reads. Refused under full validation only, /// like `max_typed_array_items`. Read by document type parser generation 3 (protocol /// version 14), the only generation that parses `propertyConstraints`, and never /// reached before. @@ -63,6 +63,14 @@ pub struct SystemLimits { /// Refused under full validation only, like `max_property_constraints`. Read by document /// type parser generation 3 (protocol version 14) and never reached before. pub max_property_constraint_nodes: u16, + /// Maximum number of distinct `countOf` and `sumOf` totals the `propertyConstraints` + /// rules of one document type read. Each is a billed read of a count or sum tree on + /// every create or replace of a document of the type (and on a transfer or purchase + /// when it depends on the owner), so this bounds the state one document write reads for + /// its rules. A total two rules read alike counts once. Refused under full validation + /// only, like `max_property_constraints`. Read by document type parser generation 3 + /// (protocol version 14) and never reached before. + pub max_property_constraint_aggregates: u16, /// Max size of a state transition in bytes. /// /// NOTE: This must be equal to the `max-tx-bytes` in the Tenderdash config 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 2494b47588e..a9b5e7fdbb6 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -10,6 +10,7 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_reference_expression_depth: 4, max_property_constraints: 16, max_property_constraint_nodes: 32, + max_property_constraint_aggregates: 4, max_state_transition_size: 20480, //20 KiB // TODO: this is currently capped at 1 because the batch state-transition // pipeline has known correctness issues with multi-transition batches: 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 ebfdb2980e9..bb5d64573be 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -16,6 +16,7 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_reference_expression_depth: 4, max_property_constraints: 16, max_property_constraint_nodes: 32, + max_property_constraint_aggregates: 4, max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. 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 5cbb8670220..b54132c50e2 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -18,6 +18,7 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_reference_expression_depth: 4, max_property_constraints: 16, max_property_constraint_nodes: 32, + max_property_constraint_aggregates: 4, max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. 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 cbe142a5bbf..a7b23123841 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -66,9 +66,10 @@ use crate::version::system_limits::SystemLimits; /// never read them; every leaf counts against `max_references_per_document`. /// * Property constraints (protocol version 14): a document type declares at most 16 /// `propertyConstraints` rules (`max_property_constraints`) of at most 32 nodes each -/// (`max_property_constraint_nodes`), both backfilled into the earlier tables, whose -/// parsers never read them. The rules read no state, so these two bound the arithmetic -/// one document write causes. +/// (`max_property_constraint_nodes`), reading at most 4 distinct `countOf` and `sumOf` +/// totals (`max_property_constraint_aggregates`), all backfilled into the earlier tables, +/// whose parsers never read them. The first two bound the arithmetic one document write +/// causes, the third the count and sum trees it reads. /// * Document expiry (protocol version 14): a document type may declare a `ttl` of at least /// one hour (`min_document_ttl_seconds`) and at most one year (`max_document_ttl_seconds`), /// and the platform deletes at most 128 expired documents per block @@ -97,7 +98,8 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_reference_expression_depth: 4, // refersTo anyOf / allOf (new in v14): contract registration caps how deep they nest max_property_constraints: 16, // propertyConstraints (new in v14): contract registration caps the rules one document type declares max_property_constraint_nodes: 32, // propertyConstraints (new in v14): contract registration caps the nodes (comparison, operators, properties, values) of one rule - max_state_transition_size: 20480, //20 KiB + max_property_constraint_aggregates: 4, // propertyConstraints countOf / sumOf (new in v14): contract registration caps the distinct totals one document type's rules read + max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. max_transitions_in_documents_batch: 1, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 0af64d7c8c4..245bb5867b9 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1083,7 +1083,16 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// (the second when the first holds, the third when it does not, only the /// branch taken evaluated), no two alike; `notIn`, an `in` negated in as /// many nodes. In an operand, a property the document leaves out counts as -/// 0, or as the value of an `ifAbsent` operand naming it. +/// 0, or as the value of an `ifAbsent` operand naming it. `countOf` and +/// `sumOf` operands read a total from state: how many documents of a type +/// of the same contract match a filter (keys of that type or `$ownerId`, +/// values read from the document written), or an integer property's total +/// over them, as the count or sum tree will keep it once the write is done; +/// the batch transformer reads them into the action +/// (`Drive::fetch_property_constraint_aggregate`, billed; in place in +/// transformer 0, reading nothing before this version), a transfer or +/// purchase reads again those depending on the owner, and a rule reading +/// one it is not given (an SDK pre-check) is not judged. /// Arithmetic is exact `i128`: `divide` and `modulo` are Euclidean (the /// remainder is never negative), and an overflow, a zero divisor, a /// negative exponent or a value that is not an integer refuses the document @@ -1117,7 +1126,12 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// (16 rules) and `max_property_constraint_nodes` (32 per rule, every /// comparison, `in`, listed value, `const`, presence test and logical /// operator counting as one), and that no `anyOf` or `allOf` lists the same -/// condition twice. `DataContract::validate_document_properties` 0 +/// condition twice, and at most `max_property_constraint_aggregates` (4) +/// distinct totals per type; once every type is parsed, that a tree keeps +/// each total (`documentsCountable` or `documentsSummable`, or an index +/// whose properties are exactly the filter's keys), in +/// `create_document_types_from_document_schemas` 1, in place and inert +/// before this version. `DataContract::validate_document_properties` 0 /// (extended in place, inert before this version, and taking the document's /// owner for `$ownerId`) calls `validate_property_constraints` /// (`validate_property_constraints` 0) after the schema validation, so @@ -1133,7 +1147,8 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// takes the document version's system values (`DocumentSystemValues`): /// consensus gives the writer and the block's time and heights on create, /// and on replace the stored creation and transfer values with the block's -/// as the update. The rules read no state and change nothing stored. They +/// as the update. The rules change nothing stored and read no state but +/// their totals. They /// are fixed when the document type is created: a changed /// `propertyConstraints` is an incompatible schema change on update. The /// moderation charters contract declares its first one: a diff --git a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs index 32f48eff62e..de476642d4c 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs @@ -45,7 +45,12 @@ const DOCUMENT_PROPERTY_CONSTRAINTS_TS: &'static str = r#" * - `length` and `byteLength`: the characters (as `maxLength` counts them) * and the UTF-8 bytes of a string property; `count`: the items of an array * property, or the bytes of a byte array property. Each is 0 when the - * document leaves the property out. + * document leaves the property out; + * - `countOf` and `sumOf`: a total read from state, how many documents of a + * type of the same contract match a filter, or the total of an integer + * property over them, as the type's count or sum trees keep it once the + * write is done. `checkDocumentPropertyConstraints` does not judge a rule + * reading one. */ export type PropertyConstraintExpression = | number @@ -63,7 +68,24 @@ export type PropertyConstraintExpression = | { abs: PropertyConstraintExpression } | { length: string } | { byteLength: string } - | { count: string }; + | { count: string } + | { countOf: [documentType: string] | [documentType: string, filter: PropertyConstraintAggregateFilter] } + | { + sumOf: + | [documentType: string, property: string] + | [documentType: string, property: string, filter: PropertyConstraintAggregateFilter] + }; + +/** + * Which documents a `countOf` or `sumOf` totals: each key, a property path of + * the counted type or `"$ownerId"`, mapped to the value its documents must + * take there, read from the document being written: a property path of it, + * `"$ownerId"`, an integer, or a `const` string or base58 identifier. + */ +export type PropertyConstraintAggregateFilter = Record< + string, + string | number | bigint | { const: string } +>; /** * One side of a comparison of strings or identifiers. @@ -376,7 +398,8 @@ pub(crate) fn property_constraints_for_document_type( /// heights the write keeps, with the device clock standing in for the block /// time it records (its update, and its creation and transfer when the /// document has none yet). The block heights it records are unknown until the -/// block, so a rule reading one is not judged. +/// block, so a rule reading one is not judged, and so is a rule reading a +/// `countOf` or `sumOf` total, which no client reads from state here. fn system_values_for_write(document: &Document) -> DocumentSystemValues { // Milliseconds since the epoch, a whole number well inside a `u64` let now = js_sys::Date::now() as u64; diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index c4a8f6d6620..0eb399a16c0 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -879,8 +879,9 @@ impl DataContractWasm { /// device clock in place of the block time the write will record (the /// document's stored creation and transfer times when it has them). A /// rule reading a block height the write records is not judged, since the - /// height is unknown until the block. `undefined` when it meets every rule - /// of its document type. + /// height is unknown until the block, and neither is a rule reading a + /// `countOf` or `sumOf` total, which only the platform reads from state. + /// `undefined` when it meets every rule of its document type. /// /// A pre-check, so an app can refuse a document before paying for a /// transition consensus would refuse with diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts index adee0db8a51..5ce43c59f46 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts @@ -417,6 +417,67 @@ describe('DataContract: propertyConstraints (v14)', () => { .to.deep.include({ rule: 'termsPositive', violation: 'NotMet' }); }); + it('should report what a countOf or sumOf reads, and leave it to consensus', () => { + const rules = { + atMostTwoPerOwner: { + lessThanOrEqual: [{ countOf: ['listing', { $ownerId: '$ownerId' }] }, 2], + }, + categoryBudget: { + lessThanOrEqual: [{ sumOf: ['listing', 'price', { category: 'category' }] }, 250], + }, + }; + const contract = buildContract({ + listing: { + type: 'object', + properties: { + price: { + type: 'integer', minimum: 0, maximum: 1000000000, position: 0, + }, + category: { + type: 'integer', minimum: 0, maximum: 100, position: 1, + }, + }, + required: ['price', 'category'], + indices: [ + { name: 'byOwner', properties: [{ $ownerId: 'asc' }], countable: 'countable' }, + { name: 'byCategory', properties: [{ category: 'asc' }], summable: 'price' }, + ], + additionalProperties: false, + propertyConstraints: rules, + }, + }); + + // The count by owner depends on the owner, the category total on a + // property of the document written + expect(contract.documentTypePropertyConstraints('listing')).to.deep.equal([ + { + name: 'atMostTwoPerOwner', + rule: rules.atMostTwoPerOwner, + reads: [], + readsOwner: true, + readsSystem: [], + }, + { + name: 'categoryBudget', + rule: rules.categoryBudget, + reads: [{ path: 'category', kind: 'value' }], + readsOwner: false, + readsSystem: [], + }, + ]); + + // A price far past the budget: the total is read from state, which the + // pre-check does not do, so neither rule is judged + const listing = new wasm.Document({ + properties: { price: 999999999, category: 1 }, + documentTypeName: 'listing', + dataContractId: contract.id, + ownerId, + revision: BigInt(1), + }); + expect(contract.checkDocumentPropertyConstraints(listing)).to.equal(undefined); + }); + it('should report integer literals past Number.MAX_SAFE_INTEGER exactly, as bigint', () => { const big = 9007199254740993n; // 2 ** 53 + 1, which a number rounds const rules = { From bed9b1676101798da1439ba4d28bb00501e4e5a8 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 12:59:52 +0700 Subject: [PATCH 073/113] fix(dpp): pass the contract's $defs to the countOf and sumOf key enum check (#5110) Co-authored-by: Claude Opus 5.5 --- .../v1/mod.rs | 2 +- .../class_methods/try_from_schema/mod.rs | 6 +- .../property_constraint_aggregates_tests.rs | 65 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs index dff9729ee45..6e31697a75d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs @@ -289,7 +289,7 @@ impl DocumentType { // What a `countOf` or `sumOf` totals is another document type of the contract, so it // is checked once all are parsed. Inert for every protocol version before 14: only // the tables carrying `parse_property_constraints: Some(_)` parse a rule at all. - validate_property_constraint_aggregates(&contract_document_types) + validate_property_constraint_aggregates(&contract_document_types, schema_defs) .map_err(consensus_or_protocol_data_contract_error)?; Ok(contract_document_types) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index aee295c76ae..51e27fb0649 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -2391,9 +2391,11 @@ impl AggregateKeyKind { /// ([`AggregateRead::whole_type_kept`], [`AggregateRead::answering_index`]), /// so that a rule never reads a total a count or sum tree does not keep. /// Registration only: the index and property settings it relies on do not -/// change on a contract update. +/// change on a contract update. `schema_defs`, the contract's `$defs`, resolves +/// a filter key declared through a `$ref`. pub(in crate::data_contract::document_type::class_methods) fn validate_property_constraint_aggregates( document_types: &BTreeMap, + schema_defs: Option<&BTreeMap>, ) -> Result<(), DataContractError> { for (type_name, document_type) in document_types { let declaring = document_type.as_ref(); @@ -2487,7 +2489,7 @@ pub(in crate::data_contract::document_type::class_methods) fn validate_property_ AggregateKeyKind::Text => { // A constant the key's `enum` does not list is a typo: // no document could match it - if !enum_admits(counted.schema(), key, constant)? { + if !enum_admits(counted.schema(), schema_defs, key, constant)? { return Err(error(format!( "{totals} with \"{key}\" at \"{constant}\", which is \ not one of its enum values" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraint_aggregates_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraint_aggregates_tests.rs index 246ad0a0c4e..805697009ea 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraint_aggregates_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraint_aggregates_tests.rs @@ -639,3 +639,68 @@ fn should_match_a_document_by_the_values_its_filter_takes() { 0 ); } + +/// A filter key whose schema is a `$ref` to one of the contract's `$defs` is +/// held to the definition's `enum`, as one declared inline is: a constant the +/// enum lists registers, and a misspelt one is refused. +#[test] +fn should_hold_a_constant_to_the_enum_of_a_key_declared_by_reference() { + let counting = |status: &str| { + let contract = json!({ + "$formatVersion": "1", + "id": Identifier::from([7; 32]).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": 1, + "schemaDefs": { + "status": { "type": "string", "enum": ["open", "closed"], "maxLength": 10 } + }, + "documentSchemas": { + "listing": { + "type": "object", + "documentsCountable": true, + "properties": { + "status": { "$ref": "#/$defs/status", "position": 0 } + }, + "required": ["status"], + "indices": [{ + "name": "byOwnerStatus", + "properties": [{ "$ownerId": "asc" }, { "status": "asc" }], + "countable": "countable" + }], + "additionalProperties": false + }, + "seller": { + "type": "object", + "properties": { + "note": { "type": "string", "maxLength": 20, "position": 0 } + }, + "additionalProperties": false, + "propertyConstraints": { + "rule": { + "lessThanOrEqual": [ + { + "countOf": [ + "listing", + { "$ownerId": "$ownerId", "status": { "const": status } } + ] + }, + 10 + ] + } + } + } + } + }); + DataContract::from_value( + platform_value::to_value(contract).expect("the contract converts"), + true, + PlatformVersion::latest(), + ) + }; + + counting("open").expect("a constant the referenced enum lists registers"); + expect_structure_error( + counting("opne"), + "with \"status\" at \"opne\", which is not one of its enum values", + ); +} From dd01f49142eb39efbe8d61c9393887db2695064c Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 13:02:23 +0700 Subject: [PATCH 074/113] fix(sdk): consensus errors reach JS with their code (#5112) Co-authored-by: Claude Opus 5.5 --- packages/wasm-dpp2/src/error.rs | 26 ++- .../unit/DocumentPropertyConstraints.spec.ts | 19 ++ packages/wasm-sdk/src/error.rs | 163 +++++++++++++++++- .../src/state_transitions/addresses.rs | 12 +- .../src/state_transitions/broadcast.rs | 14 +- .../src/state_transitions/contract.rs | 2 +- .../src/state_transitions/identity.rs | 10 +- .../wasm-sdk/src/state_transitions/token.rs | 28 ++- 8 files changed, 226 insertions(+), 48 deletions(-) diff --git a/packages/wasm-dpp2/src/error.rs b/packages/wasm-dpp2/src/error.rs index 2a38ccc9f8d..f721cb52abb 100644 --- a/packages/wasm-dpp2/src/error.rs +++ b/packages/wasm-dpp2/src/error.rs @@ -1,5 +1,7 @@ use anyhow::Error as AnyhowError; use dpp::ProtocolError; +use dpp::consensus::ConsensusError; +use dpp::consensus::codes::ErrorWithCode; use wasm_bindgen::prelude::wasm_bindgen; /// Structured error returned by wasm-dpp2 APIs. @@ -25,7 +27,7 @@ pub enum WasmDppErrorKind { pub struct WasmDppError { kind: WasmDppErrorKind, message: String, - /// Optional numeric error code. `-1` indicates absence. + /// The consensus error code when the error is a consensus error. `-1` indicates absence. code: i32, } @@ -38,10 +40,6 @@ impl WasmDppError { } } - pub(crate) fn protocol(message: impl Into) -> Self { - Self::new(WasmDppErrorKind::Protocol, message, None) - } - pub fn invalid_argument(message: impl Into) -> Self { Self::new(WasmDppErrorKind::InvalidArgument, message, None) } @@ -61,10 +59,26 @@ impl WasmDppError { impl From for WasmDppError { fn from(error: ProtocolError) -> Self { - Self::protocol(error.to_string()) + let code = consensus_error_code(&error); + Self::new(WasmDppErrorKind::Protocol, error.to_string(), code) } } +/// The consensus error code (`10422`, `40132`, ...) a protocol error carries, or `None` when it +/// is not a consensus error. JS branches on this number instead of matching the message. +pub fn consensus_error_code(error: &ProtocolError) -> Option { + let code = match error { + ProtocolError::ConsensusError(consensus_error) => consensus_error.code(), + // Platform refuses a contract with this error wrapped as `BasicError::ContractError`, + // so one caught before broadcast carries the number Platform would have sent. + ProtocolError::DataContractError(contract_error) => { + ConsensusError::from(contract_error.clone()).code() + } + _ => return None, + }; + i32::try_from(code).ok() +} + impl From for WasmDppError { fn from(error: AnyhowError) -> Self { Self::generic(error.to_string()) diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts index 5ce43c59f46..3e4ff490b3d 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts @@ -606,4 +606,23 @@ describe('DataContract: propertyConstraints (v14)', () => { expect(() => contract.checkDocumentPropertyConstraints(unknownType)).to.throw(/not found/); }); }); + + describe('a malformed rule', () => { + it('should throw with the consensus code Platform would refuse the contract with', () => { + const malformed = { + ...schemas, + offer: { ...schemas.offer, propertyConstraints: { rule: { equal: ['price'] } } }, + }; + + try { + buildContract(malformed); + expect.fail('expected to throw'); + } catch (e) { + expect(e).to.be.instanceOf(wasm.WasmDppError); + expect(e.message).to.match(/must list exactly two operands/); + // InvalidContractStructure + expect(e.code).to.equal(10231); + } + }); + }); }); diff --git a/packages/wasm-sdk/src/error.rs b/packages/wasm-sdk/src/error.rs index 8a66fa95e56..68539e1761b 100644 --- a/packages/wasm-sdk/src/error.rs +++ b/packages/wasm-sdk/src/error.rs @@ -3,7 +3,7 @@ use dash_sdk::platform::encrypted_for::EncryptedForError; use dash_sdk::{error::StateTransitionBroadcastError, Error as SdkError}; use rs_dapi_client::CanRetry; use wasm_bindgen::prelude::wasm_bindgen; -use wasm_dpp2::error::WasmDppError; +use wasm_dpp2::error::{consensus_error_code, WasmDppError}; /// Structured error surfaced to JS consumers #[wasm_bindgen] @@ -62,7 +62,9 @@ pub enum WasmSdkErrorKind { pub struct WasmSdkError { kind: WasmSdkErrorKind, message: String, - /// Optional numeric code for some errors (e.g., broadcast error code). + /// The consensus error code (`10422`, `40132`, ...) when the error is a consensus error, + /// whether Platform refused the transition or the SDK caught it before broadcast; `-1` + /// otherwise. code: i32, /// Indicates if the operation can be retried safely. is_retriable: bool, @@ -85,6 +87,19 @@ impl WasmSdkError { } } + /// Converts `err` as `?` would, keeping its kind, consensus code and whether it can be + /// retried, under the message `"{context}: {err}"`. + pub(crate) fn with_context(context: &str, err: E) -> Self + where + E: std::fmt::Display + Into, + { + let message = format!("{context}: {err}"); + Self { + message, + ..err.into() + } + } + pub(crate) fn generic(message: impl Into) -> Self { Self::new(WasmSdkErrorKind::Generic, message, None, false) } @@ -152,7 +167,12 @@ impl From for WasmSdkError { None, retriable, ), - Protocol(e) => Self::new(WasmSdkErrorKind::Protocol, e.to_string(), None, retriable), + Protocol(e) => Self::new( + WasmSdkErrorKind::Protocol, + e.to_string(), + consensus_error_code(&e), + retriable, + ), Proof(e) => Self::new(WasmSdkErrorKind::Proof, e.to_string(), None, retriable), // Deterministic for a given transition family: retrying another // node cannot upgrade a snapshot into execution evidence. @@ -273,7 +293,8 @@ impl From for WasmSdkError { } impl From for WasmSdkError { fn from(err: ProtocolError) -> Self { - Self::new(WasmSdkErrorKind::Protocol, err.to_string(), None, false) + let code = consensus_error_code(&err); + Self::new(WasmSdkErrorKind::Protocol, err.to_string(), code, false) } } @@ -299,7 +320,12 @@ impl From for WasmSdkError { WasmDppErrorKind::Conversion => WasmSdkErrorKind::SerializationError, WasmDppErrorKind::Generic => WasmSdkErrorKind::Generic, }; - Self::new(kind, err.to_string(), None, false) + Self { + kind, + message: err.to_string(), + code: err.code(), + is_retriable: false, + } } } @@ -358,7 +384,8 @@ impl WasmSdkError { self.message.clone() } - /// Optional numeric code. -1 means absent/not applicable + /// The consensus error code when the error is a consensus error (compare it with + /// `DocumentPropertyConstraintErrorCode` and the other code enums); -1 otherwise. #[wasm_bindgen(getter)] pub fn code(&self) -> i32 { self.code @@ -370,3 +397,127 @@ impl WasmSdkError { self.is_retriable } } + +#[cfg(test)] +mod tests { + use super::*; + use dapi_grpc::tonic::metadata::{MetadataMap, MetadataValue}; + use dapi_grpc::tonic::{Code, Status}; + use dash_sdk::dpp::consensus::basic::document::{ + DocumentPropertyConstraintViolatedError, PropertyConstraintViolation, + }; + use dash_sdk::dpp::consensus::state::contract_moderation::ContractUserBannedError; + use dash_sdk::dpp::consensus::ConsensusError; + use dash_sdk::dpp::data_contract::errors::DataContractError; + use dash_sdk::dpp::platform_value::Identifier; + use dash_sdk::dpp::serialization::PlatformSerializableWithPlatformVersion; + use dash_sdk::dpp::version::PlatformVersion; + use rs_dapi_client::transport::TransportError; + use rs_dapi_client::DapiClientError; + + fn property_constraint_violated() -> ConsensusError { + DocumentPropertyConstraintViolatedError::new( + "post".to_string(), + "rule 0".to_string(), + PropertyConstraintViolation::NotMet, + ) + .into() + } + + fn contract_user_banned() -> ConsensusError { + ContractUserBannedError::new(Identifier::new([1; 32]), Identifier::new([2; 32])).into() + } + + /// What the SDK makes of DAPI refusing a transition at CheckTx: a gRPC status carrying the + /// serialized consensus error in its metadata. + fn refused_by_platform(consensus_error: &ConsensusError) -> SdkError { + let bytes = consensus_error + .serialize_to_bytes_with_platform_version(PlatformVersion::latest()) + .expect("serialize consensus error"); + let mut metadata = MetadataMap::new(); + metadata.insert_bin( + "dash-serialized-consensus-error-bin", + MetadataValue::from_bytes(&bytes), + ); + let status = + Status::with_metadata(Code::InvalidArgument, consensus_error.to_string(), metadata); + SdkError::from(DapiClientError::Transport(TransportError::Grpc(status))) + } + + #[test] + fn should_carry_the_code_of_a_consensus_error_platform_refused_the_transition_with() { + let error = WasmSdkError::from(refused_by_platform(&property_constraint_violated())); + + assert_eq!(error.kind(), WasmSdkErrorKind::Protocol); + assert_eq!(error.code(), 10422); + } + + #[test] + fn should_carry_the_consensus_code_under_the_message_of_the_operation_that_failed() { + let error = WasmSdkError::with_context( + "Failed to mint tokens", + refused_by_platform(&contract_user_banned()), + ); + + assert_eq!(error.kind(), WasmSdkErrorKind::Protocol); + assert_eq!(error.code(), 41107); + assert!(!error.is_retriable()); + assert_eq!( + error.message(), + format!( + "Failed to mint tokens: Protocol error: {}", + contract_user_banned() + ) + ); + } + + #[test] + fn should_carry_the_code_of_a_consensus_error_the_sdk_caught_before_broadcast() { + let error = WasmSdkError::from(ProtocolError::from(property_constraint_violated())); + + assert_eq!(error.kind(), WasmSdkErrorKind::Protocol); + assert_eq!(error.code(), 10422); + } + + #[test] + fn should_carry_the_code_of_a_consensus_error_through_a_wasm_dpp_error() { + let dpp_error = WasmDppError::from(ProtocolError::from(contract_user_banned())); + let error = WasmSdkError::from(dpp_error); + + assert_eq!(error.kind(), WasmSdkErrorKind::Protocol); + assert_eq!(error.code(), 41107); + } + + #[test] + fn should_carry_the_code_platform_refuses_a_contract_error_with() { + let contract_error = DataContractError::InvalidContractStructure("malformed".to_string()); + let error = WasmSdkError::from(WasmDppError::from(ProtocolError::from(contract_error))); + + assert_eq!(error.code(), 10231); + } + + #[test] + fn should_report_no_code_for_a_protocol_error_that_is_not_a_consensus_error() { + let error = WasmSdkError::from(ProtocolError::Generic("not consensus".to_string())); + + assert_eq!(error.code(), -1); + } + + #[test] + fn should_keep_the_broadcast_error_code() { + let error = WasmSdkError::with_context( + "Failed to broadcast", + SdkError::from(StateTransitionBroadcastError { + code: 40132, + message: "fee agreement not set".to_string(), + cause: None, + }), + ); + + assert_eq!( + error.kind(), + WasmSdkErrorKind::StateTransitionBroadcastError + ); + assert_eq!(error.code(), 40132); + } +} diff --git a/packages/wasm-sdk/src/state_transitions/addresses.rs b/packages/wasm-sdk/src/state_transitions/addresses.rs index 8a21952a3b6..f00a2f16dcd 100644 --- a/packages/wasm-sdk/src/state_transitions/addresses.rs +++ b/packages/wasm-sdk/src/state_transitions/addresses.rs @@ -177,7 +177,7 @@ impl WasmSdk { .inner_sdk() .transfer_address_funds(inputs_map, outputs_map, fee_strategy, &signer, settings) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to transfer funds: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to transfer funds", e))?; address_infos_to_js_map(address_infos, "transfer") } @@ -300,7 +300,7 @@ impl WasmSdk { let (address_infos, new_balance, _proof_height) = identity .top_up_from_addresses(self.inner_sdk(), inputs_map, &signer, settings) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to top up identity: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to top up identity", e))?; Ok(IdentityTopUpFromAddressesResultWasm { address_infos: address_infos_to_js_map(address_infos, "top up")?, @@ -466,7 +466,7 @@ impl WasmSdk { settings, ) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to withdraw funds: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to withdraw funds", e))?; address_infos_to_js_map(address_infos, "withdrawal") } @@ -519,7 +519,7 @@ impl WasmSdk { ) .await .map_err(|e| { - WasmSdkError::generic(format!("Failed to transfer credits to addresses: {}", e)) + WasmSdkError::with_context("Failed to transfer credits to addresses", e) })?; Ok(IdentityTransferToAddressesResultWasm { @@ -758,7 +758,7 @@ impl WasmSdk { settings, ) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to fund addresses: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to fund addresses", e))?; address_infos_to_js_map(address_infos, "funding") } @@ -928,7 +928,7 @@ impl WasmSdk { ) .await .map_err(|e| { - WasmSdkError::generic(format!("Failed to create identity from addresses: {}", e)) + WasmSdkError::with_context("Failed to create identity from addresses", e) })?; Ok(IdentityCreateFromAddressesResultWasm { diff --git a/packages/wasm-sdk/src/state_transitions/broadcast.rs b/packages/wasm-sdk/src/state_transitions/broadcast.rs index c77b1d13cb2..20880dfcc4b 100644 --- a/packages/wasm-sdk/src/state_transitions/broadcast.rs +++ b/packages/wasm-sdk/src/state_transitions/broadcast.rs @@ -145,7 +145,7 @@ impl WasmSdk { st.broadcast(self.as_ref(), put_settings) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to broadcast", e))?; Ok(()) } @@ -207,14 +207,14 @@ impl WasmSdk { st.broadcast(self.as_ref(), put_settings) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to broadcast", e))?; let (outcome, _metadata) = st .wait_for_outcome_with_metadata(self.as_ref(), put_settings) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to broadcast", e))?; let owner_balance = outcome.owner_balance(); let result = require_execution_proved(outcome) - .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to broadcast", e))?; with_owner_balance(convert_proof_result(result)?, owner_balance) } @@ -247,7 +247,7 @@ impl WasmSdk { .wait_for_outcome_with_metadata(self.as_ref(), put_settings) .await .map_err(|e| { - WasmSdkError::generic(format!("Failed to wait for state transition result: {}", e)) + WasmSdkError::with_context("Failed to wait for state transition result", e) })?; let owner_balance = outcome.owner_balance(); let result = outcome.into_result(); @@ -274,11 +274,11 @@ impl WasmSdk { st.broadcast(self.as_ref(), put_settings) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to broadcast", e))?; let (outcome, _metadata) = st .wait_for_outcome_with_metadata(self.as_ref(), put_settings) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to broadcast", e))?; let owner_balance = outcome.owner_balance(); let result = outcome.into_result(); diff --git a/packages/wasm-sdk/src/state_transitions/contract.rs b/packages/wasm-sdk/src/state_transitions/contract.rs index 200329619e1..accd62b03d6 100644 --- a/packages/wasm-sdk/src/state_transitions/contract.rs +++ b/packages/wasm-sdk/src/state_transitions/contract.rs @@ -232,7 +232,7 @@ impl WasmSdk { None, ) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to create update transition: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to create update transition", e))?; // Broadcast the transition. A contract update proof authenticates // the current contract body — a height-pinned snapshot — and cannot diff --git a/packages/wasm-sdk/src/state_transitions/identity.rs b/packages/wasm-sdk/src/state_transitions/identity.rs index d222c497e63..f0354c9a9dd 100644 --- a/packages/wasm-sdk/src/state_transitions/identity.rs +++ b/packages/wasm-sdk/src/state_transitions/identity.rs @@ -125,7 +125,7 @@ impl WasmSdk { settings, ) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to create identity: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to create identity", e))?; Ok(()) } @@ -213,7 +213,7 @@ impl WasmSdk { settings, ) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to top up identity: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to top up identity", e))?; Ok(BigInt::from(new_balance)) } @@ -496,7 +496,7 @@ impl WasmSdk { settings, ) .await - .map_err(|e| WasmSdkError::generic(format!("Withdrawal failed: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Withdrawal failed", e))?; Ok(BigInt::from(remaining_balance)) } @@ -689,14 +689,14 @@ impl WasmSdk { None, ) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to create update transition: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to create update transition", e))?; // Broadcast the transition use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; state_transition .broadcast_and_wait::(self.inner_sdk(), settings) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast update: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to broadcast update", e))?; Ok(()) } diff --git a/packages/wasm-sdk/src/state_transitions/token.rs b/packages/wasm-sdk/src/state_transitions/token.rs index 061f61e44dd..6432d9563e3 100644 --- a/packages/wasm-sdk/src/state_transitions/token.rs +++ b/packages/wasm-sdk/src/state_transitions/token.rs @@ -400,7 +400,7 @@ impl WasmSdk { .inner_sdk() .token_mint(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to mint tokens: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to mint tokens", e))?; Ok(TokenMintResultWasm::from_result(result, contract_id)) } @@ -628,7 +628,7 @@ impl WasmSdk { .inner_sdk() .token_burn(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to burn tokens: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to burn tokens", e))?; Ok(TokenBurnResultWasm::from_result(result, contract_id)) } @@ -867,7 +867,7 @@ impl WasmSdk { .inner_sdk() .token_transfer(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to transfer tokens: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to transfer tokens", e))?; Ok(TokenTransferResultWasm::from_result( result, @@ -1085,7 +1085,7 @@ impl WasmSdk { .inner_sdk() .token_freeze(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to freeze tokens: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to freeze tokens", e))?; Ok(TokenFreezeResultWasm::from_result(result, contract_id)) } @@ -1298,7 +1298,7 @@ impl WasmSdk { .inner_sdk() .token_unfreeze_identity(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to unfreeze tokens: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to unfreeze tokens", e))?; Ok(TokenUnfreezeResultWasm::from_result(result, contract_id)) } @@ -1497,9 +1497,7 @@ impl WasmSdk { .inner_sdk() .token_destroy_frozen_funds(builder, &identity_key, &signer) .await - .map_err(|e| { - WasmSdkError::generic(format!("Failed to destroy frozen tokens: {}", e)) - })?; + .map_err(|e| WasmSdkError::with_context("Failed to destroy frozen tokens", e))?; Ok(TokenDestroyFrozenResultWasm::from_result( result, @@ -1715,9 +1713,7 @@ impl WasmSdk { .inner_sdk() .token_emergency_action(builder, &identity_key, &signer) .await - .map_err(|e| { - WasmSdkError::generic(format!("Failed to perform emergency action: {}", e)) - })?; + .map_err(|e| WasmSdkError::with_context("Failed to perform emergency action", e))?; Ok(TokenEmergencyActionResultWasm::from_result( result, @@ -1911,7 +1907,7 @@ impl WasmSdk { .inner_sdk() .token_claim(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to claim tokens: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to claim tokens", e))?; Ok(TokenClaimResultWasm::from_result(result, contract_id)) } @@ -2297,7 +2293,7 @@ impl WasmSdk { .inner_sdk() .token_set_price_for_direct_purchase(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to set token price: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to set token price", e))?; Ok(TokenSetPriceResultWasm::from_result(result, contract_id)) } @@ -2509,7 +2505,7 @@ impl WasmSdk { .inner_sdk() .token_purchase(builder, &identity_key, &signer) .await - .map_err(|e| WasmSdkError::generic(format!("Failed to purchase tokens: {}", e)))?; + .map_err(|e| WasmSdkError::with_context("Failed to purchase tokens", e))?; Ok(TokenDirectPurchaseResultWasm::from_result( result, @@ -2712,9 +2708,7 @@ impl WasmSdk { .inner_sdk() .token_update_contract_token_configuration(builder, &identity_key, &signer) .await - .map_err(|e| { - WasmSdkError::generic(format!("Failed to update token configuration: {}", e)) - })?; + .map_err(|e| WasmSdkError::with_context("Failed to update token configuration", e))?; Ok(TokenConfigUpdateResultWasm::from_result( result, From 8c29a53ca26fbd8d11ee7a848d323d5fd3030b02 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 13:08:48 +0700 Subject: [PATCH 075/113] feat(platform)!: generatedFrom, string properties the platform generates with a system function (PV14) (#5099) Co-authored-by: Claude Opus 5.5 --- book/src/SUMMARY.md | 1 + book/src/contract-keywords.md | 3 + book/src/contract-keywords/generated-from.md | 98 ++ .../src/contract-keywords/property-schemas.md | 2 +- book/src/contract-keywords/transient.md | 3 +- book/src/data-model/documents.md | 27 + book/src/error-handling/error-codes.md | 2 +- .../document/v3/document-meta.json | 52 +- .../document_type/accessors/mod.rs | 26 +- .../document_type/accessors/v2/mod.rs | 10 +- .../class_methods/try_from_schema/mod.rs | 271 +++- .../v3/generated_from_tests.rs | 1106 +++++++++++++++++ .../class_methods/try_from_schema/v3/mod.rs | 8 +- .../index/extract_contested_values/mod.rs | 1 + .../document_type/index/preallocation.rs | 3 + .../document_type/methods/mod.rs | 261 +++- .../methods/validate_update/common/mod.rs | 109 ++ .../methods/validate_update/v1/mod.rs | 123 ++ .../src/data_contract/document_type/mod.rs | 10 + .../property/generated_from/mod.rs | 87 ++ .../generated_from/system_function/mod.rs | 194 +++ .../system_function/string_transformations.rs | 388 ++++++ .../document_type/property/mod.rs | 33 + .../document_type/random_document.rs | 72 +- .../document_type/v0/random_document_type.rs | 2 + .../document_type/v2/accessors.rs | 6 +- .../src/data_contract/document_type/v2/mod.rs | 22 +- .../methods/validate_document/v0/mod.rs | 12 + .../src/errors/consensus/basic/basic_error.rs | 27 +- .../document_property_not_generated_error.rs | 87 ++ .../errors/consensus/basic/document/mod.rs | 2 + packages/rs-dpp/src/errors/consensus/codes.rs | 1 + .../v0/from_document.rs | 11 +- .../document_create_transition/v0/mod.rs | 13 +- .../v0/from_document.rs | 7 + .../v0/from_document.rs | 9 + .../document_replace_transition/v0/mod.rs | 16 +- .../batch/tests/document/generated_from.rs | 927 ++++++++++++++ .../batch/tests/document/mod.rs | 1 + .../batch/transformer/v0/mod.rs | 3 +- .../src/query/index_only_synthesis.rs | 11 +- .../v0/transformer.rs | 12 +- .../transformer.rs | 4 +- .../v0/transformer.rs | 21 +- .../transformer.rs | 3 + .../v0/transformer.rs | 16 +- .../src/rules/rule_set.rs | 44 + .../dpp_versions/dpp_contract_versions/mod.rs | 18 + .../dpp_versions/dpp_contract_versions/v1.rs | 3 + .../dpp_versions/dpp_contract_versions/v2.rs | 3 + .../dpp_versions/dpp_contract_versions/v3.rs | 3 + .../dpp_versions/dpp_contract_versions/v4.rs | 3 + .../dpp_versions/dpp_contract_versions/v5.rs | 3 + .../dpp_versions/dpp_contract_versions/v6.rs | 3 + .../rs-platform-version/src/version/v14.rs | 43 + .../src/data_contract/property_constraints.rs | 86 +- .../src/platform/documents/contest_fund.rs | 9 +- .../src/errors/consensus/consensus_error.rs | 5 +- packages/wasm-dpp2/src/consensus_error.rs | 75 ++ .../document_type_property_constraints.rs | 13 +- 60 files changed, 4341 insertions(+), 73 deletions(-) create mode 100644 book/src/contract-keywords/generated-from.md create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_tests.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/property/generated_from/mod.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/mod.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/string_transformations.rs create mode 100644 packages/rs-dpp/src/errors/consensus/basic/document/document_property_not_generated_error.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from.rs diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index e7afab4aa5c..8e83c3ffb7f 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -84,6 +84,7 @@ - [Writer and Creator References](contract-keywords/owner-refers-to.md) - [distinctFrom](contract-keywords/distinct-from.md) - [maxBytes](contract-keywords/max-bytes.md) +- [generatedFrom](contract-keywords/generated-from.md) - [encryptedFor](contract-keywords/encrypted-for.md) - [propertyConstraints](contract-keywords/property-constraints.md) - [Token Costs (tokenCost)](contract-keywords/token-cost.md) diff --git a/book/src/contract-keywords.md b/book/src/contract-keywords.md index d618663e2ec..82d27bd2ddd 100644 --- a/book/src/contract-keywords.md +++ b/book/src/contract-keywords.md @@ -171,6 +171,9 @@ Every key a contract can write, grouped by where it goes. **Since** is the proto | `encryptedFor.recipient` | identifier property path or `"$ownerId"` | The identity the value is encrypted to. | 14 | [encryptedFor](contract-keywords/encrypted-for.md#example) | | `encryptedFor.recipientKey`, `.senderKey` | integer property paths | The properties holding the recipient's and the sender's key ids. | 14 | [encryptedFor](contract-keywords/encrypted-for.md#example) | | `encryptedFor.scheme` | `"ecdh-secp256k1-aes256-cbc"` | How the ciphertext is made. | 14 | [The scheme](contract-keywords/encrypted-for.md#the-scheme) | +| `generatedFrom` | `{ "function", "params" }` | The platform generates the string from other properties of the document; on arrival when a document leaves it out. | 14 | [generatedFrom](contract-keywords/generated-from.md) · [internals](data-model/documents.md#generated-properties-generatedfrom) | +| `generatedFrom.function` | `"sys.stringTransformations.homographSafeASCII"` | The system function that generates the value: `sys.stringTransformations.` `lowercase`, `uppercase`, `capitalize`, `camelCase`, `snakeCase` or `homographSafeASCII`. | 14 | [Functions](contract-keywords/generated-from.md#functions) | +| `generatedFrom.params` | property paths | The properties the function reads, in order. | 14 | [Params](contract-keywords/generated-from.md#params) | | `refersTo` | a declaration | What an identifier points at, checked when a document is written. See the [keys](#refersto). | 14 | [References](contract-keywords/refers-to.md) · [internals](data-model/documents.md#document-references-refersto) | A typed array's element (`items`) takes `type`, `enum`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`, `minLength`, `maxLength`, `pattern`, `format`, `minItems` and `maxItems` (bytes of a byte array element), `byteArray`, `contentMediaType`, `maxBytes`, `distinctFrom`, `refersTo`, `$comment` and `description`. It takes no `position`, `const`, `uniqueItems` or `examples`. diff --git a/book/src/contract-keywords/generated-from.md b/book/src/contract-keywords/generated-from.md new file mode 100644 index 00000000000..766537a582c --- /dev/null +++ b/book/src/contract-keywords/generated-from.md @@ -0,0 +1,98 @@ +# generatedFrom + +`generatedFrom` says that the platform generates a string property's value with a system function of other properties of the same document, its params. The functions change the case of a string (`lowercase`, `uppercase`, `capitalize`, `camelCase`, `snakeCase`), or fold a name so that names differing only by case or by look-alike characters read the same (`homographSafeASCII`): reach for that one when a unique index must treat `Bob`, `BOB` and `B0B` as one name. A client may leave the property out, and the platform generates it when the document arrives; a client that sends it has it checked. The check reads only the document being written, so it costs no state reads. + +| | | +|---|---| +| **Where** | A string property, at the top level or inside an object. Not on a typed array or its `items`, and not beside `$ref` | +| **Value** | `{ "function": , "params": [, ...] }`: a system function, and as many params as it takes, each the dotted path of a property of the same document type (`"profile.display"` for a nested one), 1 to 256 characters (ASCII, as property names are, so as many bytes) | +| **Default** | Absent: no rule | +| **Since** | protocol version 14 | +| **On update** | Fixed: adding, removing or changing it is refused (`IncompatibleDocumentTypeSchemaError`, 10246); a property an update adds may declare it only when one of its params is new too (`DocumentTypeUpdateError`, 40212) | +| **Errors** | `DocumentPropertyNotGeneratedError` (10424) on a document; at registration `JsonSchemaError` (10101) or `InvalidContractStructure` (10231); on update `IncompatibleDocumentTypeSchemaError` (10246) or `DocumentTypeUpdateError` (40212) | + +## Example + +```json +"handle": { + "type": "object", + "indices": [ + { "name": "byNormalizedLabel", "properties": [{ "normalizedLabel": "asc" }], "unique": true } + ], + "properties": { + "label": { + "type": "string", "pattern": "^[a-zA-Z0-9-]{3,63}$", "maxLength": 63, + "position": 0 + }, + "normalizedLabel": { + "type": "string", "maxLength": 63, + "generatedFrom": { + "function": "sys.stringTransformations.homographSafeASCII", + "params": ["label"] + }, + "position": 1 + } + }, + "required": ["label", "normalizedLabel"], + "additionalProperties": false +} +``` + +A client creates `{ "label": "Bob" }` and the stored document holds `{ "label": "Bob", "normalizedLabel": "b0b" }`. A second handle `{ "label": "B0B" }` generates the same `b0b` and is refused by the unique index. A client that sends `{ "label": "Bob", "normalizedLabel": "b0b" }` gets the same result; one that sends `"normalizedLabel": "bob"` is refused with `DocumentPropertyNotGeneratedError`. + +The generated property needs no `pattern` of its own. Every value it can hold is what the function generates from params that passed their own patterns: here `label` admits ASCII letters, digits and `-`, so `normalizedLabel` can only ever hold `a` to `z` without `i`, `l` and `o`, digits and `-`. It keeps `maxLength` because it is indexed, and an indexed string declares one of at most 63. + +This is the rule the DPNS `domain` type's data trigger checks today for `normalizedLabel` and `normalizedParentDomainName`, written into the schema. + +## Functions + +Functions are system functions, built into the platform and named under `sys.`; any other name is refused at registration. The prefix keeps them apart from functions a contract may bring in a later protocol version. They are grouped in namespaces, and each one declares how many params it takes. The `sys.stringTransformations` functions each take one string: + +| Function | Returns | Example | +|---|---|---| +| `sys.stringTransformations.lowercase` | The string with `A` to `Z` lowercased | `Hello World` to `hello world` | +| `sys.stringTransformations.uppercase` | The string with `a` to `z` uppercased | `Hello World` to `HELLO WORLD` | +| `sys.stringTransformations.capitalize` | The first character uppercased and every other lowercased | `hELLO wORLD` to `Hello world` | +| `sys.stringTransformations.camelCase` | The words joined, the first lowercased and every later one capitalized | `Hello World`, `hello_world` and `HelloWorld` to `helloWorld` | +| `sys.stringTransformations.snakeCase` | The words lowercased and joined with `_` | `Hello World`, `helloWorld` and `hello-world` to `hello_world` | +| `sys.stringTransformations.homographSafeASCII` | The string with `A` to `Z` lowercased, then `o` turned into `0` and `i` and `l` into `1` | `Lil-Olive` to `111-011ve` | + +The string transformations change ASCII characters only and keep every other character as it is (`Olé` becomes `01é` under `homographSafeASCII`, `OLÉ` becomes `olÉ` under `lowercase`). They use no Unicode tables: Unicode case mappings change between releases of the tools a node is built with, and two nodes that lowercased a character differently would disagree about a document. On ASCII, `homographSafeASCII` is exactly what the DPNS trigger computes. + +`camelCase` and `snakeCase` split the string into words. Every ASCII character that is neither a letter nor a digit (a space, `-`, `_`, `.` and so on) separates words and is dropped. A word also starts at an ASCII uppercase letter that follows anything other than another ASCII uppercase letter (`helloWorld` is `hello`, `World`), or that follows one and is followed by an ASCII lowercase letter (`XMLHttpRequest` is `XML`, `Http`, `Request`, so it becomes `xmlHttpRequest` or `xml_http_request`). Characters outside ASCII belong to the word they are in. Every transformation gives back its own output unchanged. + +`lowercase`, `uppercase`, `capitalize` and `homographSafeASCII` keep the length of the value, and `camelCase` never lengthens it, but `snakeCase` can: `aBcDeF` becomes `a_bc_de_f`. Give a generated property a `maxLength` that admits what its function can generate from its params' longest value. + +A function refuses nothing. Which characters a value may hold is the job of each param's `pattern`: `homographSafeASCII` resists look-alike names only where that pattern admits ASCII alone, as DPNS's does. A contract that admits other scripts can hold two names that look alike but generate different values. + +## Params + +For now a param is always a property of the same document, written as its dotted path. The form leaves room to add, in a later protocol version, literals (`{ "const": ... }`), system values such as `"$ownerId"` and nested calls, without changing what parses today. + +## How it works + +- **Generated on arrival.** When a document create or replace, or the values of an [index-only](index-only.md) delete, leaves the property out and supplies every param, the platform writes the generated value into the document before anything reads it: contest detection, the schema validation, the indexes and the stored document all see it. A property the document sends is left as sent. A generated value then goes through the property's own schema like a sent one, so any bound it declares, such as `maxLength`, should admit every value the function can generate from its params. +- **Checked after the JSON schema.** Wherever a document's properties are validated, on every create and replace included and in a client that validates a document before sending it, the property must hold what the function generates from its params, and must be absent when a param is. A document that repeats a key in an object on the way to the property or to a param is refused too, since the value it holds there would be ambiguous. A property that breaks this refuses the transition with `DocumentPropertyNotGeneratedError` (10424), which names the document type, the property, the function and its params. A schema error on any of the values is reported first. +- **Absent params.** A property one of whose params is absent must be absent too. To make the params required in effect, list the generated property in `required`: a document without them then fails the schema. +- **Replace.** A replace is judged on the whole new document. Leave the property out to have it generated from the new params; a stale value sent with changed params is refused. +- **Transfers, purchases and deletes by id** do not change the data and are not judged. An index-only delete is: its values are generated and checked like a create's, so a stale value, or one without its params, refuses it. + +The SDK's transition builders generate the property from the document's params, replacing any value the document holds and leaving it out when a param is absent, so a transition built from a document carries the value the platform would generate, and contest detection sees it. A document fetched, edited and sent back through them therefore carries the value of its new params, not the stale one. The property-constraint pre-checks of the JavaScript and FFI SDKs judge the document the same way. A client that validates a document it built, before building a transition, should generate it first (in Rust, `DocumentTypeBasicMethods::regenerate_generated_properties`) or set the value; otherwise the local check reports the property missing. The proof a client verifies after a create or replace is checked against the document with the generated value, as the platform stored it. + +## Rules at registration + +- The keyword is allowed only on a string property, and not beside `$ref`, whose definition replaces every keyword written next to it (declare it in the definition instead). On any other property, a typed array and its `items` included, the meta-schema refuses it (`JsonSchemaError`, 10101). +- `function` must name a system function, and `params` must list 1 to 16 params, as many as the function takes. The meta-schema refuses an unknown function or an empty or overlong list (`JsonSchemaError`, 10101). +- Every param must name another string property of the same document type (not an object, not a system property, not the declaring property), and that property may not be generated itself. +- Neither the declaring property nor a param may be [transient](transient.md) or sit inside a transient object: a transient value is never stored. +- Every param must sit inside every object that holds the declaring property: a top-level property may take any param, but `profile.normalized` must take params inside `profile`. A document that supplies the params then always holds the object the platform writes the value into. +- On a contract update, a new property may declare `generatedFrom` only when one of its params is new too. Documents stored before the update were never generated, so a new generated property whose params all existed is refused (`DocumentTypeUpdateError`, 40212). + +The meta-schema refuses the shape errors of the first two rules with `JsonSchemaError` (10101). The parser refuses a function with the wrong number of params, and a declaration that breaks the param rules (the third to fifth), with `InvalidContractStructure` (10231). The update rule refuses with `DocumentTypeUpdateError` (40212). + +## See also + +- [Generated Properties](../data-model/documents.md#generated-properties-generatedfrom), the deep dive +- [Property Schemas](property-schemas.md), for `pattern` and `required` +- [Indexes (indices)](indexes.md), for the unique index that usually reads the generated property +- [Contract Keywords overview](../contract-keywords.md), for the conventions of these tables diff --git a/book/src/contract-keywords/property-schemas.md b/book/src/contract-keywords/property-schemas.md index 1f89c6f3ef2..fb6a57ff49c 100644 --- a/book/src/contract-keywords/property-schemas.md +++ b/book/src/contract-keywords/property-schemas.md @@ -1,6 +1,6 @@ # Property Schemas -Each entry of a document type's `properties` is a property schema: JSON Schema (draft 2020-12), limited to the keywords in this chapter, plus three Platform keywords that say how a value is stored (`position`, `byteArray` and `contentMediaType`). The schema is checked when the contract is registered, and every created or replaced document is validated against it. Platform keywords with more to them, such as [`maxBytes`](max-bytes.md), [`refersTo`](refers-to.md), [`distinctFrom`](distinct-from.md), [`encryptedFor`](encrypted-for.md), [`requiredSince`](required-since.md) and a typed array's [`items`](typed-arrays.md), have chapters of their own. +Each entry of a document type's `properties` is a property schema: JSON Schema (draft 2020-12), limited to the keywords in this chapter, plus three Platform keywords that say how a value is stored (`position`, `byteArray` and `contentMediaType`). The schema is checked when the contract is registered, and every created or replaced document is validated against it. Platform keywords with more to them, such as [`maxBytes`](max-bytes.md), [`refersTo`](refers-to.md), [`distinctFrom`](distinct-from.md), [`encryptedFor`](encrypted-for.md), [`generatedFrom`](generated-from.md), [`requiredSince`](required-since.md) and a typed array's [`items`](typed-arrays.md), have chapters of their own. | Keyword | Applies to | On update | |---|---|---| diff --git a/book/src/contract-keywords/transient.md b/book/src/contract-keywords/transient.md index ec5a439fc0b..c4f1fbb6806 100644 --- a/book/src/contract-keywords/transient.md +++ b/book/src/contract-keywords/transient.md @@ -55,6 +55,7 @@ From protocol version 14, a document type is refused (`InvalidContractStructure` - an index reads a transient property, or a property inside a transient object. Every stored document would lack the value, so the index could find nothing and a unique index would enforce nothing; - a reference reads one where the value would have to be stored: a `refersTo` lookup may not read one on either side, a `propertyAgreement` may not name one on its referenced side, a key reference may not store its key id with a transient identity, and a `listElement` reference may not find its list's document through one. The referring side of a `propertyAgreement` may be transient: it is checked on the transition; - `encryptedFor` names one as its recipient or key id; +- `generatedFrom` sits on one or names one as a param; - `immutable` lists one. A transient property is always absent from the stored document, so every replace that carries it would count as changing it; - a `propertyConstraints` rule reads one; - the type is `indexOnly` and declares any transient property. @@ -68,4 +69,4 @@ On a contract update the list is fixed. It decides which values stored documents - [Transient Properties](../data-model/documents.md#transient-properties) in the Documents chapter - [Document Serialization](../serialization/document-serialization.md#user-defined-properties), for the presence byte a transient property always takes - [Document Shape](document-shape.md#required), for `required` -- [References (refersTo)](refers-to.md), [encryptedFor](encrypted-for.md), [Mutability](mutability.md), [propertyConstraints](property-constraints.md) and [Index-Only Types](index-only.md), whose rules refuse transient properties +- [References (refersTo)](refers-to.md), [encryptedFor](encrypted-for.md), [generatedFrom](generated-from.md), [Mutability](mutability.md), [propertyConstraints](property-constraints.md) and [Index-Only Types](index-only.md), whose rules refuse transient properties diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 80df3b5f9f8..4f6e20e18df 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -678,6 +678,33 @@ The parser (generation 3, meta-schema v3, `apply_max_bytes`) folds the bound int The check runs where the JSON schema validation of a document's properties runs, `DataContract::validate_document_properties`, right after it: on every document create and replace, and in every client that validates a document before sending it. A longer string is refused with `DocumentPropertyMaxBytesExceededError` (basic code 10421), which names the property (`tags[2]` for an element) and both lengths. The document validation (version 0, extended in place) is inert before protocol version 14, where no string carries a byte cap and the `validate_max_bytes` method slot is `None`. In Rust the check is `DocumentTypeBasicMethods::validate_max_bytes_properties()`; in JavaScript the error reaches an app as `DocumentMaxBytesErrorCode.MaxBytesExceeded`. +## Generated Properties (`generatedFrom`) + +Protocol version 14 adds the property keyword `generatedFrom`: the platform generates a string property's value with a system function of other properties of the same document, its params. The first system functions change the case of a string (`lowercase`, `uppercase`, `capitalize`, `camelCase`, `snakeCase`) or apply the DPNS `domain` rule (`normalizedLabel` is `label` lowercased, with `o`, `i` and `l` replaced by `0`, `1` and `1`), so any contract can build a unique index that treats look-alike names as one. + +```json +"normalizedLabel": { + "type": "string", "maxLength": 63, + "generatedFrom": { + "function": "sys.stringTransformations.homographSafeASCII", + "params": ["label"] + }, + "position": 1 +} +``` + +The functions are a closed list of system functions (`SystemFunction`), named under `sys.` so that functions a contract may bring later can be told apart by name; each declares how many params it takes. `SystemFunction` is an enum of namespaces, each an enum of its own in a module of its own: `SystemFunction::StringTransformation(StringTransformation)` for `sys.stringTransformations`, in `system_function::string_transformations`, whose six functions each take one string. `lowercase`, `uppercase` and `capitalize` change the case of ASCII letters; `camelCase` and `snakeCase` split the string into words (at every ASCII character that is neither a letter nor a digit, and before an ASCII uppercase letter that starts a word) and join them as `helloWorld` or `hello_world`; `homographSafeASCII` maps `A` to `Z` to lowercase, then `o` to `0` and `i` and `l` to `1`. Every one changes ASCII characters only and keeps every other character, with no Unicode table, because Unicode case mappings differ between releases of the standard library, and two nodes must never generate different values; every one gives back its own output unchanged, and a test pins that the meta-schema lists exactly the functions the parser knows. On ASCII `homographSafeASCII` equals `convert_to_homograph_safe_chars`, the function the DPNS trigger uses; a test pins that over every ASCII character and over strings from the DPNS alphabets. It refuses nothing: the characters a value may hold are each param's `pattern`'s to decide, and the generated property needs no pattern of its own, since every value it holds is generated from params that passed theirs. A param is a property path for now (`GenerationParam::Property`); literals, system values and nested calls can be added later without changing what parses today. + +The parser (generation 3, meta-schema v3, `apply_generated_from`) reads the declaration onto `DocumentProperty::generated_from` (`Option`, absent on every property parsed before protocol version 14) and checks that `params` lists as many params as the function takes. `validate_generated_from_declarations` checks every param against the other properties on every parse: another string property, not generated itself, not transient nor inside a transient object (and neither is the declaring property), and inside every object that holds the declaring property. The meta-schema refuses the keyword beside `$ref`, whose definition replaces every keyword written next to it. On contract update a changed, added or removed `generatedFrom` is an incompatible schema change, and a property the update adds may declare it only when one of its params is new too (`validate_update` 1 refuses one whose params all existed with `DocumentTypeUpdateError`, 40212: documents stored before the update were never generated). The declaring paths and their declarations are cached on the document type (`DocumentTypeV2Getters::generated_from_fields`), so a write to a type without declarations pays nothing. + +Three methods do the work at write time: + +- `DocumentTypeBasicMethods::fill_generated_properties` writes every declared property the document leaves out while supplying every param. The action transformers of document create, replace and index-only delete call it first, before the contest resolution and every check read the data, so the stored document, its indexes and its contest all hold the generated value. `Document::try_from_create_transition` and `try_from_replace_transition` call it too, and so does `index_only_transition_entry_path_query`, the one builder the prover and the verifier share for index-only entries, so proofs are built and checked against the document the platform stored. +- `DocumentTypeBasicMethods::regenerate_generated_properties` is the client-side twin: it sets every declared property to what its params generate, replacing a value the document holds and removing it when a param is absent. The transition builders (`from_document` of create, replace and index-only delete), the SDK's contest fund lookup and the property-constraint pre-checks of the JavaScript and FFI SDKs call it, so a document fetched, edited and sent back carries the value of its new params rather than the stale one the platform would refuse, and its contest is detected from it. Random documents call it too, after drawing the params of every generated property they drew. +- `DocumentTypeBasicMethods::validate_generated_from_properties` runs in `DataContract::validate_document_properties`, after the JSON schema and `maxBytes`: a declared property must equal what its function generates from its params, and be absent when a param is. A document that repeats a key in an object on the way to the property or to a param is refused too: the schema validation and the stored document keep the last of repeated keys, where the path reads the platform generates from find the first. A property that does not pass refuses the write with `DocumentPropertyNotGeneratedError` (basic code 10424). A document that arrived has been generated, so on the platform the check only refuses a value the client sent, a value sent without its params, or a repeated key. + +Every call site was extended in place and is inert before protocol version 14: the `apply_generated_from`, `fill_generated_properties` and `validate_generated_from` slots are `None` there, and the meta-schemas refuse the keyword. In JavaScript the error reaches an app as `DocumentGeneratedFromErrorCode.DocumentPropertyNotGenerated`. + ## Property Constraints (`propertyConstraints`) Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named rules the properties of every created or replaced document must meet, where JSON Schema can only bound one property at a time. Each rule is a condition: a comparison of two integer expressions, a test of whether an integer expression takes one of listed values, a comparison of a string property with string constants, a test of whether the document holds a property, or `anyOf`, `allOf` or `not` over conditions. diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index 7e76f7cd311..57a1d90d4cb 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -53,7 +53,7 @@ Error codes are organized into ranges that correspond to error categories and su | 10200-10277 | Data Contract | `DataContractMaxDepthExceedError` (10200), `DuplicateIndexError` (10201), `InvalidDataContractIdError` (10204), `DataContractInvalidRequiredFieldsUpdateError` (10276), `PreProgrammedDistributionAmountOverLimitError` (10277) | | 10350-10359 | Groups | `GroupPositionDoesNotExistError` (10350), `GroupExceedsMaxMembersError` (10354) | | 10360-10367 | Contract Groups | `ContractGroupMembershipsOverLimitError` (10360), `InvalidContractGroupAdminsError` (10364), `InvalidContractGroupDescriptionLengthError` (10367); 10365 unassigned | -| 10400-10422 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401), `DocumentPropertyNotDistinctError` (10419), `InvalidEncryptedPropertyShapeError` (10420), `DocumentPropertyMaxBytesExceededError` (10421), `DocumentPropertyConstraintViolatedError` (10422) | +| 10400-10424 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401), `DocumentPropertyNotDistinctError` (10419), `InvalidEncryptedPropertyShapeError` (10420), `DocumentPropertyMaxBytesExceededError` (10421), `DocumentPropertyConstraintViolatedError` (10422), `DocumentPropertyNotGeneratedError` (10424) | | 10450-10460 | Tokens | `InvalidTokenIdError` (10450), `TokenTransferToOurselfError` (10456) | | 10500-10535 | Identity | `DuplicatedIdentityPublicKeyBasicError` (10500), `InvalidIdentityPublicKeyDataError` (10511) | | 10600-10603 | State Transition | `InvalidStateTransitionTypeError` (10600), `StateTransitionMaxSizeExceededError` (10602) | diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 6548aa57dfa..dda80481298 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's properties, comparisons between integer expressions, of a string or identifier property with constants or with another property of its kind, in (value membership) and present or absent tests combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's properties, comparisons between integer expressions, of a string or identifier property with constants or with another property of its kind, in (value membership) and present or absent tests combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the generatedFrom property keyword (a string property whose value a built-in function generates from other properties of the same document, on arrival when a document leaves it out), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "referenceOperands": { @@ -930,6 +930,38 @@ "minimum": 1, "maximum": 65535 }, + "generatedFrom": { + "description": "Only on string properties: the platform generates the property's value with function from params, other properties of the same document. function names a system function, under sys.; the sys.stringTransformations functions take one string and change ASCII characters only, keeping every other character as it is: lowercase and uppercase change the case of A to Z; capitalize makes the first character uppercase and the rest lowercase; camelCase and snakeCase split the string into words (at every ASCII character that is neither a letter nor a digit, and before an ASCII uppercase letter that starts a word, as in helloWorld or XMLHttp) and join them as helloWorld or hello_world; homographSafeASCII lowercases, then turns o into 0 and i and l into 1 (the DPNS label normalization over ASCII, so it only resists homographs where the param's pattern admits ASCII alone). params lists, in order, as many properties as the function takes, each the dotted path of a property of the same document type. A function never refuses a value: which characters a param may hold is the param's pattern's job, and the generated property needs no pattern of its own. When a created or replaced document (or the values of an indexOnly delete) leaves the property out and supplies every param, the platform generates the value on arrival, before anything reads the document; a value the document supplies must equal the generated one, and the property must be absent when a param is. Checked wherever a document's properties are validated, every create and replace included, after the JSON schema (DocumentPropertyNotGeneratedError, 10424). At contract registration every param must be another string property of the document type that is not generated itself, neither the property nor a param may be transient or inside a transient object, and every param must sit inside every object that holds this property (a top-level property may take any param), so a document supplying the params always holds the object the value is written into. Not allowed on the items of a typed array, nor beside $ref, whose definition replaces every keyword written next to it: declare it in the definition. Adding, removing or changing it is an incompatible schema change on update, and a property an update adds may declare it only when one of its params is new too (documents stored before the update were never generated). Available from protocol version 14.", + "type": "object", + "properties": { + "function": { + "type": "string", + "enum": [ + "sys.stringTransformations.camelCase", + "sys.stringTransformations.capitalize", + "sys.stringTransformations.homographSafeASCII", + "sys.stringTransformations.lowercase", + "sys.stringTransformations.snakeCase", + "sys.stringTransformations.uppercase" + ] + }, + "params": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + }, + "required": [ + "function", + "params" + ], + "additionalProperties": false + }, "requiredSince": { "type": "integer", "minimum": 1, @@ -1038,6 +1070,22 @@ "type" ] }, + "generatedFrom": { + "description": "generatedFrom is only allowed on string properties, and not beside $ref, whose definition replaces every keyword written next to it", + "properties": { + "type": { + "const": "string" + } + }, + "required": [ + "type" + ], + "not": { + "required": [ + "$ref" + ] + } + }, "refersTo": { "description": "refersTo is only allowed on identifier properties, except an identityPublicKey reference with identityProperty, which sits on the key id property: an integer with minimum 0 and maximum 4294967295, the range of a key id", "if": { @@ -2171,7 +2219,7 @@ "items": { "type": "string" }, - "description": "Names of top-level properties whose values are validated on the transition but never stored: a create, and from protocol version 14 a replace, drops them before the document is written. From protocol version 14 every entry must name a top-level property (list the object around a nested one); no index may read a transient property or one inside a transient object; a refersTo lookup may not read one on either side, a propertyAgreement may not name one on its referenced side, and a stored key id may not pair with a transient identity; encryptedFor may not name one. Adding, removing or changing an entry on contract update is an incompatible schema change." + "description": "Names of top-level properties whose values are validated on the transition but never stored: a create, and from protocol version 14 a replace, drops them before the document is written. From protocol version 14 every entry must name a top-level property (list the object around a nested one); no index may read a transient property or one inside a transient object; a refersTo lookup may not read one on either side, a propertyAgreement may not name one on its referenced side, and a stored key id may not pair with a transient identity; encryptedFor may not name one; generatedFrom may neither name one nor sit on one. Adding, removing or changing an entry on contract update is an incompatible schema change." }, "immutable": { "type": "array", diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs index 13996994e86..abe6c044b05 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs @@ -6,7 +6,7 @@ use crate::data_contract::document_type::action_fees::DocumentActionFees; use crate::data_contract::document_type::index::Index; use crate::data_contract::document_type::index_level::IndexLevel; use crate::data_contract::document_type::property::{ - DocumentProperty, DocumentPropertyReferenceTarget, + DocumentProperty, DocumentPropertyReferenceTarget, GeneratedFrom, }; use crate::data_contract::document_type::{DocumentType, DocumentTypeMutRef, DocumentTypeRef}; @@ -1043,6 +1043,14 @@ impl DocumentTypeV2Getters for DocumentType { } } + fn generated_from_fields(&self) -> &[(String, GeneratedFrom)] { + match self { + DocumentType::V0(_) => &[], + DocumentType::V1(_) => &[], + DocumentType::V2(v2) => v2.generated_from_fields(), + } + } + fn immutable_fields(&self) -> &BTreeSet { match self { DocumentType::V0(_) => &NO_IMMUTABLE_FIELDS, @@ -1216,6 +1224,14 @@ impl DocumentTypeV2Getters for DocumentTypeRef<'_> { } } + fn generated_from_fields(&self) -> &[(String, GeneratedFrom)] { + match self { + DocumentTypeRef::V0(_) => &[], + DocumentTypeRef::V1(_) => &[], + DocumentTypeRef::V2(v2) => v2.generated_from_fields(), + } + } + fn immutable_fields(&self) -> &BTreeSet { match self { DocumentTypeRef::V0(_) => &NO_IMMUTABLE_FIELDS, @@ -1355,6 +1371,14 @@ impl DocumentTypeV2Getters for DocumentTypeMutRef<'_> { } } + fn generated_from_fields(&self) -> &[(String, GeneratedFrom)] { + match self { + DocumentTypeMutRef::V0(_) => &[], + DocumentTypeMutRef::V1(_) => &[], + DocumentTypeMutRef::V2(v2) => v2.generated_from_fields(), + } + } + fn immutable_fields(&self) -> &BTreeSet { match self { DocumentTypeMutRef::V0(_) => &NO_IMMUTABLE_FIELDS, diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs index 388863619d3..c5977e82735 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs @@ -1,5 +1,7 @@ use crate::data_contract::document_type::action_fees::DocumentActionFees; -use crate::data_contract::document_type::property::DocumentPropertyReferenceTarget; +use crate::data_contract::document_type::property::{ + DocumentPropertyReferenceTarget, GeneratedFrom, +}; use crate::data_contract::document_type::property_constraints::PropertyConstraint; use std::collections::{BTreeMap, BTreeSet}; @@ -80,6 +82,12 @@ pub trait DocumentTypeV2Getters { /// predate the keyword. fn distinct_from_fields(&self) -> &[String]; + /// The dotted path of every property that declares `generatedFrom` + /// (protocol version 14) with its declaration, in schema order, so a + /// document write visits only them. Empty on generations that predate the + /// keyword. + fn generated_from_fields(&self) -> &[(String, GeneratedFrom)]; + /// The subset of [`Self::immutable_fields`] a replace may still set while /// the stored document has no value for them (the /// `immutableAllowSetting` keyword, protocol version 14). Once present diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 51e27fb0649..8059967bce2 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -20,9 +20,9 @@ use crate::data_contract::document_type::{ ContractReferenceRequirements, DistinctFrom, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentPropertyTypeParsingOptions, DocumentReferenceLookup, DocumentType, DocumentTypeRef, EncryptedFor, EncryptedForRecipient, EncryptionScheme, - IdentityKeyReferenceRequirements, KeyIdReference, KeyReferenceIdentityProperty, - ListElementReference, LookupKeySource, ReferenceCombinator, ReferenceOperands, - COMBINABLE_REFERENCE_TARGET_TYPES, + GeneratedFrom, GenerationParam, IdentityKeyReferenceRequirements, KeyIdReference, + KeyReferenceIdentityProperty, ListElementReference, LookupKeySource, ReferenceCombinator, + ReferenceOperands, SystemFunction, COMBINABLE_REFERENCE_TARGET_TYPES, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; @@ -223,6 +223,8 @@ fn insert_values( apply_distinct_from(&inner_properties, &property_type, platform_version)?; let encrypted_for = apply_encrypted_for(&inner_properties, &property_type, platform_version)?; + let generated_from = + apply_generated_from(&inner_properties, &property_type, platform_version)?; document_properties.insert( prefixed_property_key, DocumentProperty { @@ -232,6 +234,7 @@ fn insert_values( required_since, distinct_from, encrypted_for, + generated_from, }, ); } @@ -362,6 +365,7 @@ fn insert_values_nested( let property_type = apply_max_bytes(&inner_properties, property_type, platform_version)?; let distinct_from = apply_distinct_from(&inner_properties, &property_type, platform_version)?; let encrypted_for = apply_encrypted_for(&inner_properties, &property_type, platform_version)?; + let generated_from = apply_generated_from(&inner_properties, &property_type, platform_version)?; document_properties.insert( property_key, @@ -372,6 +376,7 @@ fn insert_values_nested( required_since, distinct_from, encrypted_for, + generated_from, }, ); @@ -515,13 +520,7 @@ fn validate_distinct_from_targets_v0( ))); } let Some(target_property) = flattened_properties.get(target) else { - // Objects are not in the flattened map, only their members are - let names_an_object = flattened_properties.keys().any(|key| { - key.len() > target.len() - && key.starts_with(target) - && key.as_bytes()[target.len()] == b'.' - }); - if names_an_object { + if names_an_object(flattened_properties, target) { return Err(DataContractError::InvalidContractStructure(format!( "document type \"{document_type_name}\" property \"{path}\" declares distinctFrom \ \"{target}\", which is an object, not an identifier property: name one of \ @@ -546,6 +545,15 @@ fn validate_distinct_from_targets_v0( Ok(()) } +/// Whether `path`, missing from the flattened map, names an object of the +/// document type: objects are not in the flattened map, only their members are. +fn names_an_object(flattened_properties: &IndexMap, path: &str) -> bool { + flattened_properties.keys().any(|key| { + key.strip_prefix(path) + .is_some_and(|rest| rest.starts_with('.')) + }) +} + /// The value of an element keyword on the `items` of a typed array property, /// refused on the array itself: the keyword binds every element, so it belongs /// on the items. `binds` finishes the refusal ("applies to every element"). @@ -1887,6 +1895,249 @@ pub(super) fn validate_encrypted_for_declarations( Ok(()) } +/// Reads a `generatedFrom` declaration off a string property: the function +/// the platform generates the property's value with, and its parameters, +/// other properties of the same document type. Only a string property +/// carries it, the one kind the functions return: a typed array has no +/// parameters for its elements, so the keyword is refused on the array and on +/// its items alike. +/// +/// Versioned on `apply_generated_from` in the platform version's document +/// type schema versions. `None` selects the behavior of the versions that +/// predate the keyword: it is ignored entirely, so their parses stay +/// byte-for-byte identical to what they always produced. +/// +/// The parameters are checked against the rest of the document type once +/// every property is parsed, by [`validate_generated_from_declarations`]. +fn apply_generated_from( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, + platform_version: &PlatformVersion, +) -> Result, DataContractError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_generated_from + { + None => Ok(None), + Some(0) => apply_generated_from_v0(inner_properties, property_type), + Some(version) => Err(DataContractError::Unsupported(format!( + "apply_generated_from version {version} is not supported" + ))), + } +} + +fn apply_generated_from_v0( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, +) -> Result, DataContractError> { + if let DocumentPropertyType::TypedArray(_) = property_type { + // On the array itself first: the shared items lookup would refuse it there as + // belonging on the items, and the keyword belongs on neither + if inner_properties.contains_key(property_names::GENERATED_FROM) + || typed_array_items_keyword( + inner_properties, + property_names::GENERATED_FROM, + "has no parameters", + )? + .is_some() + { + return Err(DataContractError::InvalidContractStructure( + "generatedFrom is only allowed on string properties, not on a typed array or \ + its items" + .to_string(), + )); + } + return Ok(None); + } + + let Some(generated_from_value) = inner_properties.get(property_names::GENERATED_FROM) else { + return Ok(None); + }; + + if !matches!(property_type, DocumentPropertyType::String(_)) { + return Err(DataContractError::InvalidContractStructure( + "generatedFrom is only allowed on string properties".to_string(), + )); + } + + let shape_error = || { + DataContractError::InvalidContractStructure( + "generatedFrom must be an object with a function (its name) and params (the paths \ + of the properties of the same document type it reads)" + .to_string(), + ) + }; + let generated_from_map = generated_from_value + .to_btree_ref_string_map() + .map_err(|_| shape_error())?; + + for key in generated_from_map.keys() { + if !matches!( + key.as_str(), + property_names::FUNCTION | property_names::PARAMS + ) { + return Err(DataContractError::InvalidContractStructure(format!( + "generatedFrom {key:?} is unknown, expected function and params" + ))); + } + } + + let function_name = generated_from_map + .get(property_names::FUNCTION) + .and_then(|value| value.as_text()) + .ok_or_else(shape_error)?; + let function = SystemFunction::from_wire_name(function_name).ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "generatedFrom function {function_name:?} is unknown, expected one of {}", + SystemFunction::ALL + .iter() + .map(|function| format!("{:?}", function.as_str())) + .collect::>() + .join(", ") + )) + })?; + + let params = generated_from_map + .get(property_names::PARAMS) + .and_then(|value| value.as_array()) + .ok_or_else(shape_error)?; + if params.len() != function.parameter_count() { + return Err(DataContractError::InvalidContractStructure(format!( + "generatedFrom function {function} takes {} parameter(s), but params lists {}", + function.parameter_count(), + params.len() + ))); + } + let params = params + .iter() + .map(|param| { + let path = param.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "generatedFrom params must be property paths (strings)".to_string(), + ) + })?; + if path.is_empty() || path.len() > MAX_PROPERTY_PATH_LENGTH { + return Err(DataContractError::InvalidContractStructure(format!( + "generatedFrom params must be between 1 and {MAX_PROPERTY_PATH_LENGTH} \ + characters" + ))); + } + if path.starts_with('$') { + return Err(DataContractError::InvalidContractStructure(format!( + "generatedFrom params must name properties of the document type, not \ + system property \"{path}\"" + ))); + } + Ok(GenerationParam::Property(path.to_string())) + }) + .collect::, _>>()?; + + Ok(Some(GeneratedFrom { function, params })) +} + +/// Checks every `generatedFrom` declaration of a document type against the +/// properties its parameters name, once all of them are parsed. Each +/// parameter: +/// +/// * must be a string property of the type (the one kind the functions read) +/// other than the declaring one, a nested one named by its dotted path, as +/// the flattened map names it; +/// * may not be generated itself, so the platform generates every left-out +/// value from values the client sent, in any order; +/// * may not be transient or sit inside a transient object, nor may the +/// declaring property: a transient parameter is dropped before storage, so a +/// replace would have to supply it again or lose the generated value, and a +/// transient generated property is never stored at all; +/// * must sit inside every object that holds the declaring property (a +/// top-level property may take any parameter), so a document supplying the +/// parameters always holds the object the platform writes the value into. +/// +/// Owned by parser generation 3: the only generation that admits the keyword. +pub(super) fn validate_generated_from_declarations( + document_type: &DocumentTypeV2, + document_type_name: &str, +) -> Result<(), DataContractError> { + let flattened_properties = &document_type.flattened_properties; + for (path, property) in flattened_properties { + let Some(generated_from) = &property.generated_from else { + continue; + }; + let structure_error = |message: String| { + DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" property \"{path}\" generatedFrom \ + {message}" + )) + }; + + if is_transient(DocumentTypeRef::V2(document_type), path) { + return Err(structure_error( + "is on a property that is transient or inside a transient object: a transient \ + value is never stored, so the generated value would be lost" + .to_string(), + )); + } + + for param in generated_from.property_params() { + if param == path { + return Err(structure_error( + "reads the property itself: name other string properties of the document \ + type" + .to_string(), + )); + } + let Some(param_property) = flattened_properties.get(param) else { + if names_an_object(flattened_properties, param) { + return Err(structure_error(format!( + "param \"{param}\" is an object, not a string property: name one of its \ + string members" + ))); + } + return Err(structure_error(format!( + "param \"{param}\" is not a property of the document type" + ))); + }; + if !matches!( + param_property.property_type, + DocumentPropertyType::String(_) + ) { + return Err(structure_error(format!( + "param \"{param}\" has type {}, not string", + param_property.property_type.name() + ))); + } + if param_property.generated_from.is_some() { + return Err(structure_error(format!( + "param \"{param}\" is generated itself: name the properties it is generated \ + from instead" + ))); + } + if is_transient(DocumentTypeRef::V2(document_type), param) { + return Err(structure_error(format!( + "param \"{param}\" is transient or inside a transient object: a transient \ + value is never stored, so a replace could not keep the generated value \ + without sending it again" + ))); + } + if let Some((target_object, _)) = path.rsplit_once('.') { + let inside_target_object = param + .strip_prefix(target_object) + .is_some_and(|rest| rest.starts_with('.')); + if !inside_target_object { + return Err(structure_error(format!( + "param \"{param}\" is outside \"{target_object}\": every param must sit \ + inside every object that holds the generated property, so a document \ + supplying the params always has the object the value is written into" + ))); + } + } + } + } + Ok(()) +} + /// Reads the `propertyConstraints` keyword onto the document type and checks /// every property its rules read: by its value, an integer or boolean /// property of the type (a nested one named by its dotted path, as the diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_tests.rs new file mode 100644 index 00000000000..74c3dc70247 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_tests.rs @@ -0,0 +1,1106 @@ +//! `generatedFrom`: the property keyword saying the platform generates a +//! string property with a built-in function of other properties of the same +//! document. +//! +//! The grammar is the v3 document meta-schema's (protocol version 14), the +//! parse is `apply_generated_from` 0 and the checks against the rest of the +//! type are `validate_generated_from_declarations`, both reached from +//! protocol version 14 only. At write time the platform generates a +//! left-out property from its params (`fill_generated_properties`), and +//! `DataContract::validate_document_properties` checks it after the JSON +//! schema (`validate_generated_from_properties`). + +use super::typed_array_test_helpers::{ + expect_json_schema_error, expect_structure_error, parse_dispatched, +}; +use super::*; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; +use crate::data_contract::document_type::methods::{ + DocumentTypeBasicMethods, DocumentTypeV0Methods, +}; +use crate::data_contract::document_type::property_constraints::DocumentSystemValues; +use crate::data_contract::document_type::{ + GeneratedFrom, GenerationParam, StringTransformation, SystemFunction, +}; +use crate::data_contract::validate_document::DataContractDocumentValidationMethodsV0; +use crate::data_contract::DataContract; +use crate::document::Document; +use crate::validation::SimpleConsensusValidationResult; +use platform_value::platform_value; +use platform_value::string_encoding::Encoding; +use serde_json::json; + +const HOMOGRAPH_SAFE_ASCII: &str = "sys.stringTransformations.homographSafeASCII"; + +fn generated_from(source: &str) -> Value { + platform_value!({ "function": HOMOGRAPH_SAFE_ASCII, "params": [source] }) +} + +fn string_property(position: u64) -> Value { + platform_value!({ "type": "string", "maxLength": 32, "position": position }) +} + +fn generated_property(position: u64, source: &str) -> Value { + let mut property = string_property(position); + property + .insert("generatedFrom".to_string(), generated_from(source)) + .expect("the property is a map"); + property +} + +/// A `handle` type: a top-level `label` and its `normalizedLabel`, an object +/// `profile` holding `display` and its `normalizedDisplay`, and a top-level +/// `slug` generated from the nested `profile.display`. +fn schema() -> Value { + platform_value!({ + "type": "object", + "properties": { + "label": string_property(0), + "normalizedLabel": generated_property(1, "label"), + "profile": { + "type": "object", + "position": 2, + "properties": { + "display": string_property(0), + "normalizedDisplay": generated_property(1, "profile.display") + }, + "additionalProperties": false + }, + "slug": generated_property(3, "profile.display") + }, + "additionalProperties": false + }) +} + +/// A document type with a string `label` and the one other property `value`. +fn schema_with(value: Value) -> Value { + platform_value!({ + "type": "object", + "properties": { "label": string_property(0), "value": value }, + "additionalProperties": false + }) +} + +fn parse(schema: Value) -> DocumentType { + parse_dispatched(schema, PlatformVersion::latest(), true).expect("the schema parses") +} + +fn generated_from_of(document_type: &DocumentType, path: &str) -> Option { + document_type + .as_ref() + .flattened_properties() + .get(path) + .unwrap_or_else(|| panic!("{path} is parsed")) + .generated_from + .clone() +} + +/// Both parses refuse the declaration: the validating one with the meta-schema +/// (or, for a check the meta-schema cannot state, with the parser's +/// structure error), the one that skips the meta-schema with the parser's. +fn expect_refused(schema: Value, needle: &str) { + assert!( + parse_dispatched(schema.clone(), PlatformVersion::latest(), true).is_err(), + "a validating parse refuses it ({needle})" + ); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + needle, + ); +} + +fn first_basic_error(result: SimpleConsensusValidationResult) -> BasicError { + match result.errors.into_iter().next() { + Some(ConsensusError::BasicError(error)) => error, + other => panic!("expected a basic error, got {other:?}"), + } +} + +// ================================================================ +// Parse +// ================================================================ + +#[test] +fn should_parse_generated_from_onto_string_properties_at_any_depth() { + let document_type = parse(schema()); + let expected = |source: &str| { + Some(GeneratedFrom { + function: SystemFunction::StringTransformation( + StringTransformation::HomographSafeAscii, + ), + params: vec![GenerationParam::Property(source.to_string())], + }) + }; + + assert_eq!( + generated_from_of(&document_type, "normalizedLabel"), + expected("label") + ); + assert_eq!( + generated_from_of(&document_type, "profile.normalizedDisplay"), + expected("profile.display") + ); + assert_eq!( + generated_from_of(&document_type, "slug"), + expected("profile.display") + ); + assert_eq!(generated_from_of(&document_type, "label"), None); +} + +#[test] +fn should_refuse_generated_from_on_a_property_that_is_not_a_string() { + let schema = schema_with(platform_value!({ + "type": "integer", + "minimum": 0, + "maximum": 100, + "generatedFrom": generated_from("label"), + "position": 1 + })); + let error = expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + assert_eq!(error.keyword(), "const", "{error:?}"); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "generatedFrom is only allowed on string properties", + ); +} + +#[test] +fn should_refuse_generated_from_on_a_typed_array_or_its_items() { + for value in [ + platform_value!({ + "type": "array", + "maxItems": 4, + "generatedFrom": generated_from("label"), + "items": { "type": "string", "maxLength": 16 }, + "position": 1 + }), + platform_value!({ + "type": "array", + "maxItems": 4, + "items": { + "type": "string", + "maxLength": 16, + "generatedFrom": generated_from("label") + }, + "position": 1 + }), + ] { + expect_json_schema_error(parse_dispatched( + schema_with(value.clone()), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema_with(value), PlatformVersion::latest(), false), + "not on a typed array or its items", + ); + } +} + +/// A `$ref` replaces every keyword written beside it with its definition, so a +/// declaration there would be dropped: the meta-schema refuses it. +#[test] +fn should_refuse_generated_from_beside_a_ref() { + let platform_version = PlatformVersion::latest(); + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available on this platform version"); + let schema_defs = BTreeMap::from([( + "name".to_string(), + platform_value!({ "type": "string", "maxLength": 32 }), + )]); + let schema = platform_value!({ + "type": "object", + "properties": { + "label": string_property(0), + "normalizedLabel": { + "$ref": "#/$defs/name", + "type": "string", + "position": 1, + "generatedFrom": generated_from("label") + } + }, + "additionalProperties": false + }); + let error = expect_json_schema_error(DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "handle", + schema, + Some(&schema_defs), + &BTreeMap::new(), + &config, + true, + &mut vec![], + platform_version, + )); + assert_eq!(error.keyword(), "not", "{error:?}"); +} + +/// Every system function registers, and generates what it returns for the +/// document's param. +#[test] +fn should_register_and_generate_with_every_string_transformation() { + for transformation in StringTransformation::ALL { + let document_type = parse(schema_with(platform_value!({ + "type": "string", + "maxLength": 64, + "generatedFrom": { "function": transformation.as_str(), "params": ["label"] }, + "position": 1 + }))); + assert_eq!( + generated_from_of(&document_type, "value").map(|declaration| declaration.function), + Some(SystemFunction::StringTransformation(transformation)) + ); + let mut properties = BTreeMap::from([( + "label".to_string(), + Value::Text("hello World-again".to_string()), + )]); + document_type + .fill_generated_properties(&mut properties, PlatformVersion::latest()) + .expect("the fill runs"); + assert_eq!( + properties.get("value"), + Some(&Value::Text(transformation.apply("hello World-again"))), + "{}", + transformation.as_str() + ); + } +} + +#[test] +fn should_refuse_a_malformed_generated_from() { + let with = |generated_from: Value| { + schema_with(platform_value!({ + "type": "string", + "maxLength": 32, + "generatedFrom": generated_from, + "position": 1 + })) + }; + + for (declaration, needle) in [ + ( + platform_value!({ + "function": "sys.stringTransformations.homographSafe", + "params": ["label"] + }), + "function \"sys.stringTransformations.homographSafe\" is unknown, expected one of \ + \"sys.stringTransformations.camelCase\", \"sys.stringTransformations.capitalize\", \ + \"sys.stringTransformations.homographSafeASCII\", \"sys.stringTransformations.lowercase\", \ + \"sys.stringTransformations.snakeCase\", \"sys.stringTransformations.uppercase\"", + ), + // Built-ins are named under sys.: the bare name is no function + ( + platform_value!({ "function": "homographSafeASCII", "params": ["label"] }), + "function \"homographSafeASCII\" is unknown", + ), + ( + platform_value!({ "params": ["label"] }), + "generatedFrom must be an object with a function", + ), + ( + platform_value!({ "function": HOMOGRAPH_SAFE_ASCII }), + "generatedFrom must be an object with a function", + ), + ( + platform_value!({ "function": HOMOGRAPH_SAFE_ASCII, "params": "label" }), + "generatedFrom must be an object with a function", + ), + ( + platform_value!({ "function": HOMOGRAPH_SAFE_ASCII, "params": ["label"], "extra": 1 }), + "generatedFrom \"extra\" is unknown, expected function and params", + ), + ( + platform_value!({ "function": HOMOGRAPH_SAFE_ASCII, "params": [] }), + "takes 1 parameter(s), but params lists 0", + ), + ( + platform_value!({ "function": HOMOGRAPH_SAFE_ASCII, "params": ["label", "label"] }), + "takes 1 parameter(s), but params lists 2", + ), + ( + platform_value!({ "function": HOMOGRAPH_SAFE_ASCII, "params": [7] }), + "generatedFrom params must be property paths (strings)", + ), + ( + platform_value!({ "function": HOMOGRAPH_SAFE_ASCII, "params": [""] }), + "generatedFrom params must be between 1 and 256 characters", + ), + ( + platform_value!({ "function": HOMOGRAPH_SAFE_ASCII, "params": ["$ownerId"] }), + "not system property \"$ownerId\"", + ), + ( + platform_value!("label"), + "generatedFrom must be an object with a function", + ), + ] { + expect_refused(with(declaration), needle); + } +} + +#[test] +fn should_refuse_a_param_that_is_not_another_string_property() { + for (value, needle) in [ + ( + generated_property(1, "missing"), + "generatedFrom param \"missing\" is not a property of the document type", + ), + ( + generated_property(1, "value"), + "generatedFrom reads the property itself", + ), + ] { + expect_refused(schema_with(value), needle); + } + + let with_extra = |extra: Value, source: &str| { + platform_value!({ + "type": "object", + "properties": { + "label": string_property(0), + "extra": extra, + "value": generated_property(2, source) + }, + "additionalProperties": false + }) + }; + expect_refused( + with_extra( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 9, "position": 1 }), + "extra", + ), + "generatedFrom param \"extra\" has type", + ); + expect_refused( + with_extra( + platform_value!({ + "type": "object", + "position": 1, + "properties": { "inner": string_property(0) }, + "additionalProperties": false + }), + "extra", + ), + "generatedFrom param \"extra\" is an object, not a string property", + ); + // A nested member is named by its dotted path + parse(with_extra( + platform_value!({ + "type": "object", + "position": 1, + "properties": { "inner": string_property(0) }, + "additionalProperties": false + }), + "extra.inner", + )); +} + +/// The platform fills every left-out property from a value the client sent, +/// so a param is never itself a generated property. +#[test] +fn should_refuse_a_param_that_is_generated_itself() { + let schema = platform_value!({ + "type": "object", + "properties": { + "label": string_property(0), + "normalizedLabel": generated_property(1, "label"), + "twiceNormalized": generated_property(2, "normalizedLabel") + }, + "additionalProperties": false + }); + expect_refused( + schema, + "generatedFrom param \"normalizedLabel\" is generated itself", + ); +} + +#[test] +fn should_refuse_a_transient_property_or_param() { + for (transient, needle) in [ + ( + "value", + "is on a property that is transient or inside a transient object", + ), + ( + "label", + "param \"label\" is transient or inside a transient object", + ), + ] { + let mut schema = schema_with(generated_property(1, "label")); + schema + .insert("transient".to_string(), platform_value!([transient])) + .expect("the schema is a map"); + expect_refused(schema, needle); + } + + // Inside a transient object, on either side + let with_transient_profile = |slug: Value| { + platform_value!({ + "type": "object", + "properties": { + "profile": { + "type": "object", + "position": 0, + "properties": { + "display": string_property(0), + "normalizedDisplay": generated_property(1, "profile.display") + }, + "additionalProperties": false + }, + "plain": string_property(1), + "slug": slug + }, + "transient": ["profile"], + "additionalProperties": false + }) + }; + let error = parse_dispatched( + with_transient_profile(generated_property(2, "plain")), + PlatformVersion::latest(), + false, + ) + .expect_err("a generated property inside a transient object is refused"); + assert!( + error.to_string().contains( + "\"profile.normalizedDisplay\" generatedFrom is on a property that is transient" + ), + "{error}" + ); + + let schema = platform_value!({ + "type": "object", + "properties": { + "profile": { + "type": "object", + "position": 0, + "properties": { "display": string_property(0) }, + "additionalProperties": false + }, + "slug": generated_property(1, "profile.display") + }, + "transient": ["profile"], + "additionalProperties": false + }); + expect_refused( + schema, + "generatedFrom param \"profile.display\" is transient or inside a transient object", + ); +} + +/// The platform writes a left-out property into the objects around it, which a +/// document supplying the params must hold: every param sits inside every one. +#[test] +fn should_refuse_a_param_outside_the_object_that_holds_the_property() { + let with_profile = |source: &str| { + platform_value!({ + "type": "object", + "properties": { + "label": string_property(0), + "profile": { + "type": "object", + "position": 1, + "properties": { + "display": string_property(0), + "inner": { + "type": "object", + "position": 1, + "properties": { "deep": string_property(0) }, + "additionalProperties": false + }, + "normalized": generated_property(2, source) + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }) + }; + + expect_refused( + with_profile("label"), + "generatedFrom param \"label\" is outside \"profile\"", + ); + // Inside the object, at its level or deeper, is fine + parse(with_profile("profile.display")); + parse(with_profile("profile.inner.deep")); +} + +#[test] +fn should_ignore_generated_from_before_protocol_version_14() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + + // Protocol version 13's meta-schema refuses the keyword + expect_json_schema_error(parse_dispatched(schema(), platform_version, true)); + + // A parse that skips it (a contract read back from state) ignores it, as it always did + let document_type = parse_dispatched(schema(), platform_version, false) + .expect("a parse predating generatedFrom ignores it"); + assert_eq!(generated_from_of(&document_type, "normalizedLabel"), None); + assert_eq!(generated_from_of(&document_type, "slug"), None); +} + +// ================================================================ +// Fill on arrival +// ================================================================ + +fn data(value: Value) -> BTreeMap { + value.into_btree_string_map().expect("a map") +} + +#[test] +fn should_fill_every_left_out_property_from_its_params() { + let document_type = parse(schema()); + let mut properties = data(platform_value!({ + "label": "Bob", + "profile": { "display": "Lil-Olive" } + })); + document_type + .fill_generated_properties(&mut properties, PlatformVersion::latest()) + .expect("the fill runs"); + + assert_eq!( + properties, + data(platform_value!({ + "label": "Bob", + "normalizedLabel": "b0b", + "profile": { "display": "Lil-Olive", "normalizedDisplay": "111-011ve" }, + "slug": "111-011ve" + })) + ); +} + +/// What a client sends is left as it is, right or wrong: the check judges it. +#[test] +fn should_leave_a_supplied_property_and_an_absent_param_alone() { + let document_type = parse(schema()); + for sent in [ + platform_value!({ "label": "Bob", "normalizedLabel": "wrong" }), + platform_value!({ "label": "Bob", "normalizedLabel": "b0b" }), + platform_value!({ "normalizedLabel": "b0b" }), + platform_value!({ "profile": {} }), + platform_value!({}), + // A param that is not a string is the schema's to refuse + platform_value!({ "label": 7 }), + ] { + let mut properties = data(sent.clone()); + document_type + .fill_generated_properties(&mut properties, PlatformVersion::latest()) + .expect("the fill runs"); + assert_eq!(properties, data(sent.clone()), "{sent:?}"); + } +} + +#[test] +fn should_fill_nothing_before_protocol_version_14() { + // A type parsed with the keyword, filled under protocol version 13's method table + let document_type = parse(schema()); + let mut properties = data(platform_value!({ "label": "Bob" })); + document_type + .fill_generated_properties( + &mut properties, + PlatformVersion::get(13).expect("protocol version 13"), + ) + .expect("the fill runs"); + assert_eq!(properties, data(platform_value!({ "label": "Bob" }))); +} + +/// The client-side twin sets every property to what its current params generate, +/// replacing a stale value, and removes one whose param is absent. +#[test] +fn should_regenerate_every_property_from_its_current_params() { + let document_type = parse(schema()); + let mut properties = data(platform_value!({ + "label": "Robin", + "normalizedLabel": "b0b", + "profile": { "normalizedDisplay": "stale" }, + "slug": "stale" + })); + document_type + .regenerate_generated_properties(&mut properties, PlatformVersion::latest()) + .expect("the regeneration runs"); + assert_eq!( + properties, + data(platform_value!({ + "label": "Robin", + "normalizedLabel": "r0b1n", + "profile": {} + })) + ); +} + +// ================================================================ +// Check +// ================================================================ + +#[test] +fn should_accept_the_generated_value_and_both_absent() { + let document_type = parse(schema()); + for properties in [ + platform_value!({ "label": "Bob", "normalizedLabel": "b0b" }), + platform_value!({ + "profile": { "display": "Oil", "normalizedDisplay": "011" }, + "slug": "011" + }), + // Characters outside ASCII are kept as they are + platform_value!({ "label": "Olé", "normalizedLabel": "01é" }), + platform_value!({ "label": "", "normalizedLabel": "" }), + platform_value!({}), + platform_value!({ "profile": {} }), + ] { + let result = document_type + .validate_generated_from_properties(&properties, PlatformVersion::latest()) + .expect("validation executes"); + assert!(result.is_valid(), "{properties:?}: {:?}", result.errors); + } +} + +#[test] +fn should_refuse_a_property_that_is_not_the_generated_value() { + let document_type = parse(schema()); + for (properties, property, source) in [ + // Not the generated value + ( + platform_value!({ "label": "Bob", "normalizedLabel": "bob" }), + "normalizedLabel", + "label", + ), + // Present without its param + ( + platform_value!({ "normalizedLabel": "b0b" }), + "normalizedLabel", + "label", + ), + // Absent while its param is present: a document that skipped the fill + ( + platform_value!({ "label": "Bob" }), + "normalizedLabel", + "label", + ), + // A nested one is named by its dotted path + ( + platform_value!({ + "profile": { "display": "Oil", "normalizedDisplay": "oil" }, + "slug": "011" + }), + "profile.normalizedDisplay", + "profile.display", + ), + ] { + let result = document_type + .validate_generated_from_properties(&properties, PlatformVersion::latest()) + .expect("validation executes"); + match first_basic_error(result) { + BasicError::DocumentPropertyNotGeneratedError(e) => { + assert_eq!(e.document_type_name(), "charter", "{properties:?}"); + assert_eq!(e.property(), property, "{properties:?}"); + assert_eq!(e.params(), [source.to_string()], "{properties:?}"); + assert_eq!(e.function(), HOMOGRAPH_SAFE_ASCII, "{properties:?}"); + } + other => { + panic!("{properties:?}: expected DocumentPropertyNotGeneratedError, got {other:?}") + } + } + } +} + +#[test] +fn should_check_nothing_before_protocol_version_14() { + // A type parsed with the keyword, judged under protocol version 13's method table + let document_type = parse(schema()); + assert!(document_type + .validate_generated_from_properties( + &platform_value!({ "label": "Bob", "normalizedLabel": "wrong" }), + PlatformVersion::get(13).expect("protocol version 13"), + ) + .expect("validation executes") + .is_valid()); +} + +/// A repeated key on the way to a param or to the property is refused: the +/// schema validation and the stored document keep the last of repeated keys, +/// where the platform generated from the first. +#[test] +fn should_refuse_a_repeated_key_on_the_way_to_a_param_or_the_property() { + let document_type = parse(schema()); + let text = |value: &str| Value::Text(value.to_string()); + let map = |entries: Vec<(&str, Value)>| { + Value::Map( + entries + .into_iter() + .map(|(key, value)| (text(key), value)) + .collect(), + ) + }; + + // A repeated param: the fill reads the first, the stored document would keep the last + let mut repeated_param = BTreeMap::from([( + "profile".to_string(), + map(vec![("display", text("zzz")), ("display", text("B0B"))]), + )]); + document_type + .fill_generated_properties(&mut repeated_param, PlatformVersion::latest()) + .expect("the fill runs"); + assert_eq!(repeated_param.get("slug"), Some(&text("zzz"))); + + let repeated_property = BTreeMap::from([ + ( + "profile".to_string(), + map(vec![ + ("display", text("Bob")), + ("normalizedDisplay", text("zzz")), + ("normalizedDisplay", text("b0b")), + ]), + ), + ("slug".to_string(), text("b0b")), + ]); + + for properties in [repeated_param, repeated_property] { + let properties = Value::from(properties); + let result = document_type + .validate_generated_from_properties(&properties, PlatformVersion::latest()) + .expect("validation executes"); + match first_basic_error(result) { + BasicError::DocumentPropertyNotGeneratedError(e) => { + assert_eq!(e.property(), "profile.normalizedDisplay", "{properties:?}") + } + other => { + panic!("{properties:?}: expected DocumentPropertyNotGeneratedError, got {other:?}") + } + } + } +} + +// ================================================================ +// Document validation +// ================================================================ + +fn handle_contract() -> DataContract { + let schema = serde_json::to_value(schema()).expect("the schema converts to JSON"); + let contract = json!({ + "$formatVersion": "1", + "id": Identifier::from([7; 32]).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { "handle": schema } + }); + DataContract::from_value( + platform_value::to_value(contract).expect("the contract converts"), + true, + PlatformVersion::latest(), + ) + .expect("the contract parses") +} + +/// `validate_document_properties` runs the check after the JSON schema: a +/// schema error keeps precedence, then a property that is not the generated +/// value is refused. +#[test] +fn should_refuse_a_wrong_generated_property_in_document_validation() { + let platform_version = PlatformVersion::latest(); + let contract = handle_contract(); + let validate = |properties: Value| { + contract + .validate_document_properties( + "handle", + properties, + &DocumentSystemValues::default(), + platform_version, + ) + .expect("validation returns a consensus result") + }; + + assert!(validate(platform_value!({ "label": "Bob", "normalizedLabel": "b0b" })).is_valid()); + + assert!(matches!( + validate(platform_value!({ "label": "Bob", "normalizedLabel": "bob" })).first_error(), + Some(ConsensusError::BasicError(BasicError::DocumentPropertyNotGeneratedError(e))) + if e.property() == "normalizedLabel" + )); + + // The schema's own refusal comes first + assert!(matches!( + validate(platform_value!({ "label": 7, "normalizedLabel": "bob" })).first_error(), + Some(ConsensusError::BasicError(BasicError::JsonSchemaError(_))) + )); +} + +// ================================================================ +// Client builders and random documents +// ================================================================ + +/// An immutable `name` type whose unique index over `normalizedLabel` is +/// contested, as DPNS's `domain` is. +fn contested_name_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": false, + "indices": [ + { + "name": "byNormalizedLabel", + "properties": [{ "normalizedLabel": "asc" }], + "unique": true, + "contested": { + "fieldMatches": [ + { "field": "normalizedLabel", "regexPattern": "^[a-zA-Z01-]{3,19}$" } + ], + "resolution": 0 + } + } + ], + "properties": { + "label": string_property(0), + "normalizedLabel": generated_property(1, "label") + }, + "required": ["label", "normalizedLabel"], + "additionalProperties": false + }) +} + +/// An indexOnly `entry` type: a `name` and its `normalizedName`, both in the +/// one index, whose terminal is the owner. +fn index_only_entry_schema() -> Value { + platform_value!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "indices": [ + { + "name": "byNormalizedName", + "properties": [{ "normalizedName": "asc" }, { "name": "asc" }], + "terminal": "$ownerId" + } + ], + "properties": { + "name": string_property(0), + "normalizedName": generated_property(1, "name") + }, + "required": ["name", "normalizedName"], + "additionalProperties": false + }) +} + +fn document_of(document_type: &DocumentType, properties: Value) -> Document { + document_type + .as_ref() + .create_document_from_data( + properties, + Identifier::new([3; 32]), + 1, + 1, + [7; 32], + PlatformVersion::latest(), + ) + .expect("the document builds") +} + +/// The contest is resolved on the document the platform will store: the create +/// builder generates the property a document leaves out before it resolves the +/// contest, so the transition carries both. +#[test] +fn should_build_a_create_transition_carrying_the_generated_property_and_its_contest() { + use crate::state_transition::batch_transition::batched_transition::DocumentCreateTransition; + use crate::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; + + let document_type = parse(contested_name_schema()); + let transition = DocumentCreateTransition::from_document( + document_of(&document_type, platform_value!({ "label": "Bob" })), + document_type.as_ref(), + [7; 32], + None, + 1, + PlatformVersion::latest(), + None, + None, + ) + .expect("the create transition builds"); + + assert_eq!( + transition.data().get("normalizedLabel"), + Some(&Value::Text("b0b".to_string())) + ); + assert_eq!( + transition + .prefunded_voting_balance() + .as_ref() + .map(|(index, _)| index.as_str()), + Some("byNormalizedLabel") + ); +} + +#[test] +fn should_build_replace_and_index_only_delete_transitions_carrying_the_generated_property() { + use crate::state_transition::batch_transition::batched_transition::document_index_only_delete_transition::v0::v0_methods::DocumentIndexOnlyDeleteTransitionV0Methods; + use crate::state_transition::batch_transition::batched_transition::document_replace_transition::v0::v0_methods::DocumentReplaceTransitionV0Methods; + use crate::state_transition::batch_transition::batched_transition::{ + DocumentIndexOnlyDeleteTransition, DocumentReplaceTransition, + }; + + let platform_version = PlatformVersion::latest(); + let handle_type = parse(schema()); + let replace = DocumentReplaceTransition::from_document( + document_of(&handle_type, platform_value!({ "label": "Oil" })), + handle_type.as_ref(), + None, + 2, + platform_version, + None, + None, + ) + .expect("the replace transition builds"); + assert_eq!( + replace.data().get("normalizedLabel"), + Some(&Value::Text("011".to_string())) + ); + + // A document fetched and edited still holds the value generated from its old + // label: the builder replaces it with the one the platform would generate + let stale = DocumentReplaceTransition::from_document( + document_of( + &handle_type, + platform_value!({ "label": "Robin", "normalizedLabel": "b0b" }), + ), + handle_type.as_ref(), + None, + 2, + platform_version, + None, + None, + ) + .expect("the replace transition builds"); + assert_eq!( + stale.data().get("normalizedLabel"), + Some(&Value::Text("r0b1n".to_string())) + ); + + let entry_type = parse(index_only_entry_schema()); + let delete = DocumentIndexOnlyDeleteTransition::from_document( + document_of(&entry_type, platform_value!({ "name": "Bob" })), + entry_type.as_ref(), + None, + 3, + platform_version, + None, + None, + ) + .expect("the indexOnly delete transition builds"); + assert_eq!( + delete.data().get("normalizedName"), + Some(&Value::Text("b0b".to_string())) + ); +} + +/// Random documents hold each generated property as its function returns it for +/// their random params, so fixtures and strategy tests produce documents +/// consensus accepts. +#[cfg(feature = "random-documents")] +#[test] +fn should_generate_random_documents_holding_their_generated_values() { + use crate::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, + }; + use crate::document::DocumentV0Getters; + use platform_value::Bytes32; + use rand::rngs::StdRng; + use rand::SeedableRng; + + let platform_version = PlatformVersion::latest(); + let document_type = parse(schema()); + let mut rng = StdRng::seed_from_u64(99); + for fill_type in [ + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillType::DoNotFillIfNotRequired, + ] { + for _ in 0..20 { + let entropy = Bytes32::random_with_rng(&mut rng); + let document = document_type + .random_document_with_identifier_and_entropy( + &mut rng, + Identifier::new([3; 32]), + entropy, + fill_type, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("a random document"); + let properties: Value = document.properties().into(); + let result = document_type + .validate_generated_from_properties(&properties, platform_version) + .expect("validation executes"); + assert!(result.is_valid(), "{properties:?}: {:?}", result.errors); + } + } +} + +/// A required generated property whose param is optional, at the top level and +/// inside an object: random documents draw the param too, so the property is +/// never left out. +#[cfg(feature = "random-documents")] +#[test] +fn should_draw_the_optional_params_of_a_required_generated_property() { + use crate::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, + }; + use crate::document::DocumentV0Getters; + use platform_value::Bytes32; + use rand::rngs::StdRng; + use rand::SeedableRng; + + let platform_version = PlatformVersion::latest(); + let document_type = parse(platform_value!({ + "type": "object", + "properties": { + "label": string_property(0), + "normalizedLabel": generated_property(1, "label"), + "profile": { + "type": "object", + "position": 2, + "properties": { + "display": string_property(0), + "normalizedDisplay": generated_property(1, "profile.display") + }, + "required": ["normalizedDisplay"], + "additionalProperties": false + } + }, + "required": ["normalizedLabel", "profile"], + "additionalProperties": false + })); + let mut rng = StdRng::seed_from_u64(7); + for fill_type in [ + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillType::DoNotFillIfNotRequired, + ] { + for _ in 0..20 { + let entropy = Bytes32::random_with_rng(&mut rng); + let document = document_type + .random_document_with_identifier_and_entropy( + &mut rng, + Identifier::new([3; 32]), + entropy, + fill_type, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("a random document"); + let properties: Value = document.properties().into(); + for path in ["normalizedLabel", "profile.normalizedDisplay"] { + assert!( + matches!(properties.get_optional_value_at_path(path), Ok(Some(_))), + "{path} in {properties:?}" + ); + } + let result = document_type + .validate_generated_from_properties(&properties, platform_version) + .expect("validation executes"); + assert!(result.is_valid(), "{properties:?}: {:?}", result.errors); + } + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 3453d1eaebe..2fe80a58ced 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -57,7 +57,8 @@ use crate::consensus::ConsensusError; use super::common; use super::{ apply_property_constraints, parse_doctype_reference, validate_encrypted_for_declarations, - validate_list_element_sources, validate_reference_lookup_sources, + validate_generated_from_declarations, validate_list_element_sources, + validate_reference_lookup_sources, }; mod ranked_prefix_overlap; @@ -543,6 +544,9 @@ fn parse_generation_3( // the core parse read it. validate_encrypted_for_declarations(&v2, schema_defs, name) .map_err(consensus_or_protocol_data_contract_error)?; + // The same for the string property each `generatedFrom` declaration names. + validate_generated_from_declarations(&v2, name) + .map_err(consensus_or_protocol_data_contract_error)?; // The same for the properties a `refersTo` lookup reads to assemble its key, // the lookup of the `ownerRefersTo` declaration included. validate_reference_lookup_sources(DocumentTypeRef::V2(&v2), name) @@ -1086,6 +1090,8 @@ mod immutable_tests; #[cfg(test)] mod index_only_tests; +#[cfg(all(test, feature = "validation"))] +mod generated_from_tests; #[cfg(test)] mod keep_history_tests; #[cfg(all(test, feature = "validation"))] diff --git a/packages/rs-dpp/src/data_contract/document_type/index/extract_contested_values/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/extract_contested_values/mod.rs index ce4335043f3..6fdde541fc6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/extract_contested_values/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/extract_contested_values/mod.rs @@ -90,6 +90,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }; let document_properties = IndexMap::from([ ( diff --git a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs index 5c13466f2c8..1d3f5355240 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs @@ -198,6 +198,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, transient: false, } } @@ -214,6 +215,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, transient: false, } } @@ -225,6 +227,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, transient: false, } } diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index 5a6dc811a9f..1c6ed8ae4f2 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -15,7 +15,8 @@ use crate::ProtocolError; #[cfg(feature = "validation")] use crate::consensus::basic::document::{ - DocumentPropertyMaxBytesExceededError, InvalidEncryptedPropertyShapeError, + DocumentPropertyMaxBytesExceededError, DocumentPropertyNotGeneratedError, + InvalidEncryptedPropertyShapeError, }; use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, @@ -28,8 +29,9 @@ use crate::data_contract::document_type::property_constraints::{ use crate::data_contract::document_type::{DocumentPropertyType, StringPropertySizes}; use crate::fee::Credits; use crate::voting::vote_polls::VotePoll; -#[cfg(feature = "validation")] -use platform_value::btreemap_extensions::BTreeValueMapPathHelper; +use platform_value::btreemap_extensions::{ + BTreeValueMapInsertionPathHelper, BTreeValueMapPathHelper, +}; use platform_value::{Identifier, Value}; pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { @@ -228,6 +230,215 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { SimpleConsensusValidationResult::new() } + /// Writes every `generatedFrom` property `data` (a created or replaced document's + /// properties, as they arrive) leaves out, generated from its parameters: the platform + /// generates a property a client does not send, and checks one it does send + /// (`validate_generated_from_properties`). A property the document supplies is left as + /// it is, whatever it holds, and nothing is written when a parameter is absent or is not + /// a string (the schema validation refuses a parameter that is not a string on its own). + /// + /// Runs wherever a document arrives, before anything reads its data: the action + /// transformers of document create, replace and indexOnly delete, and the proof + /// verification that rebuilds the document a transition wrote. + /// + /// Versioned on `fill_generated_properties` in the document type method versions: + /// `None` before protocol version 14 leaves `data` untouched, which keeps the shipped + /// transformers and proof verification that call it inert. + fn fill_generated_properties( + &self, + data: &mut BTreeMap, + platform_version: &PlatformVersion, + ) -> Result<(), ProtocolError> + where + Self: DocumentTypeV2Getters, + { + self.generate_properties(data, false, platform_version) + } + + /// Sets every `generatedFrom` property of `data` (a document a client is about to + /// send) to what the platform generates from its current parameters, replacing a value + /// it holds and removing it when a parameter is absent. A document fetched, edited and + /// sent back keeps the generated values of its old parameters, which the platform + /// refuses; the transition builders call this instead of `fill_generated_properties` + /// so the transition carries the values the platform would generate. + /// + /// Versioned on `fill_generated_properties` like it: `None` before protocol version 14 + /// leaves `data` untouched. + fn regenerate_generated_properties( + &self, + data: &mut BTreeMap, + platform_version: &PlatformVersion, + ) -> Result<(), ProtocolError> + where + Self: DocumentTypeV2Getters, + { + self.generate_properties(data, true, platform_version) + } + + /// `fill_generated_properties` (`replace_present` false) and + /// `regenerate_generated_properties` (`replace_present` true). + fn generate_properties( + &self, + data: &mut BTreeMap, + replace_present: bool, + platform_version: &PlatformVersion, + ) -> Result<(), ProtocolError> + where + Self: DocumentTypeV2Getters, + { + match platform_version + .dpp + .contract_versions + .document_type_versions + .methods + .fill_generated_properties + { + None => Ok(()), + Some(0) => { + self.generate_properties_v0(data, replace_present); + Ok(()) + } + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "fill_generated_properties".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + + fn generate_properties_v0(&self, data: &mut BTreeMap, replace_present: bool) + where + Self: DocumentTypeV2Getters, + { + for (path, generated_from) in self.generated_from_fields() { + if replace_present { + remove_at_path(data, path); + } else if !matches!(data.get_optional_at_path(path), Ok(None)) { + // Supplied, or unreadable (an intermediate that is not a map, which the + // schema validation refuses on its own): either way not the platform's to + // write + continue; + } + let arguments: Option> = generated_from + .property_params() + .map(|param| match data.get_optional_at_path(param) { + Ok(Some(Value::Text(value))) => Some(value.as_str()), + _ => None, + }) + .collect(); + let Some(generated) = + arguments.and_then(|arguments| generated_from.function.apply(&arguments)) + else { + continue; + }; + // Registration puts every parameter inside every object that holds the + // property, so the objects on the way are present and are maps: the parameters + // were read through them. The insert cannot fail; if it ever did, the property + // would stay absent and the generatedFrom check would refuse the document. + let _ = data.insert_at_path(path, Value::Text(generated)); + } + } + + /// Checks every `generatedFrom` property of `properties` (the document's properties + /// map) against its parameters: when every parameter is present the property must hold + /// what the function generates from them, and when a parameter is absent the property + /// must be absent too. A document that repeats a key on the way to the property or to + /// a parameter is refused as well: the schema validation and the stored document keep + /// the last of repeated keys, where the platform generated from the first. The first + /// property that does not pass is refused with `DocumentPropertyNotGeneratedError`. A + /// value that is not a string is not compared: the JSON schema validation that + /// `DataContract::validate_document_properties` runs alongside refuses it, and its + /// result is reported first. + /// + /// A document that arrived at the platform has been through + /// `fill_generated_properties`, so a left-out property whose parameters are present is + /// already written; one that has not (a client validating a document before sending + /// it) is refused for the missing property. + /// + /// Versioned on `validate_generated_from` in the document type method versions: `None` + /// before protocol version 14 returns an empty result, which keeps the shipped document + /// validation that calls it inert. + #[cfg(feature = "validation")] + fn validate_generated_from_properties( + &self, + properties: &Value, + platform_version: &PlatformVersion, + ) -> Result + where + Self: DocumentTypeV2Getters, + { + match platform_version + .dpp + .contract_versions + .document_type_versions + .methods + .validate_generated_from + { + None => Ok(SimpleConsensusValidationResult::default()), + Some(0) => Ok(self.validate_generated_from_properties_v0(properties)), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "validate_generated_from_properties".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + + #[cfg(feature = "validation")] + fn validate_generated_from_properties_v0( + &self, + properties: &Value, + ) -> SimpleConsensusValidationResult + where + Self: DocumentTypeV2Getters, + { + for (path, generated_from) in self.generated_from_fields() { + let generated = match ( + read_at_path(properties, path), + generated_from + .property_params() + .map(|param| read_at_path(properties, param)) + .collect::, RepeatedKey>>(), + ) { + (Err(RepeatedKey), _) | (_, Err(RepeatedKey)) => false, + (Ok(value), Ok(arguments)) => { + if arguments.iter().any(Option::is_none) { + // Nothing to generate from: the property must be left out too + value.is_none() + } else { + let texts: Option> = arguments + .iter() + .map(|argument| argument.and_then(|argument| argument.as_text())) + .collect(); + match (value.map(|value| value.as_text()), texts) { + (None, _) => false, + (Some(Some(value)), Some(arguments)) => { + generated_from.function.apply(&arguments).as_deref() == Some(value) + } + // A value that is not a string: the schema validation refuses it + _ => true, + } + } + } + }; + if !generated { + return SimpleConsensusValidationResult::new_with_error( + DocumentPropertyNotGeneratedError::new( + self.name().clone(), + path.clone(), + generated_from.function.as_str().to_string(), + generated_from + .property_params() + .map(str::to_string) + .collect(), + ) + .into(), + ); + } + } + SimpleConsensusValidationResult::new() + } + fn top_level_indices(&self) -> Vec<&IndexProperty> { self.indexes() .values() @@ -251,6 +462,50 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { } } +/// A map on the way to a path holds the path's key more than once. +#[cfg(feature = "validation")] +struct RepeatedKey; + +/// The value at a dotted `path` of a document's properties: `Ok(None)` when it is absent or +/// the path runs through a value that is not a map (the schema validation refuses that +/// shape on its own), `Err` when a map on the way holds the path's key more than once. +#[cfg(feature = "validation")] +fn read_at_path<'a>(properties: &'a Value, path: &str) -> Result, RepeatedKey> { + let mut current = properties; + for segment in path.split('.') { + let Value::Map(map) = current else { + return Ok(None); + }; + let mut matches = map + .iter() + .filter(|(key, _)| key.as_text() == Some(segment)) + .map(|(_, value)| value); + let Some(value) = matches.next() else { + return Ok(None); + }; + if matches.next().is_some() { + return Err(RepeatedKey); + } + current = value; + } + Ok(Some(current)) +} + +/// Removes the value at a dotted `path` of a document's properties, if there is one. +fn remove_at_path(data: &mut BTreeMap, path: &str) { + match path.split_once('.') { + None => { + data.remove(path); + } + Some((head, rest)) => { + if let Some(value) = data.get_mut(head) { + // A value on the way that is not a map holds nothing to remove + let _ = value.remove_optional_value_at_path(rest); + } + } + } +} + /// The flat field list the v0 (protocol versions <= 13, frozen on chain) /// matchers run over, assembled the way `select_best_index` always has: /// equality fields first, then the range and `in` fields, then any diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs index 1cfe620ab34..882ad7479d0 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs @@ -2649,6 +2649,115 @@ mod tests { assert!(result.is_valid(), "{:?}", result.errors); } + /// A `handle` type whose `normalizedName` is generated from `generated_from` when given. + fn generated_from_document_type( + generated_from: Option<&str>, + platform_version: &PlatformVersion, + ) -> DocumentType { + let mut normalized_name = platform_value!({ + "type": "string", + "maxLength": 32, + "position": 2 + }); + if let Some(source) = generated_from { + normalized_name + .insert( + "generatedFrom".to_string(), + platform_value!({ + "function": "sys.stringTransformations.homographSafeASCII", + "params": [source] + }), + ) + .expect("should insert generatedFrom"); + } + + let schema = platform_value!({ + "type": "object", + "properties": { + "name": { "type": "string", "maxLength": 32, "position": 0 }, + "displayName": { "type": "string", "maxLength": 32, "position": 1 }, + "normalizedName": normalized_name + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "handle", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + #[test] + fn should_return_invalid_result_when_generated_from_is_added_changed_or_removed() { + let platform_version = PlatformVersion::latest(); + + for (old_generated_from, new_generated_from, path) in [ + ( + None, + Some("name"), + "/properties/normalizedName/generatedFrom", + ), + ( + Some("name"), + Some("displayName"), + "/properties/normalizedName/generatedFrom/params/0", + ), + ( + Some("name"), + None, + "/properties/normalizedName/generatedFrom", + ), + ] { + let old_document_type = + generated_from_document_type(old_generated_from, platform_version); + let new_document_type = + generated_from_document_type(new_generated_from, platform_version); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == path, + "{old_generated_from:?} -> {new_generated_from:?}: {:?}", + result.errors + ); + } + } + + #[test] + fn should_return_valid_result_when_generated_from_is_unchanged() { + let platform_version = PlatformVersion::latest(); + + let old_document_type = generated_from_document_type(Some("name"), platform_version); + let new_document_type = generated_from_document_type(Some("name"), platform_version); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert!(result.is_valid(), "{:?}", result.errors); + } + /// A `message` type whose `senderKeyId` is a `u32` key id, carrying /// `refers_to` when given. fn key_id_document_type( diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs index 1168d973dcc..e5f770cd404 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs @@ -74,6 +74,15 @@ impl DocumentTypeRef<'_> { return Ok(result); } + // Validate that a property the update adds is generated only when one of its + // params is new too (the keyword arrives with protocol version 14, the only + // version selecting this generation) + let result = self.validate_generated_from_additions(new_document_type); + + if !result.is_valid() { + return Ok(result); + } + // Validate that index definitions are unchanged let result = self.validate_index_definitions_unchanged(new_document_type); @@ -395,6 +404,42 @@ impl DocumentTypeRef<'_> { ) } + /// A property this update adds may declare `generatedFrom` only when a param is new + /// too. Documents stored before the update were never generated, so a new generated + /// property whose params all existed would be missing from every stored document + /// holding them, for good on a type whose documents are never replaced; with a new + /// param, those documents lack it, and the generated property is rightly absent. A + /// property that already existed keeps its declaration unchanged: the schema + /// compatibility differ freezes the keyword. + fn validate_generated_from_additions( + &self, + new_document_type: DocumentTypeRef, + ) -> SimpleConsensusValidationResult { + let old_properties = self.flattened_properties(); + for (path, generated_from) in new_document_type.generated_from_fields() { + if old_properties.contains_key(path) { + continue; + } + if generated_from + .property_params() + .all(|param| old_properties.contains_key(param)) + { + let params = generated_from.params_description(); + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not add property \"{path}\" generated from existing properties ({params}): documents stored before the update hold them without it" + ), + ) + .into(), + ); + } + } + SimpleConsensusValidationResult::new() + } + /// A document type's time to live is fixed when the type is created. Every document /// already stored has its expiry indexed from the time to live it was written with, and /// paid for that lifetime: adding a `ttl` would leave the stored documents without an @@ -938,6 +983,84 @@ mod tests { assert!(result.is_valid(), "{:?}", result.errors); } + #[test] + fn should_refuse_adding_a_generated_property_over_existing_params() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + let make_document_type = |properties: Value| { + let schema = platform_value!({ + "type": "object", + "properties": properties, + "additionalProperties": false, + }); + DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + "handle", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("document type should parse") + }; + let string = |position: u64| platform_value!({ "type": "string", "maxLength": 32, "position": position }); + let generated = |position: u64, source: &str| { + platform_value!({ + "type": "string", "maxLength": 32, "position": position, + "generatedFrom": { + "function": "sys.stringTransformations.homographSafeASCII", + "params": [source] + } + }) + }; + let old = make_document_type(platform_value!({ "label": string(0) })); + + // Stored handles hold a label but would never hold the generated value + let result = old + .as_ref() + .validate_update( + make_document_type(platform_value!({ + "label": string(0), + "normalizedLabel": generated(1, "label") + })) + .as_ref(), + 2, + platform_version, + ) + .expect("validate_update should not error"); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError(StateError::DocumentTypeUpdateError(e))] + if e.additional_message().contains( + "can not add property \"normalizedLabel\" generated from existing properties (label)" + ) + ); + + // A new property generated from a new param holds for every stored document: + // neither is there + let result = old + .as_ref() + .validate_update( + make_document_type(platform_value!({ + "label": string(0), + "nickname": string(1), + "normalizedNickname": generated(2, "nickname") + })) + .as_ref(), + 2, + platform_version, + ) + .expect("validate_update should not error"); + assert!(result.is_valid(), "{:?}", result.errors); + } + #[test] fn should_reject_removing_an_immutable_property() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 902bc6c3c5e..229d6b6ab91 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -181,6 +181,16 @@ pub(crate) mod property_names { /// Meta-schema v3+ (protocol version 14). See `apply_max_bytes` in /// `try_from_schema`. pub const MAX_BYTES: &str = "maxBytes"; + /// Property-level object on a string property: the [`FUNCTION`] the platform + /// generates the value with and its [`PARAMS`], other properties of the same + /// document. Meta-schema v3+ (protocol version 14). See + /// `apply_generated_from` in `try_from_schema`. + pub const GENERATED_FROM: &str = "generatedFrom"; + /// `generatedFrom`: the function name, one of `SystemFunction::ALL`. + pub const FUNCTION: &str = "function"; + /// `generatedFrom`: the parameters, dotted paths of properties of the same + /// document type. + pub const PARAMS: &str = "params"; pub const KEY_REQUIREMENTS: &str = "keyRequirements"; pub const PURPOSE: &str = "purpose"; pub const BOUND_TO: &str = "boundTo"; diff --git a/packages/rs-dpp/src/data_contract/document_type/property/generated_from/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/mod.rs new file mode 100644 index 00000000000..dfae469efcc --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/mod.rs @@ -0,0 +1,87 @@ +//! The `generatedFrom` declaration of a property: the platform generates the +//! property's value with a function of other properties of the same document, +//! such as a name folded for case-insensitive, homograph-resistant uniqueness. +//! +//! The platform computes the property when a created or replaced document +//! leaves it out and supplies every parameter, and checks it when the document +//! supplies it. The functions are a closed list of system functions named +//! under `sys.` ([`system_function`]). A function never refuses a value: which +//! characters a parameter may hold is the job of that parameter's own +//! `pattern`, and the generated property needs no pattern of its own. + +pub mod system_function; + +pub use system_function::{StringTransformation, SystemFunction}; + +use serde::{Deserialize, Serialize}; + +/// The `generatedFrom` declaration of a property. +/// +/// Declared as +/// `"generatedFrom": { "function": "sys.stringTransformations.homographSafeASCII", "params": [""] }` +/// on a string property (meta-schema v3, protocol version 14). The declaring +/// property must hold what `function` returns for the values of `params`, and +/// be absent exactly when a parameter is. The parser checks at contract +/// registration that `params` holds as many parameters as the function takes, +/// each another property of the same document type of the kind the function +/// reads, none transient, none generated itself, and each inside every object +/// that holds the declaring property, so a document supplying the parameters +/// always has somewhere to put the generated value. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GeneratedFrom { + /// The function that generates the value. + pub function: SystemFunction, + /// What the function is applied to, in order. + pub params: Vec, +} + +impl GeneratedFrom { + /// The property paths the parameters read, in order. + pub fn property_params(&self) -> impl Iterator { + self.params.iter().map(|param| match param { + GenerationParam::Property(path) => path.as_str(), + }) + } + + /// The parameters as the schema spells them, joined for a message. + pub fn params_description(&self) -> String { + self.property_params().collect::>().join(", ") + } +} + +/// One parameter of a [`GeneratedFrom`] function. +/// +/// Only properties of the same document for now, written as a bare dotted +/// path. The grammar leaves room for literals (`{ "const": ... }`), system +/// values (`"$ownerId"`) and nested calls, which a later protocol version can +/// add without changing what parses today. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum GenerationParam { + /// The dotted path of a property of the same document type. + Property(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_read_and_write_the_wire_form() { + let declaration = GeneratedFrom { + function: SystemFunction::StringTransformation( + StringTransformation::HomographSafeAscii, + ), + params: vec![GenerationParam::Property("label".to_string())], + }; + assert_eq!(declaration.params_description(), "label"); + assert_eq!( + serde_json::to_value(&declaration).expect("serializes"), + serde_json::json!({ + "function": "sys.stringTransformations.homographSafeASCII", + "params": ["label"] + }) + ); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/mod.rs new file mode 100644 index 00000000000..7577e93d3c9 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/mod.rs @@ -0,0 +1,194 @@ +//! The system functions a `generatedFrom` property is generated with: built +//! into the platform and named under `sys.`, so that functions a contract +//! brings later can be told apart by name. Each namespace under `sys.` is an +//! enum of its own in a submodule: `sys.stringTransformations` is +//! [`StringTransformation`], in [`string_transformations`]. + +pub mod string_transformations; + +pub use string_transformations::StringTransformation; + +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; + +/// A system function, by namespace. +/// +/// A closed list of built-ins. Every node must compute exactly the same value, +/// so each function is defined without any table that could differ between +/// builds (Unicode case mappings change between Rust releases): the string +/// transformations change ASCII characters only. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SystemFunction { + /// `sys.stringTransformations.*`: one string to a string. + StringTransformation(StringTransformation), +} + +impl SystemFunction { + /// Every function, in wire-name order. + pub const ALL: [SystemFunction; StringTransformation::ALL.len()] = { + let mut all = [SystemFunction::StringTransformation(StringTransformation::ALL[0]); + StringTransformation::ALL.len()]; + let mut index = 0; + while index < all.len() { + all[index] = SystemFunction::StringTransformation(StringTransformation::ALL[index]); + index += 1; + } + all + }; + + /// The wire name, the value of `generatedFrom.function`. + pub const fn as_str(&self) -> &'static str { + match self { + SystemFunction::StringTransformation(transformation) => transformation.as_str(), + } + } + + /// The function a wire name names, `None` for any other name. + pub fn from_wire_name(name: &str) -> Option { + Self::ALL + .into_iter() + .find(|function| function.as_str() == name) + } + + /// How many parameters the function takes. Every one is a string. + pub const fn parameter_count(&self) -> usize { + match self { + SystemFunction::StringTransformation(_) => 1, + } + } + + /// The value for `arguments`, the parameters' values in `params` order. + /// `None` when their count is not the function's, which registration rules + /// out. The platform writes this value into a document that leaves the + /// property out, and compares a sent value with it. + pub fn apply(&self, arguments: &[&str]) -> Option { + match (self, arguments) { + (SystemFunction::StringTransformation(transformation), [source]) => { + Some(transformation.apply(source)) + } + _ => None, + } + } +} + +impl fmt::Display for SystemFunction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Written as its wire name. +impl Serialize for SystemFunction { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +/// Read from its wire name; any other name is refused. +impl<'de> Deserialize<'de> for SystemFunction { + fn deserialize>(deserializer: D) -> Result { + let name = String::deserialize(deserializer)?; + SystemFunction::from_wire_name(&name) + .ok_or_else(|| D::Error::custom(format!("unknown system function {name:?}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HOMOGRAPH_SAFE_ASCII: SystemFunction = + SystemFunction::StringTransformation(StringTransformation::HomographSafeAscii); + + #[test] + fn should_apply_the_function_to_its_parameters() { + assert_eq!( + HOMOGRAPH_SAFE_ASCII.apply(&["Bob"]), + Some("b0b".to_string()) + ); + assert_eq!( + SystemFunction::StringTransformation(StringTransformation::SnakeCase) + .apply(&["helloWorld"]), + Some("hello_world".to_string()) + ); + } + + /// A call with another number of parameters than the function takes, which + /// registration rules out, generates nothing. + #[test] + fn should_generate_nothing_for_another_number_of_parameters() { + for function in SystemFunction::ALL { + assert_eq!(function.parameter_count(), 1, "{function}"); + assert_eq!(function.apply(&[]), None, "{function}"); + assert_eq!(function.apply(&["a", "b"]), None, "{function}"); + } + } + + #[test] + fn should_list_every_string_transformation_in_wire_name_order() { + let names: Vec<&str> = SystemFunction::ALL + .iter() + .map(SystemFunction::as_str) + .collect(); + assert_eq!( + names, + [ + "sys.stringTransformations.camelCase", + "sys.stringTransformations.capitalize", + "sys.stringTransformations.homographSafeASCII", + "sys.stringTransformations.lowercase", + "sys.stringTransformations.snakeCase", + "sys.stringTransformations.uppercase", + ] + ); + } + + #[test] + fn should_read_and_write_the_wire_name() { + for function in SystemFunction::ALL { + assert_eq!( + SystemFunction::from_wire_name(function.as_str()), + Some(function) + ); + assert_eq!(function.to_string(), function.as_str()); + let written = serde_json::to_value(function).expect("serializes"); + assert_eq!(written, serde_json::json!(function.as_str())); + assert_eq!( + serde_json::from_value::(written).expect("deserializes"), + function + ); + } + assert_eq!(SystemFunction::from_wire_name("homographSafeASCII"), None); + assert_eq!( + SystemFunction::from_wire_name("sys.stringTransformations.lowerCase"), + None + ); + assert!(serde_json::from_value::(serde_json::json!("sys.nope")).is_err()); + } + + /// Meta-schema v3 lists every system function the parser knows, so a name + /// it admits always parses and none the parser knows is refused by it. + #[test] + fn should_list_every_function_in_the_meta_schema() { + let meta_schema: serde_json::Value = serde_json::from_str(include_str!( + "../../../../../../schema/meta_schemas/document/v3/document-meta.json" + )) + .expect("the meta-schema is JSON"); + let listed = meta_schema + .pointer("/$defs/documentSchema/properties/generatedFrom/properties/function/enum") + .and_then(|names| names.as_array()) + .expect("the meta-schema lists the functions"); + let names: Vec<&str> = SystemFunction::ALL + .iter() + .map(SystemFunction::as_str) + .collect(); + assert_eq!( + listed + .iter() + .map(|name| name.as_str().expect("a name")) + .collect::>(), + names + ); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/string_transformations.rs b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/string_transformations.rs new file mode 100644 index 00000000000..b7c09cc9818 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/string_transformations.rs @@ -0,0 +1,388 @@ +//! `sys.stringTransformations`: system functions from one string to a string. +//! +//! Every transformation changes ASCII characters only and keeps every other +//! character as it is: Unicode case mappings change between releases of the +//! standard library, and two nodes must never generate different values. + +/// A `sys.stringTransformations` function. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StringTransformation { + /// `sys.stringTransformations.camelCase`: [`camel_case`]. + CamelCase, + /// `sys.stringTransformations.capitalize`: [`capitalize`]. + Capitalize, + /// `sys.stringTransformations.homographSafeASCII`: [`homograph_safe_ascii`]. + HomographSafeAscii, + /// `sys.stringTransformations.lowercase`: [`lowercase`]. + Lowercase, + /// `sys.stringTransformations.snakeCase`: [`snake_case`]. + SnakeCase, + /// `sys.stringTransformations.uppercase`: [`uppercase`]. + Uppercase, +} + +impl StringTransformation { + /// Every string transformation, in wire-name order. + pub const ALL: [StringTransformation; 6] = [ + StringTransformation::CamelCase, + StringTransformation::Capitalize, + StringTransformation::HomographSafeAscii, + StringTransformation::Lowercase, + StringTransformation::SnakeCase, + StringTransformation::Uppercase, + ]; + + /// The wire name, the value of `generatedFrom.function`. + pub const fn as_str(&self) -> &'static str { + match self { + StringTransformation::CamelCase => "sys.stringTransformations.camelCase", + StringTransformation::Capitalize => "sys.stringTransformations.capitalize", + StringTransformation::HomographSafeAscii => { + "sys.stringTransformations.homographSafeASCII" + } + StringTransformation::Lowercase => "sys.stringTransformations.lowercase", + StringTransformation::SnakeCase => "sys.stringTransformations.snakeCase", + StringTransformation::Uppercase => "sys.stringTransformations.uppercase", + } + } + + /// The transformation of `source`. + pub fn apply(&self, source: &str) -> String { + match self { + StringTransformation::CamelCase => camel_case(source), + StringTransformation::Capitalize => capitalize(source), + StringTransformation::HomographSafeAscii => homograph_safe_ascii(source), + StringTransformation::Lowercase => lowercase(source), + StringTransformation::SnakeCase => snake_case(source), + StringTransformation::Uppercase => uppercase(source), + } + } +} + +/// `sys.stringTransformations.homographSafeASCII`: DPNS's label normalization +/// over ASCII. `A` to `Z` become lowercase, then `o` becomes `0` and `i` and +/// `l` become `1`. Every other character, ASCII or not, is kept as it is, so +/// the value keeps its length. +/// +/// On an ASCII value this is exactly `convert_to_homograph_safe_chars`; it +/// resists homographs only when the parameter's `pattern` restricts it to +/// ASCII, as DPNS's does. +pub fn homograph_safe_ascii(source: &str) -> String { + source + .chars() + .map(|character| match character.to_ascii_lowercase() { + 'o' => '0', + 'i' | 'l' => '1', + other => other, + }) + .collect() +} + +/// `sys.stringTransformations.lowercase`: `A` to `Z` become `a` to `z`. +pub fn lowercase(source: &str) -> String { + source.to_ascii_lowercase() +} + +/// `sys.stringTransformations.uppercase`: `a` to `z` become `A` to `Z`. +pub fn uppercase(source: &str) -> String { + source.to_ascii_uppercase() +} + +/// `sys.stringTransformations.capitalize`: the first character uppercase and +/// every other lowercase (`hELLO wORLD` becomes `Hello world`). +pub fn capitalize(source: &str) -> String { + let mut characters = source.chars(); + let Some(first) = characters.next() else { + return String::new(); + }; + let mut capitalized = String::with_capacity(source.len()); + capitalized.push(first.to_ascii_uppercase()); + capitalized.push_str(&characters.as_str().to_ascii_lowercase()); + capitalized +} + +/// `sys.stringTransformations.camelCase`: the [`words`] joined, the first +/// lowercase and every later one capitalized (`Hello world`, `hello_world` and +/// `HelloWorld` all become `helloWorld`). +pub fn camel_case(source: &str) -> String { + let mut camel = String::with_capacity(source.len()); + for (index, word) in words(source).into_iter().enumerate() { + if index == 0 { + camel.push_str(&lowercase(word)); + } else { + camel.push_str(&capitalize(word)); + } + } + camel +} + +/// `sys.stringTransformations.snakeCase`: the [`words`] lowercase, joined with +/// `_` (`Hello world`, `helloWorld` and `hello-world` all become +/// `hello_world`). +pub fn snake_case(source: &str) -> String { + words(source) + .into_iter() + .map(lowercase) + .collect::>() + .join("_") +} + +/// The words of `source`, as camelCase and snakeCase split it. Every ASCII +/// character that is neither a letter nor a digit separates words and is +/// dropped. A word also ends before an ASCII uppercase letter that follows any +/// other character than an ASCII uppercase letter (`helloWorld` is `hello`, +/// `World`), and before one that follows another and is followed by an ASCII +/// lowercase letter (`XMLHttp` is `XML`, `Http`). Characters outside ASCII are +/// word characters: they never separate words, and never change case. The +/// rules make camelCase and snakeCase idempotent: splitting their output gives +/// back the same words. +pub fn words(source: &str) -> Vec<&str> { + let characters: Vec<(usize, char)> = source.char_indices().collect(); + let mut words = Vec::new(); + let mut word_start: Option = None; + for (position, &(index, character)) in characters.iter().enumerate() { + if character.is_ascii() && !character.is_ascii_alphanumeric() { + if let Some(start) = word_start.take() { + words.push(&source[start..index]); + } + continue; + } + let Some(start) = word_start else { + word_start = Some(index); + continue; + }; + // The word holds the previous character: a separator would have ended it + let previous = characters[position - 1].1; + let next = characters.get(position + 1).map(|&(_, next)| next); + let starts_a_word = character.is_ascii_uppercase() + && (!previous.is_ascii_uppercase() + || next.is_some_and(|next| next.is_ascii_lowercase())); + if starts_a_word { + words.push(&source[start..index]); + word_start = Some(index); + } + } + if let Some(start) = word_start { + words.push(&source[start..]); + } + words +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::strings::convert_to_homograph_safe_chars; + + /// Every string of up to three characters over `alphabet`. + fn strings_up_to_three(alphabet: &[char]) -> Vec { + let mut strings = vec![String::new()]; + let mut last: Vec = vec![String::new()]; + for _ in 0..3 { + last = last + .iter() + .flat_map(|prefix| { + alphabet.iter().map(move |character| { + let mut string = prefix.clone(); + string.push(*character); + string + }) + }) + .collect(); + strings.extend(last.iter().cloned()); + } + strings + } + + /// Strings that exercise every splitting rule, plus characters outside + /// ASCII. + const SAMPLES: &[&str] = &[ + "", + "hello", + "Hello World", + "hello_world", + "hello-world", + "helloWorld", + "HelloWorld", + "XMLHttpRequest", + "version2Beta", + " leading and trailing ", + "__many___separators--", + "ALL CAPS", + "Olé Señor", + "oléSeñor", + "名前Lo", + "aBCd", + "hello 2World", + ]; + + #[test] + fn should_match_the_dpns_trigger_normalization_on_every_ascii_character() { + for byte in 0u8..=127 { + let character = char::from(byte).to_string(); + assert_eq!( + homograph_safe_ascii(&character), + convert_to_homograph_safe_chars(&character), + "character {byte:#04x}" + ); + } + } + + /// DPNS's `label` and `parentDomainName` patterns admit ASCII letters, digits and + /// `-`; its normalized parent may also hold `.`. + #[test] + fn should_match_the_dpns_trigger_normalization_over_the_dpns_alphabets() { + let alphabet: Vec = ('a'..='z') + .chain('A'..='Z') + .chain('0'..='9') + .chain(['-', '.']) + .collect(); + for value in strings_up_to_three(&alphabet) { + assert_eq!( + homograph_safe_ascii(&value), + convert_to_homograph_safe_chars(&value), + "{value:?}" + ); + } + for value in [ + "Bob", + "DASH", + "dash", + "Alice-In-Wonderland", + "LoOoIiLl-0123456789", + "a0b1c2-xyz-QRS-tuv-WXYZ-ok-oil-lol-io-Ol1ioL", + ] { + assert_eq!( + homograph_safe_ascii(value), + convert_to_homograph_safe_chars(value), + "{value:?}" + ); + } + } + + #[test] + fn should_generate_the_homograph_safe_form() { + assert_eq!(homograph_safe_ascii("Bob"), "b0b"); + assert_eq!(homograph_safe_ascii("ALICE"), "a11ce"); + assert_eq!(homograph_safe_ascii("L0l-Io"), "101-10"); + assert_eq!(homograph_safe_ascii(""), ""); + } + + #[test] + fn should_change_the_case_of_ascii_letters() { + assert_eq!(lowercase("Hello World 42"), "hello world 42"); + assert_eq!(uppercase("Hello World 42"), "HELLO WORLD 42"); + assert_eq!(capitalize("hELLO wORLD"), "Hello world"); + assert_eq!(capitalize("bob"), "Bob"); + assert_eq!(capitalize(" bob"), " bob"); + assert_eq!(capitalize(""), ""); + } + + #[test] + fn should_split_words_at_separators_and_case_changes() { + assert_eq!(words("Hello World"), ["Hello", "World"]); + assert_eq!( + words("hello_world-again.now"), + ["hello", "world", "again", "now"] + ); + assert_eq!(words("helloWorld"), ["hello", "World"]); + assert_eq!(words("XMLHttpRequest"), ["XML", "Http", "Request"]); + assert_eq!(words("version2Beta"), ["version2", "Beta"]); + assert_eq!(words("__many___separators--"), ["many", "separators"]); + assert_eq!(words("ALL CAPS"), ["ALL", "CAPS"]); + assert_eq!(words("Olé Señor"), ["Olé", "Señor"]); + assert_eq!(words("éB"), ["é", "B"]); + assert_eq!(words("aBCd"), ["a", "B", "Cd"]); + assert!(words("").is_empty()); + assert!(words("-_ .").is_empty()); + } + + #[test] + fn should_generate_camel_case_and_snake_case() { + for (source, camel, snake) in [ + ("Hello World", "helloWorld", "hello_world"), + ("hello_world", "helloWorld", "hello_world"), + ("hello-world", "helloWorld", "hello_world"), + ("helloWorld", "helloWorld", "hello_world"), + ("HelloWorld", "helloWorld", "hello_world"), + ("XMLHttpRequest", "xmlHttpRequest", "xml_http_request"), + ("version2Beta", "version2Beta", "version2_beta"), + ( + " leading and trailing ", + "leadingAndTrailing", + "leading_and_trailing", + ), + ("ALL CAPS", "allCaps", "all_caps"), + ("Olé Señor", "oléSeñor", "olé_señor"), + ("", "", ""), + ] { + assert_eq!(camel_case(source), camel, "{source:?}"); + assert_eq!(snake_case(source), snake, "{source:?}"); + } + } + + /// Characters outside ASCII are never changed, where a Unicode case mapping + /// would change some (the Kelvin sign lowercases to `k`, U+0130 to `i` + /// followed by a combining dot). + #[test] + fn should_keep_every_character_outside_ascii() { + let non_ascii = |value: &str| value.chars().filter(|c| !c.is_ascii()).collect::(); + for value in [ + "\u{212A}", + "\u{0130}", + "Ωmega", + "bоb", + "名前", + "é", + "Dž", + "🙂", + "Olé Señor", + ] { + for transformation in StringTransformation::ALL { + assert_eq!( + non_ascii(&transformation.apply(value)), + non_ascii(value), + "{} of {value:?}", + transformation.as_str() + ); + } + } + assert_eq!(homograph_safe_ascii("\u{212A}"), "\u{212A}"); + assert_ne!(convert_to_homograph_safe_chars("\u{212A}"), "\u{212A}"); + assert_eq!(uppercase("ω"), "ω"); + assert_eq!(homograph_safe_ascii("Olé"), "01é"); + } + + /// Every transformation applied to its own output changes nothing more. + #[test] + fn should_be_idempotent() { + for transformation in StringTransformation::ALL { + for value in SAMPLES { + let once = transformation.apply(value); + assert_eq!( + transformation.apply(&once), + once, + "{} of {value:?}", + transformation.as_str() + ); + } + } + } + + /// The case changes keep the byte length, camelCase never grows a value, and + /// snakeCase grows it by at most one `_` per character. + #[test] + fn should_bound_the_length_of_the_generated_value() { + for value in SAMPLES { + for transformation in [ + StringTransformation::Capitalize, + StringTransformation::HomographSafeAscii, + StringTransformation::Lowercase, + StringTransformation::Uppercase, + ] { + assert_eq!(transformation.apply(value).len(), value.len(), "{value:?}"); + } + assert!(camel_case(value).len() <= value.len(), "{value:?}"); + assert!(snake_case(value).len() <= 2 * value.len(), "{value:?}"); + } + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index 005341e4760..56b91fc6ad2 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -41,11 +41,13 @@ use serde::{Deserialize, Serialize}; pub mod array; pub mod encrypted_for; +pub mod generated_from; pub mod list_element_reference; pub mod reference_expression; pub mod reference_lookup; pub use encrypted_for::{EncryptedFor, EncryptedForRecipient, EncryptionScheme}; +pub use generated_from::{GeneratedFrom, GenerationParam, StringTransformation, SystemFunction}; pub use list_element_reference::ListElementReference; pub use reference_expression::{ ReferenceCombinator, ReferenceOperands, COMBINABLE_REFERENCE_TARGET_TYPES, @@ -80,6 +82,11 @@ pub struct DocumentProperty { /// and only on contracts parsed from protocol version 14 on. #[serde(skip_serializing_if = "Option::is_none")] pub encrypted_for: Option, + /// The function the platform generates this property's value with, and + /// the properties it reads (`generatedFrom`). Only ever `Some` on a string + /// property, and only on contracts parsed from protocol version 14 on. + #[serde(skip_serializing_if = "Option::is_none")] + pub generated_from: Option, } /// What a `distinctFrom` identifier property must differ from. @@ -4562,6 +4569,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); sub_fields.insert( @@ -4573,6 +4581,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -7400,6 +7409,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); inner_fields.insert( @@ -7411,6 +7421,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7465,6 +7476,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7487,6 +7499,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); inner_fields.insert( @@ -7498,6 +7511,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7935,6 +7949,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7972,6 +7987,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); sub_fields.insert( @@ -7983,6 +7999,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -8003,6 +8020,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); sub_fields.insert( @@ -8014,6 +8032,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -8309,6 +8328,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); sub_fields.insert( @@ -8320,6 +8340,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -8383,6 +8404,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); sub_fields.insert( @@ -8394,6 +8416,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -8483,6 +8506,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); sub_fields.insert( @@ -8494,6 +8518,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -8765,6 +8790,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -8796,6 +8822,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); // Second field is required @@ -8808,6 +8835,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -8928,6 +8956,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -8948,6 +8977,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -9257,6 +9287,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -9523,6 +9554,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }; let value = serde_json::to_value(&property).expect("serialization should succeed"); @@ -9546,6 +9578,7 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, }; let value = serde_json::to_value(&property).expect("serialization should succeed"); diff --git a/packages/rs-dpp/src/data_contract/document_type/random_document.rs b/packages/rs-dpp/src/data_contract/document_type/random_document.rs index e91115413e0..f1a5af790b2 100644 --- a/packages/rs-dpp/src/data_contract/document_type/random_document.rs +++ b/packages/rs-dpp/src/data_contract/document_type/random_document.rs @@ -1,9 +1,11 @@ use bincode::{Decode, DecodeUntrusted, Encode}; use std::time::{SystemTime, UNIX_EPOCH}; -use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; use crate::data_contract::document_type::methods::DocumentTypeV0Methods; -use crate::data_contract::document_type::{DocumentType, DocumentTypeRef}; +use crate::data_contract::document_type::{DocumentPropertyType, DocumentType, DocumentTypeRef}; use crate::document::property_names::{ CREATED_AT, CREATED_AT_BLOCK_HEIGHT, CREATED_AT_CORE_BLOCK_HEIGHT, TRANSFERRED_AT, TRANSFERRED_AT_BLOCK_HEIGHT, TRANSFERRED_AT_CORE_BLOCK_HEIGHT, UPDATED_AT, @@ -15,9 +17,13 @@ use crate::identity::Identity; use crate::prelude::{BlockHeight, CoreBlockHeight, TimestampMillis}; use crate::version::PlatformVersion; use crate::ProtocolError; -use platform_value::{Bytes32, Identifier}; +use platform_value::btreemap_extensions::{ + BTreeValueMapInsertionPathHelper, BTreeValueMapPathHelper, +}; +use platform_value::{Bytes32, Identifier, Value}; use rand::prelude::StdRng; use rand::SeedableRng; +use std::collections::BTreeMap; #[derive(Clone, Copy, Debug, Eq, PartialEq, Encode, Decode, DecodeUntrusted)] pub enum DocumentFieldFillType { @@ -39,7 +45,9 @@ pub enum DocumentFieldFillSize { // TODO The factory is used in benchmark and tests. Probably it should be available under the test feature /// Functions for creating various types of random documents. -pub trait CreateRandomDocument: DocumentTypeV0Getters + DocumentTypeV0Methods { +pub trait CreateRandomDocument: + DocumentTypeV0Getters + DocumentTypeV2Getters + DocumentTypeV0Methods +{ /// Generates a single random document, employing default behavior for document field /// filling where fields that are not required will not be filled (`DoNotFillIfNotRequired`) and /// any fill size that is contractually allowed may be used (`AnyDocumentFillSize`). @@ -223,24 +231,15 @@ pub trait CreateRandomDocument: DocumentTypeV0Getters + DocumentTypeV0Methods { entropy.as_slice(), ); // dbg!("gen", hex::encode(id), hex::encode(&self.data_contract_id), hex::encode(&owner_id), self.name.as_str(), hex::encode(entropy.as_slice())); - let properties = self + let mut properties: BTreeMap = self .properties() .iter() .filter_map(|(key, property)| { if property.required || document_field_fill_type == DocumentFieldFillType::FillIfNotRequired { - let value = match document_field_fill_size { - DocumentFieldFillSize::MinDocumentFillSize => { - property.property_type.random_sub_filled_value(rng) - } - DocumentFieldFillSize::MaxDocumentFillSize => { - property.property_type.random_filled_value(rng) - } - DocumentFieldFillSize::AnyDocumentFillSize => { - property.property_type.random_value(rng) - } - }; + let value = + random_value_of(&property.property_type, document_field_fill_size, rng); Some((key.clone(), value)) } else { None @@ -248,6 +247,34 @@ pub trait CreateRandomDocument: DocumentTypeV0Getters + DocumentTypeV0Methods { }) .collect(); + // A random value is not what a function generates: replace the one drawn for each + // `generatedFrom` property with the value generated from its params. A drawn + // property whose params were not drawn (optional ones, or inside an optional object) + // gets them drawn first, so a required generated property is not left out. + for (path, generated_from) in self.generated_from_fields() { + if !matches!(properties.get_optional_at_path(path), Ok(Some(_))) { + continue; + } + for param in generated_from.property_params() { + let head = param.split_once('.').map_or(param, |(head, _)| head); + if !properties.contains_key(head) { + if let Some(property) = self.properties().get(head) { + let value = + random_value_of(&property.property_type, document_field_fill_size, rng); + properties.insert(head.to_string(), value); + } + } + if matches!(properties.get_optional_at_path(param), Ok(None)) { + if let Some(property) = self.flattened_properties().get(param) { + let value = + random_value_of(&property.property_type, document_field_fill_size, rng); + properties.insert_at_path(param, value)?; + } + } + } + } + self.regenerate_generated_properties(&mut properties, platform_version)?; + let revision = if self.requires_revision() { Some(INITIAL_REVISION) } else { @@ -463,6 +490,19 @@ pub trait CreateRandomDocument: DocumentTypeV0Getters + DocumentTypeV0Methods { } } +/// A random value of `property_type` of the requested fill size. +fn random_value_of( + property_type: &DocumentPropertyType, + document_field_fill_size: DocumentFieldFillSize, + rng: &mut StdRng, +) -> Value { + match document_field_fill_size { + DocumentFieldFillSize::MinDocumentFillSize => property_type.random_sub_filled_value(rng), + DocumentFieldFillSize::MaxDocumentFillSize => property_type.random_filled_value(rng), + DocumentFieldFillSize::AnyDocumentFillSize => property_type.random_value(rng), + } +} + impl CreateRandomDocument for DocumentType {} impl CreateRandomDocument for DocumentTypeRef<'_> {} diff --git a/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs b/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs index 3e85e064161..8d637b04d1a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs @@ -201,6 +201,7 @@ impl DocumentTypeV0 { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, } }; @@ -596,6 +597,7 @@ impl DocumentTypeV0 { required_since: None, distinct_from: None, encrypted_for: None, + generated_from: None, } }; diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs index afd600a178c..bd413e12573 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs @@ -6,7 +6,7 @@ use crate::data_contract::document_type::action_fees::DocumentActionFees; use crate::data_contract::document_type::index::Index; use crate::data_contract::document_type::index_level::IndexLevel; use crate::data_contract::document_type::property::{ - DocumentProperty, DocumentPropertyReferenceTarget, + DocumentProperty, DocumentPropertyReferenceTarget, GeneratedFrom, }; use platform_value::{Identifier, Value}; @@ -266,6 +266,10 @@ impl DocumentTypeV2Getters for DocumentTypeV2 { &self.distinct_from_fields } + fn generated_from_fields(&self) -> &[(String, GeneratedFrom)] { + &self.generated_from_fields + } + fn immutable_fields_allow_setting(&self) -> &BTreeSet { &self.immutable_fields_allow_setting } diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index e18aee31c3d..042fa0a29c8 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::data_contract::document_type::index::Index; use crate::data_contract::document_type::index_level::IndexLevel; -use crate::data_contract::document_type::property::DocumentProperty; +use crate::data_contract::document_type::property::{DocumentProperty, GeneratedFrom}; use crate::data_contract::storage_requirements::keys_for_document_type::StorageKeyRequirements; use crate::data_contract::document_type::action_fees::DocumentActionFees; @@ -61,6 +61,11 @@ pub struct DocumentTypeV2 { /// (protocol version 14), in schema order, so a document write finds /// them without walking every property. Empty on every pre-PV14 contract. pub(in crate::data_contract) distinct_from_fields: Vec, + /// The dotted path of every property that declares `generatedFrom` + /// (protocol version 14) with its declaration, in schema order, so a + /// document write finds them without walking every property. Empty on + /// every pre-PV14 contract. + pub(in crate::data_contract) generated_from_fields: Vec<(String, GeneratedFrom)>, /// On an indexOnly type, the top-level properties stored in every entry's /// value after the row commitment (the `entryPayload` keyword), in name /// order. Empty on every other type and on every pre-PV14 contract. @@ -233,9 +238,21 @@ fn distinct_from_fields_of( .collect() } +/// The properties that declare `generatedFrom`, with their declarations, in the +/// flattened map's (schema) order. +fn generated_from_fields_of( + flattened_properties: &IndexMap, +) -> Vec<(String, GeneratedFrom)> { + flattened_properties + .iter() + .filter_map(|(path, property)| Some((path.clone(), property.generated_from.clone()?))) + .collect() +} + impl From for DocumentTypeV2 { fn from(value: DocumentTypeV0) -> Self { let distinct_from_fields = distinct_from_fields_of(&value.flattened_properties); + let generated_from_fields = generated_from_fields_of(&value.flattened_properties); DocumentTypeV2 { name: value.name, schema: value.schema, @@ -250,6 +267,7 @@ impl From for DocumentTypeV2 { immutable_fields: BTreeSet::new(), immutable_fields_allow_setting: BTreeSet::new(), distinct_from_fields, + generated_from_fields, entry_payload: BTreeSet::new(), documents_keep_history: value.documents_keep_history, documents_keep_transfer_history: value.documents_keep_transfer_history, @@ -288,6 +306,7 @@ impl From for DocumentTypeV2 { impl From for DocumentTypeV2 { fn from(value: DocumentTypeV1) -> Self { let distinct_from_fields = distinct_from_fields_of(&value.flattened_properties); + let generated_from_fields = generated_from_fields_of(&value.flattened_properties); DocumentTypeV2 { name: value.name, schema: value.schema, @@ -302,6 +321,7 @@ impl From for DocumentTypeV2 { immutable_fields: BTreeSet::new(), immutable_fields_allow_setting: BTreeSet::new(), distinct_from_fields, + generated_from_fields, entry_payload: BTreeSet::new(), documents_keep_history: value.documents_keep_history, documents_keep_transfer_history: value.documents_keep_transfer_history, diff --git a/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs b/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs index a6d650021a0..21804afe7db 100644 --- a/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs @@ -111,6 +111,15 @@ impl DataContract { let max_bytes_result = document_type.validate_max_bytes_properties(&value, platform_version)?; + // Added in place at protocol version 14, inert before it: the meta-schemas there + // refuse `generatedFrom`, their parser ignores it (`apply_generated_from` is + // `None`, so no property carries a declaration) and `validate_generated_from` is + // `None`, so the check returns an empty result without reading `value`. Computed and + // reported like `maxBytes`, after it, so a schema error keeps precedence and every + // value it compares is known to be a string. + let generated_from_result = + document_type.validate_generated_from_properties(&value, platform_version)?; + // Added in place at protocol version 14, inert for every earlier version that // selects this generation: `validate_property_constraints` is `None` in all of their // tables, so the call returns an empty result without reading `value`. (Only parser @@ -157,6 +166,9 @@ impl DataContract { if !max_bytes_result.is_valid() { return Ok(max_bytes_result); } + if !generated_from_result.is_valid() { + return Ok(generated_from_result); + } Ok(property_constraints_result) } diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index 211839e6dbf..14c0d1127e7 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -55,10 +55,11 @@ use crate::consensus::basic::document::{ ContestedDocumentsTemporarilyNotAllowedError, DataContractNotPresentError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, DocumentPropertyConstraintViolatedError, DocumentPropertyMaxBytesExceededError, - DocumentPropertyNotDistinctError, DocumentTransitionsAreAbsentError, - DuplicateDocumentTransitionsWithIdsError, DuplicateDocumentTransitionsWithIndicesError, - InconsistentCompoundIndexDataError, InvalidDocumentTransitionActionError, - InvalidDocumentTransitionIdError, InvalidDocumentTypeError, InvalidEncryptedPropertyShapeError, + DocumentPropertyNotDistinctError, DocumentPropertyNotGeneratedError, + DocumentTransitionsAreAbsentError, DuplicateDocumentTransitionsWithIdsError, + DuplicateDocumentTransitionsWithIndicesError, InconsistentCompoundIndexDataError, + InvalidDocumentTransitionActionError, InvalidDocumentTransitionIdError, + InvalidDocumentTypeError, InvalidEncryptedPropertyShapeError, MaxDocumentsTransitionsExceededError, MissingDataContractIdBasicError, MissingDocumentTransitionActionError, MissingDocumentTransitionTypeError, MissingDocumentTypeError, MissingPositionsInDocumentTypePropertiesError, NonceOutOfBoundsError, @@ -835,6 +836,11 @@ pub enum BasicError { InvalidTokenDistributionEpochIntervalTooShortError( InvalidTokenDistributionEpochIntervalTooShortError, ), + + // A `generatedFrom` string property that is not what its function generates from its params + // (protocol version 14). + #[error(transparent)] + DocumentPropertyNotGeneratedError(DocumentPropertyNotGeneratedError), } impl From for ConsensusError { @@ -988,6 +994,19 @@ mod tests { ), 199 ); + // A `generatedFrom` property that is not what its function generates (protocol + // version 14). + assert_eq!( + discriminant_of(BasicError::DocumentPropertyNotGeneratedError( + DocumentPropertyNotGeneratedError::new( + "domain".to_string(), + "normalizedLabel".to_string(), + "sys.stringTransformations.homographSafeASCII".to_string(), + vec!["label".to_string()], + ) + )), + 200 + ); } /// The variants that shipped in 4.1 keep the discriminants they were released with, so an diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/document_property_not_generated_error.rs b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_not_generated_error.rs new file mode 100644 index 00000000000..28788ded6a8 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_not_generated_error.rs @@ -0,0 +1,87 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A `generatedFrom` property of the written document is not what its function generates +/// from its parameters: its value differs, or it is present while a parameter is absent (or +/// absent while every parameter is present, which the platform only sees when a document +/// skipped the generation it runs on arrival). +/// +/// A pure structure check on document create and replace (protocol version 14): it reads +/// the document alone, so it is a basic error, not a state one. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Document type \"{document_type_name}\" property \"{property}\" must be what {function} \ + generates from {}, and absent when any of them is", + .params.join(", ") +)] +#[platform_serialize(unversioned)] +pub struct DocumentPropertyNotGeneratedError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + document_type_name: String, + /// Dotted path of the declaring property within the document type. + property: String, + /// The function's wire name, such as `sys.stringTransformations.homographSafeASCII`. + function: String, + /// Dotted paths of the properties the function reads, in order. + params: Vec, +} + +impl DocumentPropertyNotGeneratedError { + pub fn new( + document_type_name: String, + property: String, + function: String, + params: Vec, + ) -> Self { + Self { + document_type_name, + property, + function, + params, + } + } + + pub fn document_type_name(&self) -> &str { + &self.document_type_name + } + + pub fn property(&self) -> &str { + &self.property + } + + pub fn function(&self) -> &str { + &self.function + } + + pub fn params(&self) -> &[String] { + &self.params + } +} + +impl From for ConsensusError { + fn from(err: DocumentPropertyNotGeneratedError) -> Self { + Self::BasicError(BasicError::DocumentPropertyNotGeneratedError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs index 5af2ea022d9..d991d43deba 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs @@ -5,6 +5,7 @@ mod document_field_max_size_exceeded_error; mod document_property_constraint_violated_error; mod document_property_max_bytes_exceeded_error; mod document_property_not_distinct_error; +mod document_property_not_generated_error; mod document_transitions_are_absent_error; mod duplicate_document_transitions_with_ids_error; mod duplicate_document_transitions_with_indices_error; @@ -28,6 +29,7 @@ pub use document_field_max_size_exceeded_error::*; pub use document_property_constraint_violated_error::*; pub use document_property_max_bytes_exceeded_error::*; pub use document_property_not_distinct_error::*; +pub use document_property_not_generated_error::*; pub use document_transitions_are_absent_error::*; pub use duplicate_document_transitions_with_ids_error::*; pub use duplicate_document_transitions_with_indices_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index af4c8d69a95..4c9132fdbe2 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -170,6 +170,7 @@ impl ErrorWithCode for BasicError { Self::InvalidEncryptedPropertyShapeError(_) => 10420, Self::DocumentPropertyMaxBytesExceededError(_) => 10421, Self::DocumentPropertyConstraintViolatedError(_) => 10422, + Self::DocumentPropertyNotGeneratedError(_) => 10424, // Token Errors: 10450-10499 Self::InvalidTokenIdError(_) => 10450, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs index f6cc1681d66..1e9786c1ca1 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs @@ -1,4 +1,6 @@ -use crate::data_contract::document_type::methods::DocumentTypeV0Methods; +use crate::data_contract::document_type::methods::{ + DocumentTypeBasicMethods, DocumentTypeV0Methods, +}; use crate::data_contract::document_type::DocumentTypeRef; use crate::document::{Document, DocumentV0Getters}; use crate::prelude::IdentityNonce; @@ -30,6 +32,13 @@ impl DocumentCreateTransitionV0 { platform_version, )?; } + // Every `generatedFrom` property is set to what the platform generates from the + // document's params, replacing a value the document holds, so the contest resolution + // below and the transition see the value the platform will store. Inert before + // protocol version 14: the `fill_generated_properties` slot is `None` there and + // leaves the document as it is. + document_type + .regenerate_generated_properties(document.properties_mut(), platform_version)?; let prefunded_voting_balance = document_type.prefunded_voting_balance_for_document(&document, platform_version)?; Ok(DocumentCreateTransitionV0 { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs index c151f5c1183..5f3ef1a8f69 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs @@ -307,7 +307,12 @@ impl DocumentFromCreateTransitionV0 for Document { where Self: Sized, { - let DocumentCreateTransitionV0 { base, data, .. } = v0; + let DocumentCreateTransitionV0 { base, mut data, .. } = v0; + + // The document the platform stores holds every generated property the transition + // left out, generated on arrival. Inert before protocol version 14: the + // `fill_generated_properties` slot is `None` there and leaves the data as it is. + document_type.fill_generated_properties(&mut data, platform_version)?; let requires_created_at = document_type .required_fields() @@ -422,7 +427,11 @@ impl DocumentFromCreateTransitionV0 for Document { .required_fields() .contains(document::property_names::CREATED_AT); - let properties = data.clone(); + let mut properties = data.clone(); + // The document the platform stores holds every generated property the transition + // left out, generated on arrival. Inert before protocol version 14: the + // `fill_generated_properties` slot is `None` there and leaves the data as it is. + document_type.fill_generated_properties(&mut properties, platform_version)?; let creator_id = if document_type.should_use_creator_id( contract.system_version_type(), diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_index_only_delete_transition/v0/from_document.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_index_only_delete_transition/v0/from_document.rs index 5559012f7ac..d5dac07542e 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_index_only_delete_transition/v0/from_document.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_index_only_delete_transition/v0/from_document.rs @@ -1,4 +1,5 @@ use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; use crate::data_contract::document_type::DocumentTypeRef; use crate::document::property_names::CREATED_AT; use crate::document::{Document, DocumentV0Getters}; @@ -39,6 +40,12 @@ impl DocumentIndexOnlyDeleteTransitionV0 { // validation accepts. data: { let mut data = document.properties().clone(); + // The values name the entry the way its create stored it, every + // `generatedFrom` property generated from its params as the platform + // generates it. Inert before protocol version 14: the + // `fill_generated_properties` slot is `None` there and leaves the values + // as they are. + document_type.regenerate_generated_properties(&mut data, platform_version)?; if document_type.required_fields().contains(CREATED_AT) { let created_at = document.created_at().ok_or_else(|| { ProtocolError::Generic(format!( diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/v0/from_document.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/v0/from_document.rs index c51afdfceb4..f4c76b9c55a 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/v0/from_document.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/v0/from_document.rs @@ -1,3 +1,4 @@ +use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; use crate::data_contract::document_type::DocumentTypeRef; use crate::document::errors::DocumentError; use crate::document::{Document, DocumentV0Getters}; @@ -17,6 +18,14 @@ impl DocumentReplaceTransitionV0 { platform_version: &PlatformVersion, base_feature_version: Option, ) -> Result { + // The transition carries every `generatedFrom` property as the platform generates + // it from the document's params, replacing a value the document holds: a document + // fetched and edited still holds the one generated from its old params, which the + // platform refuses. Inert before protocol version 14: the + // `fill_generated_properties` slot is `None` there and leaves the document as it is. + let mut document = document; + document_type + .regenerate_generated_properties(document.properties_mut(), platform_version)?; Ok(DocumentReplaceTransitionV0 { base: DocumentBaseTransition::from_document( &document, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/v0/mod.rs index 3e72a89a327..404d52c8bda 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/v0/mod.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::block::block_info::BlockInfo; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; use crate::data_contract::document_type::DocumentTypeRef; use crate::document::{Document, DocumentV0}; use crate::{document, ProtocolError}; @@ -197,6 +198,12 @@ impl DocumentFromReplaceTransitionV0 for Document { data, } = value; + // The document the platform stores holds every generated property the transition + // left out, generated on arrival. Inert before protocol version 14: the + // `fill_generated_properties` slot is `None` there and leaves the data as it is. + let mut data = data.clone(); + document_type.fill_generated_properties(&mut data, platform_version)?; + let id = base.id(); let requires_updated_at = document_type @@ -238,7 +245,7 @@ impl DocumentFromReplaceTransitionV0 for Document { contract_version: None, id, owner_id, - properties: data.clone(), + properties: data, revision: Some(*revision), created_at, updated_at, @@ -277,9 +284,14 @@ impl DocumentFromReplaceTransitionV0 for Document { let DocumentReplaceTransitionV0 { base, revision, - data, + mut data, } = value; + // The document the platform stores holds every generated property the transition + // left out, generated on arrival. Inert before protocol version 14: the + // `fill_generated_properties` slot is `None` there and leaves the data as it is. + document_type.fill_generated_properties(&mut data, platform_version)?; + let id = base.id(); let requires_updated_at = document_type diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from.rs new file mode 100644 index 00000000000..2835e251d2d --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from.rs @@ -0,0 +1,927 @@ +//! End-to-end coverage for the `generatedFrom` property keyword (protocol +//! version 14): a string property the platform generates with a built-in +//! function of other properties of the same document, here +//! `sys.stringTransformations.homographSafeASCII`. When a created or replaced +//! document leaves it out, the action transformer generates it from its params +//! before anything reads the document; when the document supplies it, the +//! document validation checks it after the JSON schema, and a wrong value, or +//! one without its params, is consensus-rejected and leaves the stored document +//! untouched. + +use super::*; + +mod generated_from_tests { + use super::*; + use crate::execution::validation::state_transition::batch::action_validation::document::document_replace_transition_action::DocumentReplaceTransitionActionValidation; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::consensus::basic::BasicError; + use dpp::consensus::state::state_error::StateError; + use dpp::data_contract::schema::DataContractSchemaMethodsV0; + use dpp::document::Document; + use dpp::identity::{Identity, IdentityPublicKey, SecurityLevel}; + use dpp::platform_value::platform_value; + use dpp::prelude::Identifier; + use dpp::prelude::{DataContract, IdentityNonce}; + use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; + use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; + use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; + use dpp::state_transition::batch_transition::batched_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; + use dpp::state_transition::batch_transition::batched_transition::{ + BatchedTransitionMutRef, BatchedTransitionRef, + }; + use dpp::state_transition::proof_result::StateTransitionProofResult; + use dpp::state_transition::StateTransition; + use dpp::tests::fixtures::get_data_contract_fixture; + use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; + use drive::drive::contract::DataContractFetchInfo; + use drive::drive::Drive; + use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionV0}; + use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{DocumentCreateTransitionAction, DocumentCreateTransitionActionAccessorsV0}; + use drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::{DocumentReplaceTransitionAction, DocumentReplaceTransitionActionAccessorsV0, DocumentReplaceTransitionActionV0}; + use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; + use drive::state_transition_action::batch::batched_transition::BatchedTransitionAction; + use drive::util::storage_flags::StorageFlags; + use simple_signer::signer::SimpleSigner; + use std::collections::{BTreeMap, BTreeSet}; + use std::sync::Arc; + + /// A mutable `handle` type shaped like DPNS's domain: a `label` and its + /// required `normalizedLabel`, unique across handles, and an optional + /// `parent` with its optional `normalizedParent`, each generated from its + /// counterpart. The params' patterns hold them to ASCII, as DPNS's do; the + /// generated properties need none of their own, since each can only hold + /// what its function generates. + fn handle_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "indices": [ + { + "name": "byNormalizedLabel", + "properties": [{ "normalizedLabel": "asc" }], + "unique": true + } + ], + "properties": { + "label": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]{1,32}$", + "maxLength": 32, + "position": 0 + }, + "normalizedLabel": { + "type": "string", + "maxLength": 32, + "generatedFrom": { + "function": "sys.stringTransformations.homographSafeASCII", + "params": ["label"] + }, + "position": 1 + }, + "parent": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]{1,32}$", + "maxLength": 32, + "position": 2 + }, + "normalizedParent": { + "type": "string", + "maxLength": 32, + "generatedFrom": { + "function": "sys.stringTransformations.homographSafeASCII", + "params": ["parent"] + }, + "position": 3 + } + }, + "required": ["label", "normalizedLabel"], + "additionalProperties": false + }) + } + + /// An indexOnly `entry` type: a `name` and its `normalizedName`, both + /// stored only in the one index, whose terminal is the owner. + fn entry_schema() -> Value { + platform_value!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "indices": [ + { + "name": "byNormalizedName", + "properties": [{ "normalizedName": "asc" }, { "name": "asc" }], + "terminal": "$ownerId" + } + ], + "properties": { + "name": { "type": "string", "pattern": "^[a-zA-Z0-9-]{1,32}$", "maxLength": 32, "position": 0 }, + "normalizedName": { + "type": "string", + "maxLength": 32, + "generatedFrom": { + "function": "sys.stringTransformations.homographSafeASCII", + "params": ["name"] + }, + "position": 1 + } + }, + "required": ["name", "normalizedName"], + "additionalProperties": false + }) + } + + /// An immutable `name` type whose unique index over `normalizedLabel` is + /// contested, as DPNS's `domain` is: a short normalized label opens a + /// masternode vote. + fn name_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": false, + "indices": [ + { + "name": "byNormalizedLabel", + "properties": [{ "normalizedLabel": "asc" }], + "unique": true, + "contested": { + "fieldMatches": [ + { "field": "normalizedLabel", "regexPattern": "^[a-zA-Z01-]{3,19}$" } + ], + "resolution": 0 + } + } + ], + "properties": { + "label": { "type": "string", "pattern": "^[a-zA-Z0-9-]{3,32}$", "maxLength": 32, "position": 0 }, + "normalizedLabel": { + "type": "string", + "maxLength": 32, + "generatedFrom": { + "function": "sys.stringTransformations.homographSafeASCII", + "params": ["label"] + }, + "position": 1 + } + }, + "required": ["label", "normalizedLabel"], + "additionalProperties": false + }) + } + + fn text(value: &str) -> Value { + Value::Text(value.to_string()) + } + + /// One identity and one contract holding the `handle`, `entry` and `name` + /// types above. + struct HandleFixture { + platform: TempPlatform, + signer: SimpleSigner, + key: IdentityPublicKey, + identity: Identity, + contract: DataContract, + /// The identity contract nonce the next transition uses. Every + /// processed transition consumes one, including the ones that fail + /// with a paid consensus error. + next_nonce: IdentityNonce, + } + + impl HandleFixture { + fn new() -> Self { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.5)); + + let mut contract = get_data_contract_fixture( + Some(identity.id()), + 0, + platform_version.protocol_version, + ) + .data_contract_owned(); + for (name, schema) in [ + ("handle", handle_schema()), + ("entry", entry_schema()), + ("name", name_schema()), + ] { + contract + .set_document_schema(name, schema, true, &mut Vec::new(), platform_version) + .unwrap_or_else(|e| panic!("expected to add the {name} document type: {e}")); + } + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + + Self { + platform, + signer, + key, + identity, + contract, + next_nonce: 1, + } + } + + /// A create transition for a document of `type_name` holding `properties`, + /// and the document. The builder generates a property the + /// document leaves out, as the platform would; `as_given` sends the + /// document exactly as `properties` says instead, so what the test sees is + /// the platform's own computation. + async fn create_transition_of( + &mut self, + type_name: &str, + properties: Value, + seed: u64, + as_given: bool, + ) -> (Document, StateTransition) { + let platform_version = PlatformVersion::latest(); + let document_type = self + .contract + .document_type_for_name(type_name) + .expect("expected the document type"); + let mut rng = StdRng::seed_from_u64(seed); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + &mut rng, + self.identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document + .set_id_for_creation(document_type, &entropy.0, self.next_nonce, platform_version) + .expect("expected to set the document id"); + let properties = properties + .into_btree_string_map() + .expect("the properties are a map"); + *document.properties_mut() = properties.clone(); + + let transition = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + document_type, + entropy.0, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + self.next_nonce += 1; + let transition = if as_given { + self.send_as_given(transition, properties).await + } else { + transition + }; + (document, transition) + } + + /// A create transition for a handle holding exactly `properties`. + async fn create_transition(&mut self, properties: Value, seed: u64) -> StateTransition { + self.create_transition_of("handle", properties, seed, true) + .await + .1 + } + + /// `transition` with its document's data set to exactly `data`, signed + /// again. + async fn send_as_given( + &self, + mut transition: StateTransition, + data: BTreeMap, + ) -> StateTransition { + { + let StateTransition::Batch(batch) = &mut transition else { + panic!("expected a batch transition"); + }; + let Some(BatchedTransitionMutRef::Document(document_transition)) = + batch.first_transition_mut() + else { + panic!("expected a document transition"); + }; + *document_transition + .data_mut() + .expect("the document transition carries data") = data; + } + transition + .sign_external( + &self.key, + &self.signer, + Some(|_, _| Ok(SecurityLevel::HIGH)), + ) + .await + .expect("expected to sign the transition again"); + transition + } + + async fn create(&mut self, properties: Value, seed: u64) -> StateTransitionExecutionResult { + let transition = self.create_transition(properties, seed).await; + self.process(&transition) + } + + /// A replace transition for `stored` with `mutate` applied and the revision + /// bumped, sent as given. + async fn replace_transition( + &mut self, + stored: &Document, + mutate: impl FnOnce(&mut Document), + ) -> StateTransition { + let platform_version = PlatformVersion::latest(); + let mut replacement = stored.clone(); + mutate(&mut replacement); + replacement + .increment_revision() + .expect("expected the revision to increment"); + let handle_type = self + .contract + .document_type_for_name("handle") + .expect("expected the handle document type"); + let properties = replacement.properties().clone(); + let transition = BatchTransition::new_document_replacement_transition_from_document( + replacement, + handle_type, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition"); + self.next_nonce += 1; + self.send_as_given(transition, properties).await + } + + /// Replaces `stored` with `mutate` applied and the revision bumped. + async fn replace( + &mut self, + stored: &Document, + mutate: impl FnOnce(&mut Document), + ) -> StateTransitionExecutionResult { + let transition = self.replace_transition(stored, mutate).await; + self.process(&transition) + } + + /// Proves the committed state `transition` wrote and verifies the proof as + /// a client holding the contract does. + fn verify_proof(&self, transition: &StateTransition) -> StateTransitionProofResult { + let platform_version = PlatformVersion::latest(); + let proof = self + .platform + .drive + .prove_state_transition(transition, None, platform_version) + .expect("expected to prove the state transition") + .into_data() + .expect("expected proof bytes"); + let known_contracts: BTreeMap = + BTreeMap::from([(self.contract.id(), self.contract.clone())]); + let (_, outcome) = Drive::verify_state_transition_was_executed_with_proof( + transition, + &BlockInfo::default(), + &proof, + &|id| Ok(known_contracts.get(id).cloned().map(Arc::new)), + platform_version, + ) + .expect("expected the proof to verify"); + outcome.into_result() + } + + fn process(&self, transition: &StateTransition) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let platform_state = self.platform.state.load(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the transition to serialize"); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) + } + + /// The stored handles, read back from Drive. + fn stored_handles(&self) -> Vec { + let platform_version = PlatformVersion::latest(); + let query = DriveDocumentQuery::from_sql_expr( + "select * from handle", + &self.contract, + Some(&self.platform.config.drive), + platform_version, + ) + .expect("expected a document query"); + self.platform + .drive + .query_documents(query, None, false, None, None) + .expect("expected a query result") + .documents() + .to_vec() + } + + /// The contract as Drive hands it to the transformers and validators. + fn contract_fetch_info(&self) -> Arc { + let (_, contract_fetch_info) = self + .platform + .drive + .get_contract_with_fetch_info_and_fee( + self.contract.id().to_buffer(), + None, + false, + None, + PlatformVersion::latest(), + ) + .expect("expected to fetch the contract"); + contract_fetch_info.expect("the contract is in state") + } + } + + fn assert_success(result: &StateTransitionExecutionResult) { + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "{result:?}" + ); + } + + fn expect_not_generated_error( + result: StateTransitionExecutionResult, + property: &str, + param: &str, + ) { + let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { + panic!("expected a paid consensus error, got {result:?}"); + }; + assert_matches!( + error, + ConsensusError::BasicError(BasicError::DocumentPropertyNotGeneratedError(e)) + if e.property() == property + && e.params() == [param.to_string()] + && e.function() == "sys.stringTransformations.homographSafeASCII" + ); + } + + #[tokio::test] + async fn should_generate_a_left_out_property_on_arrival() { + let mut fixture = HandleFixture::new(); + + let result = fixture + .create(platform_value!({ "label": "Bob", "parent": "Dash-Oil" }), 1) + .await; + + assert_success(&result); + let stored = fixture.stored_handles(); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].get("normalizedLabel"), Some(&text("b0b"))); + assert_eq!(stored[0].get("normalizedParent"), Some(&text("dash-011"))); + } + + #[tokio::test] + async fn should_accept_a_supplied_property_equal_to_the_generated_one() { + let mut fixture = HandleFixture::new(); + + let result = fixture + .create( + platform_value!({ "label": "Alice", "normalizedLabel": "a11ce" }), + 2, + ) + .await; + + assert_success(&result); + let stored = fixture.stored_handles(); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].get("normalizedLabel"), Some(&text("a11ce"))); + // Its source is absent, so it is too + assert_eq!(stored[0].get("normalizedParent"), None); + } + + /// "bob" is "Bob" lowercased without the homograph mapping. The normalized + /// property declares no pattern, so the keyword is what refuses it. + #[tokio::test] + async fn should_refuse_a_supplied_property_that_differs_from_the_generated_one() { + let mut fixture = HandleFixture::new(); + + let result = fixture + .create( + platform_value!({ "label": "Bob", "normalizedLabel": "bob" }), + 3, + ) + .await; + + expect_not_generated_error(result, "normalizedLabel", "label"); + assert!(fixture.stored_handles().is_empty()); + } + + #[tokio::test] + async fn should_refuse_a_generated_property_without_its_param() { + let mut fixture = HandleFixture::new(); + + let result = fixture + .create( + platform_value!({ "label": "Carl", "normalizedParent": "dash" }), + 4, + ) + .await; + + expect_not_generated_error(result, "normalizedParent", "parent"); + assert!(fixture.stored_handles().is_empty()); + } + + /// The computed value is what the unique index holds: a second handle whose + /// label only differs by case and homographs collides with the first, though + /// neither transition carried the normalized form. + #[tokio::test] + async fn should_find_a_unique_index_collision_through_the_computed_value() { + let mut fixture = HandleFixture::new(); + + assert_success(&fixture.create(platform_value!({ "label": "Bob" }), 5).await); + + let result = fixture.create(platform_value!({ "label": "B0B" }), 6).await; + let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { + panic!("expected a paid consensus error, got {result:?}"); + }; + assert_matches!( + error, + ConsensusError::StateError(StateError::DuplicateUniqueIndexError(_)) + ); + assert_eq!(fixture.stored_handles().len(), 1); + } + + #[tokio::test] + async fn should_regenerate_a_left_out_property_on_replace() { + let mut fixture = HandleFixture::new(); + assert_success(&fixture.create(platform_value!({ "label": "Bob" }), 7).await); + let stored = fixture.stored_handles().remove(0); + + // A new label with the normalized form left out: computed again + let result = fixture + .replace(&stored, |handle| { + handle.set("label", text("Bobby-Lee")); + handle.remove("normalizedLabel"); + }) + .await; + assert_success(&result); + let replaced = fixture.stored_handles().remove(0); + assert_eq!(replaced.get("normalizedLabel"), Some(&text("b0bby-1ee"))); + + // A stale normalized form sent with the new label is refused + let result = fixture + .replace(&replaced, |handle| { + handle.set("label", text("Robin")); + }) + .await; + expect_not_generated_error(result, "normalizedLabel", "label"); + let after = fixture.stored_handles().remove(0); + assert_eq!(after.get("label"), Some(&text("Bobby-Lee"))); + assert_eq!(after.get("normalizedLabel"), Some(&text("b0bby-1ee"))); + } + + /// A client reading the create back with a proof rebuilds the document the + /// transition wrote, computed property included, and the proof verifies. + #[tokio::test] + async fn should_verify_the_proof_of_a_create_that_left_the_generated_property_out() { + let mut fixture = HandleFixture::new(); + let transition = fixture + .create_transition(platform_value!({ "label": "Olive" }), 8) + .await; + assert_success(&fixture.process(&transition)); + + let StateTransitionProofResult::VerifiedDocuments(documents) = + fixture.verify_proof(&transition) + else { + panic!("expected verified documents"); + }; + let document = documents + .into_values() + .next() + .flatten() + .expect("the proof holds the created document"); + assert_eq!(document.get("normalizedLabel"), Some(&text("011ve"))); + } + + /// The replace a client proves is rebuilt from the transition with the + /// property it left out computed from the new source. + #[tokio::test] + async fn should_verify_the_proof_of_a_replace_that_left_the_generated_property_out() { + let mut fixture = HandleFixture::new(); + assert_success( + &fixture + .create(platform_value!({ "label": "Bob" }), 10) + .await, + ); + let stored = fixture.stored_handles().remove(0); + + let transition = fixture + .replace_transition(&stored, |handle| { + handle.set("label", text("Oliver")); + handle.remove("normalizedLabel"); + }) + .await; + assert_success(&fixture.process(&transition)); + + let StateTransitionProofResult::VerifiedDocuments(documents) = + fixture.verify_proof(&transition) + else { + panic!("expected verified documents"); + }; + let document = documents + .into_values() + .next() + .flatten() + .expect("the proof holds the replaced document"); + assert_eq!(document.get("normalizedLabel"), Some(&text("011ver"))); + } + + /// An indexOnly entry has no primary row: its create and delete are proved + /// and executed through the entry its values produce, so the property the + /// transitions leave out must be computed on both sides, by the node and by + /// the verifier. The delete names the entry without the normalized value and + /// still finds it (a delete of a missing entry is refused). + #[tokio::test] + async fn should_create_prove_and_delete_an_index_only_entry_that_leaves_the_generated_property_out( + ) { + let platform_version = PlatformVersion::latest(); + let mut fixture = HandleFixture::new(); + let (entry, create) = fixture + .create_transition_of("entry", platform_value!({ "name": "Bob" }), 11, true) + .await; + assert_success(&fixture.process(&create)); + + let StateTransitionProofResult::VerifiedDocuments(documents) = + fixture.verify_proof(&create) + else { + panic!("expected verified documents"); + }; + let proved = documents + .into_values() + .next() + .flatten() + .expect("the proof holds the created entry"); + assert_eq!(proved.get("normalizedName"), Some(&text("b0b"))); + + let entry_type = fixture + .contract + .document_type_for_name("entry") + .expect("expected the entry document type"); + let delete = BatchTransition::new_document_deletion_transition_from_document( + entry.clone(), + entry_type, + &fixture.key, + fixture.next_nonce, + 0, + None, + &fixture.signer, + platform_version, + None, + ) + .await + .expect("expected the delete transition"); + fixture.next_nonce += 1; + let delete = fixture + .send_as_given(delete, entry.properties().clone()) + .await; + assert_success(&fixture.process(&delete)); + + let StateTransitionProofResult::VerifiedDocuments(documents) = + fixture.verify_proof(&delete) + else { + panic!("expected verified documents"); + }; + assert_eq!(documents.into_values().next(), Some(None)); + } + + /// A contested index over the generated property: a document built by the + /// SDK without the property carries its contest, because the builder + /// computes the property before it resolves the contest; and a transition + /// sent without the property, with its contest named, is resolved by the + /// node against the value it computes. + #[tokio::test] + async fn should_resolve_the_contest_of_a_document_that_leaves_the_generated_property_out() { + let mut fixture = HandleFixture::new(); + + let (_, built) = fixture + .create_transition_of("name", platform_value!({ "label": "Bob" }), 12, false) + .await; + let StateTransition::Batch(batch) = &built else { + panic!("expected a batch transition"); + }; + let Some(BatchedTransitionRef::Document(DocumentTransition::Create(create))) = + batch.first_transition() + else { + panic!("expected a document create"); + }; + assert_eq!(create.data().get("normalizedLabel"), Some(&text("b0b"))); + assert_eq!( + create + .prefunded_voting_balance() + .as_ref() + .map(|(index, _)| index.as_str()), + Some("byNormalizedLabel") + ); + assert_success(&fixture.process(&built)); + + let (_, as_given) = fixture + .create_transition_of("name", platform_value!({ "label": "Alice" }), 13, true) + .await; + assert_success(&fixture.process(&as_given)); + } + + /// The create transformer computes the property only from protocol version + /// 14: before it, the data goes on as sent. + #[tokio::test] + async fn should_not_generate_the_property_before_protocol_version_14() { + let mut fixture = HandleFixture::new(); + let transition = fixture + .create_transition(platform_value!({ "label": "Bob" }), 9) + .await; + let StateTransition::Batch(batch) = &transition else { + panic!("expected a batch transition"); + }; + let create_transition = match batch.first_transition() { + Some(BatchedTransitionRef::Document(DocumentTransition::Create(create))) => create, + other => panic!("expected a document create, got {other:?}"), + }; + let contract_fetch_info = fixture.contract_fetch_info(); + + let action_data = |platform_version: &PlatformVersion| { + let (result, _) = + DocumentCreateTransitionAction::try_from_document_borrowed_create_transition_with_contract_lookup( + &fixture.platform.drive, + fixture.identity.id(), + None, + create_transition, + &BlockInfo::default(), + 0, + |_| Ok(contract_fetch_info.clone()), + platform_version, + ) + .expect("expected the transformer to run"); + match result.into_data().expect("expected an action") { + BatchedTransitionAction::DocumentAction( + DocumentTransitionAction::CreateAction(action), + ) => action.data().clone(), + other => panic!("expected a create action, got {other:?}"), + } + }; + + let before = action_data(PlatformVersion::get(13).expect("protocol version 13")); + assert_eq!(before.get("normalizedLabel"), None); + + let at = action_data(PlatformVersion::latest()); + assert_eq!(at.get("normalizedLabel"), Some(&text("b0b"))); + } + + /// The replace transformer, edited in place, generates the property only + /// from protocol version 14: before it, the data goes on as sent. + #[tokio::test] + async fn should_not_regenerate_the_property_on_replace_before_protocol_version_14() { + let mut fixture = HandleFixture::new(); + assert_success( + &fixture + .create(platform_value!({ "label": "Bob" }), 20) + .await, + ); + let stored = fixture.stored_handles().remove(0); + let transition = fixture + .replace_transition(&stored, |handle| { + handle.set("label", text("Robin")); + handle.remove("normalizedLabel"); + }) + .await; + let StateTransition::Batch(batch) = &transition else { + panic!("expected a batch transition"); + }; + let replace_transition = match batch.first_transition() { + Some(BatchedTransitionRef::Document(DocumentTransition::Replace(replace))) => replace, + other => panic!("expected a document replace, got {other:?}"), + }; + let contract_fetch_info = fixture.contract_fetch_info(); + + let action_data = |platform_version: &PlatformVersion| { + let (result, _) = + DocumentReplaceTransitionAction::try_from_borrowed_document_replace_transition( + replace_transition, + fixture.identity.id(), + &stored, + &BlockInfo::default(), + 0, + |_| Ok(contract_fetch_info.clone()), + platform_version, + ) + .expect("expected the transformer to run"); + match result.into_data().expect("expected an action") { + BatchedTransitionAction::DocumentAction( + DocumentTransitionAction::ReplaceAction(action), + ) => action.data().clone(), + other => panic!("expected a replace action, got {other:?}"), + } + }; + + let before = action_data(PlatformVersion::get(13).expect("protocol version 13")); + assert_eq!(before.get("label"), Some(&text("Robin"))); + assert_eq!(before.get("normalizedLabel"), None); + + let at = action_data(PlatformVersion::latest()); + assert_eq!(at.get("normalizedLabel"), Some(&text("r0b1n"))); + } + + /// The replace structure dispatcher on both sides of the gate: the document + /// validation it runs gained the check in place, so at protocol version 13 + /// it must still accept the action (the dpp gate is `None` there), and at + /// 14 refuse it. The action is built by hand the way the transformer would + /// build it, against the contract as Drive hands it back. + #[test] + fn should_not_check_the_generated_property_before_protocol_version_14() { + let fixture = HandleFixture::new(); + let owner_id = fixture.identity.id(); + let contract_fetch_info = fixture.contract_fetch_info(); + + let action = || { + DocumentReplaceTransitionAction::V0(DocumentReplaceTransitionActionV0 { + base: DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: Identifier::from([0xAA; 32]), + identity_contract_nonce: 1, + document_type_name: "handle".to_string(), + data_contract: contract_fetch_info.clone(), + token_cost: None, + gas_fees_paid_by: GasFeesPaidBy::default(), + contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, + }), + revision: 2, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + data: BTreeMap::from([ + ("label".to_string(), text("Bob")), + ("normalizedLabel".to_string(), text("b1b")), + ]), + changed_data_fields: BTreeSet::new(), + added_data_fields: BTreeSet::new(), + removed_identifier_fields: BTreeMap::new(), + stored_changed_values: BTreeMap::new(), + creator_id: None, + property_constraint_aggregates: Default::default(), + }) + }; + + let before = action() + .validate_structure( + owner_id, + PlatformVersion::get(13).expect("platform version 13 should exist"), + ) + .expect("structure validation should run"); + assert!( + before.is_valid(), + "the document validation must not check generatedFrom before 14: {:?}", + before.errors + ); + + let at = action() + .validate_structure(owner_id, PlatformVersion::latest()) + .expect("structure validation should run"); + assert_matches!( + at.errors.as_slice(), + [ConsensusError::BasicError(BasicError::DocumentPropertyNotGeneratedError(e))] + if e.property() == "normalizedLabel" + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index cda64f2f870..2f49736f357 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -8,6 +8,7 @@ mod document_ttl; mod dpns; mod encrypted_for; mod gas_sponsorship; +mod generated_from; mod id_reuse; mod immutable; mod index_only; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index ea6aed45f1d..1c4dc094031 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -912,6 +912,7 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { block_info, user_fee_increase, |_identifier| Ok(data_contract_fetch_info.clone()), + platform_version, )?; execution_context @@ -959,7 +960,7 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { DocumentTransition::IndexOnlyDelete(document_index_only_delete_transition) => { let (batched_action, fee_result) = DocumentIndexOnlyDeleteTransitionAction::try_from_document_borrowed_index_only_delete_transition_with_contract_lookup(document_index_only_delete_transition, owner_id, user_fee_increase, |_identifier| { Ok(data_contract_fetch_info.clone()) - })?; + }, platform_version)?; execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); diff --git a/packages/rs-drive/src/query/index_only_synthesis.rs b/packages/rs-drive/src/query/index_only_synthesis.rs index 007d2e9d3e5..fe3d5507626 100644 --- a/packages/rs-drive/src/query/index_only_synthesis.rs +++ b/packages/rs-drive/src/query/index_only_synthesis.rs @@ -45,6 +45,7 @@ use crate::query::{ use crate::verify::RootHash; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; use dpp::data_contract::document_type::{DocumentPropertyType, DocumentTypeRef, Index}; use dpp::document::{Document, DocumentV0}; use dpp::identifier::Identifier; @@ -1091,6 +1092,12 @@ pub fn index_only_entry_path_and_key_from_values( /// proven (and verified) against. Shared by `prove_state_transition` and /// `verify_state_transition_was_executed_with_proof` — one builder, both /// sides. +/// +/// `data` is the transition's values. The entry holds every `generatedFrom` +/// property the transition left out, as the node generated it on arrival, so +/// the builder generates them here for both sides (the `fill_generated_properties` +/// slot is `None` before protocol version 14, and indexOnly types only exist +/// from it). pub fn index_only_transition_entry_path_query( contract_id: Identifier, document_type: DocumentTypeRef, @@ -1098,12 +1105,14 @@ pub fn index_only_transition_entry_path_query( owner_id: Identifier, platform_version: &PlatformVersion, ) -> Result { + let mut data = data.clone(); + document_type.fill_generated_properties(&mut data, platform_version)?; let index = index_only_proof_index(&document_type)?; let (path, member_key) = index_only_entry_path_and_key_from_values( contract_id, document_type, index, - data, + &data, owner_id, platform_version, )?; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs index c8c0fdf8d73..54c0b58fac9 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs @@ -1,5 +1,6 @@ use dpp::block::block_info::BlockInfo; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV1Getters}; +use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; use dpp::fee::fee_result::FeeResult; use dpp::platform_value::Identifier; use grovedb::TransactionArg; @@ -79,6 +80,13 @@ impl DocumentCreateTransitionActionV0 { let document_type = base.document_type()?; + // Added in place at protocol version 14, inert before it: `fill_generated_properties` + // is `None` there and leaves the data as sent. From 14 on, every `generatedFrom` + // property the transition leaves out is generated from its params here, before the + // contest resolution below and every later check read the data. + let mut data = data.clone(); + document_type.fill_generated_properties(&mut data, platform_version)?; + let document_type_indexes = document_type.indexes(); let prefunded_voting_balances_by_vote_poll = prefunded_voting_balance @@ -95,7 +103,7 @@ impl DocumentCreateTransitionActionV0 { // contender of a contest names it with the same poll and prefunds the same // balance; before 14 they are taken as given, as they always were let index_values = index.extract_contested_values( - data, + &data, document_type.flattened_properties(), platform_version, )?; @@ -152,7 +160,7 @@ impl DocumentCreateTransitionActionV0 { DocumentCreateTransitionActionV0 { base, block_info: *block_info, - data: data.clone(), + data, prefunded_voting_balance: prefunded_voting_balances_by_vote_poll, current_store_contest_info, should_store_contest_info, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_index_only_delete_transition_action/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_index_only_delete_transition_action/transformer.rs index 82ab89b341e..4b8b3d66a56 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_index_only_delete_transition_action/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_index_only_delete_transition_action/transformer.rs @@ -4,6 +4,7 @@ use dpp::fee::fee_result::FeeResult; use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; use dpp::ProtocolError; use dpp::state_transition::batch_transition::batched_transition::DocumentIndexOnlyDeleteTransition; +use platform_version::version::PlatformVersion; use crate::drive::contract::DataContractFetchInfo; use crate::error::Error; use crate::state_transition_action::batch::batched_transition::BatchedTransitionAction; @@ -16,6 +17,7 @@ impl DocumentIndexOnlyDeleteTransitionAction { owner_id: Identifier, user_fee_increase: UserFeeIncrease, get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, ) -> Result< ( ConsensusValidationResult, @@ -24,7 +26,7 @@ impl DocumentIndexOnlyDeleteTransitionAction { Error, > { match value { - DocumentIndexOnlyDeleteTransition::V0(v0) => DocumentIndexOnlyDeleteTransitionActionV0::try_from_borrowed_document_index_only_delete_transition_with_contract_lookup(v0, owner_id, user_fee_increase, get_data_contract), + DocumentIndexOnlyDeleteTransition::V0(v0) => DocumentIndexOnlyDeleteTransitionActionV0::try_from_borrowed_document_index_only_delete_transition_with_contract_lookup(v0, owner_id, user_fee_increase, get_data_contract, platform_version), } } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_index_only_delete_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_index_only_delete_transition_action/v0/transformer.rs index f6c84e89f12..7bc7da94005 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_index_only_delete_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_index_only_delete_transition_action/v0/transformer.rs @@ -1,6 +1,8 @@ use dpp::platform_value::Identifier; use std::sync::Arc; use dpp::data_contract::document_type::accessors::DocumentTypeV1Getters; +use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; +use platform_version::version::PlatformVersion; use dpp::fee::fee_result::FeeResult; use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; use dpp::ProtocolError; @@ -8,7 +10,7 @@ use dpp::state_transition::batch_transition::batched_transition::document_index_ use crate::drive::contract::DataContractFetchInfo; use crate::error::Error; use crate::state_transition_action::batch::batched_transition::BatchedTransitionAction; -use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionAction; +use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionAccessorsV0}; use crate::state_transition_action::batch::batched_transition::document_transition::document_index_only_delete_transition_action::v0::DocumentIndexOnlyDeleteTransitionActionV0; use crate::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; use crate::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; @@ -21,6 +23,7 @@ impl DocumentIndexOnlyDeleteTransitionActionV0 { owner_id: Identifier, user_fee_increase: UserFeeIncrease, get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, ) -> Result< ( ConsensusValidationResult, @@ -61,14 +64,20 @@ impl DocumentIndexOnlyDeleteTransitionActionV0 { } }; + // Added in place at protocol version 14, inert before it: `fill_generated_properties` + // is `None` there and leaves the values as sent. From 14 on, the values name an + // entry the way its create stored it: a `generatedFrom` property left out is + // generated from its params, as the create generated it. `document_type()` cannot + // fail here: building the base action above already resolved the document type + // (its deletion token cost is read from it). + let mut data = data.clone(); + base.document_type()? + .fill_generated_properties(&mut data, platform_version)?; + Ok(( BatchedTransitionAction::DocumentAction( DocumentTransitionAction::IndexOnlyDeleteAction( - DocumentIndexOnlyDeleteTransitionActionV0 { - base, - data: data.clone(), - } - .into(), + DocumentIndexOnlyDeleteTransitionActionV0 { base, data }.into(), ), ) .into(), diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/transformer.rs index 7cd0add02b5..2e1258df23d 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/transformer.rs @@ -6,6 +6,7 @@ use dpp::fee::fee_result::FeeResult; use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; use dpp::ProtocolError; use dpp::state_transition::batch_transition::batched_transition::DocumentReplaceTransition; +use platform_version::version::PlatformVersion; use crate::drive::contract::DataContractFetchInfo; use crate::error::Error; use crate::state_transition_action::batch::batched_transition::BatchedTransitionAction; @@ -21,6 +22,7 @@ impl DocumentReplaceTransitionAction { block_info: &BlockInfo, user_fee_increase: UserFeeIncrease, get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, ) -> Result< ( ConsensusValidationResult, @@ -37,6 +39,7 @@ impl DocumentReplaceTransitionAction { block_info, user_fee_increase, get_data_contract, + platform_version, ) } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs index cdd28954d77..e41a18ff9cc 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs @@ -4,6 +4,8 @@ use dpp::platform_value::{Identifier, Value}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use dpp::data_contract::document_type::accessors::DocumentTypeV1Getters; +use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; +use platform_version::version::PlatformVersion; use dpp::fee::fee_result::FeeResult; use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; use dpp::ProtocolError; @@ -26,6 +28,7 @@ impl DocumentReplaceTransitionActionV0 { block_info: &BlockInfo, user_fee_increase: UserFeeIncrease, get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, + platform_version: &PlatformVersion, ) -> Result< ( ConsensusValidationResult, @@ -69,6 +72,17 @@ impl DocumentReplaceTransitionActionV0 { )); } }; + // Added in place at protocol version 14, inert before it: `fill_generated_properties` + // is `None` there and leaves the data as sent. From 14 on, every `generatedFrom` + // property the transition leaves out is generated from its params here, before the + // changed fields below and every later check read the data. `document_type()` + // cannot fail here at any version: building the base action above already resolved + // the document type (its token cost is read from it), and the + // `document_type_field_is_required` calls below resolve it the same way. + let mut data = data.clone(); + base.document_type()? + .fill_generated_properties(&mut data, platform_version)?; + let updated_at = if base.document_type_field_is_required(property_names::UPDATED_AT)? { Some(block_info.time_ms) } else { @@ -191,7 +205,7 @@ impl DocumentReplaceTransitionActionV0 { updated_at_core_block_height, transferred_at_core_block_height: original_document_transferred_at_core_block_height, - data: data.clone(), + data, changed_data_fields: changed_fields, added_data_fields: added_fields, removed_identifier_fields, diff --git a/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs b/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs index f884fe9e38c..960676cd76d 100644 --- a/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs +++ b/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs @@ -1512,6 +1512,50 @@ pub static KEYWORD_COMPATIBILITY_RULES: Lazy = Laz ], }, ), + // `generatedFrom` (a string property whose value a built-in function + // generates from other properties of the same document type) is frozen + // like `distinctFrom`: adding, removing or changing its function or + // params changes which documents the type accepts, and what the + // platform writes into those that leave the property out. + ( + "generatedFrom", + CompatibilityRules { + allow_addition: false, + allow_removal: false, + allow_replacement_callback: FALSE_CALLBACK.clone(), + subschema_levels_depth: None, + inner: None, + #[cfg(any(test, feature = "examples"))] + examples: vec![ + ( + json!({}), + json!({ "generatedFrom": { "function": "sys.stringTransformations.homographSafeASCII", "params": ["label"] } }), + Some(JsonSchemaChange::Add(AddOperation { + path: "/generatedFrom".to_string(), + value: json!({ "function": "sys.stringTransformations.homographSafeASCII", "params": ["label"] }), + })), + ) + .into(), + ( + json!({ "generatedFrom": { "function": "sys.stringTransformations.homographSafeASCII", "params": ["label"] } }), + json!({}), + Some(JsonSchemaChange::Remove(RemoveOperation { + path: "/generatedFrom".to_string(), + })), + ) + .into(), + ( + json!({ "generatedFrom": { "function": "sys.stringTransformations.homographSafeASCII", "params": ["label"] } }), + json!({ "generatedFrom": { "function": "sys.stringTransformations.homographSafeASCII", "params": ["displayName"] } }), + Some(JsonSchemaChange::Replace(ReplaceOperation { + path: "/generatedFrom/params/0".to_string(), + value: json!("displayName"), + })), + ) + .into(), + ], + }, + ), // `maxBytes` (the most UTF-8 bytes a string may take) moves like // `maxLength`: raising or dropping the bound keeps every stored document // valid, adding or lowering it would not. diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs index 132030fa120..0b83d6bd05c 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs @@ -99,6 +99,18 @@ pub struct DocumentTypeMethodVersions { /// contender names one contest with one poll. `None` on versions that predate it, where /// the values are taken as given. pub canonical_contested_index_values: OptionalFeatureVersion, + /// `fill_generated_properties`: writes each `generatedFrom` property a + /// created or replaced document leaves out, generated from its params, + /// before anything reads the document's data. `None` on versions that predate the + /// keyword: the method leaves the data untouched there, so the shipped action + /// transformers and proof verification that call it are inert. + pub fill_generated_properties: OptionalFeatureVersion, + /// `validate_generated_from_properties`: refuses a document whose + /// `generatedFrom` property is not what its function generates from its + /// params, or is present while a param is absent. `None` on versions that predate the + /// keyword: the method returns an empty result there, so the shipped + /// `DataContract::validate_document_properties` 0 that calls it is inert. + pub validate_generated_from: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] @@ -145,6 +157,12 @@ pub struct DocumentTypeSchemaVersions { /// read. `None` on versions that predate the keyword: they ignore it /// entirely, exactly as they parsed before it existed. pub parse_property_constraints: OptionalFeatureVersion, + /// Parses the `generatedFrom` property keyword (a string property whose + /// value a built-in function generates from other properties of the same + /// document) onto the property, and checks the properties it reads at + /// contract registration. `None` on versions that predate the keyword: they ignore + /// it entirely, exactly as they parsed before it existed. + pub apply_generated_from: OptionalFeatureVersion, pub validate_max_depth: FeatureVersion, pub max_depth: u16, pub recursive_schema_validator_versions: RecursiveSchemaValidatorVersions, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs index 72310706d4c..59a2ecf5d18 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs @@ -50,6 +50,7 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { apply_max_bytes: None, parse_typed_array: None, parse_property_constraints: None, + apply_generated_from: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { @@ -72,6 +73,8 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { validate_max_bytes: None, validate_property_constraints: None, canonical_contested_index_values: None, + fill_generated_properties: None, + validate_generated_from: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs index 50dac063540..f1048b68873 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs @@ -50,6 +50,7 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { apply_max_bytes: None, parse_typed_array: None, parse_property_constraints: None, + apply_generated_from: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { @@ -72,6 +73,8 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { validate_max_bytes: None, validate_property_constraints: None, canonical_contested_index_values: None, + fill_generated_properties: None, + validate_generated_from: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs index aed49ded327..25d065cf5fc 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs @@ -52,6 +52,7 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { apply_max_bytes: None, parse_typed_array: None, parse_property_constraints: None, + apply_generated_from: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { @@ -74,6 +75,8 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { validate_max_bytes: None, validate_property_constraints: None, canonical_contested_index_values: None, + fill_generated_properties: None, + validate_generated_from: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs index 4192b021d1e..f7a03636136 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs @@ -52,6 +52,7 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { apply_max_bytes: None, parse_typed_array: None, parse_property_constraints: None, + apply_generated_from: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { @@ -74,6 +75,8 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { validate_max_bytes: None, validate_property_constraints: None, canonical_contested_index_values: None, + fill_generated_properties: None, + validate_generated_from: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs index e4933053905..6a0d2fe6dfb 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs @@ -54,6 +54,7 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { apply_max_bytes: None, parse_typed_array: None, parse_property_constraints: None, + apply_generated_from: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { @@ -76,6 +77,8 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { validate_max_bytes: None, validate_property_constraints: None, canonical_contested_index_values: None, + fill_generated_properties: None, + validate_generated_from: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs index 5adef48dc13..8d2b246b7ee 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs @@ -87,6 +87,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { apply_max_bytes: Some(0), // changed: the meta-schema v3 `maxBytes` keyword (the most UTF-8 bytes a string property, or each string element of a typed array, may hold) is folded into the string's `StringPropertySizes`; None before this version means the keyword is ignored, as it was before it existed parse_typed_array: Some(0), // changed: a meta-schema v3 typed array (`type: "array"` with an `items` element schema) parses to `DocumentPropertyType::TypedArray`; None before this version leaves it to the scalar parser, which refuses an array that is not a byte array parse_property_constraints: Some(0), // changed: the meta-schema v3 `propertyConstraints` doctype keyword (named comparisons between integer expressions over the document's properties) is parsed onto the document type and the properties it reads are checked; None before this version means the keyword is ignored, as it was before it existed + apply_generated_from: Some(0), // changed: the meta-schema v3 `generatedFrom` keyword (a string property whose value a built-in function generates from other properties of the same document) is parsed onto the property and the properties it reads are checked at registration; None before this version means the keyword is ignored, as it was before it existed validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { @@ -120,6 +121,8 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { validate_max_bytes: Some(0), // changed: refuses a string longer in UTF-8 bytes than its property's `maxBytes` (DocumentPropertyMaxBytesExceededError, 10421); None before this version returns an empty result validate_property_constraints: Some(0), // changed: `validate_property_constraints` refuses a created or replaced document that breaks one of its type's `propertyConstraints` (DocumentPropertyConstraintViolatedError, 10422); None before this version returns an empty result canonical_contested_index_values: Some(0), // new: identifier index values of a contest are written as identifiers + fill_generated_properties: Some(0), // new: a created or replaced document that leaves out a `generatedFrom` property whose params it supplies gets the property generated on arrival; None before this version leaves the data as sent + validate_generated_from: Some(0), // new: `validate_generated_from_properties` refuses a document whose `generatedFrom` property is not what its function generates from its params, or is present without them (DocumentPropertyNotGeneratedError, 10424); None before this version returns an empty result }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 245bb5867b9..a4722dfb0c7 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1380,6 +1380,49 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// against the property's `enum` and an `encryptedFor` key id's bounds; it /// refused every such contract with a decoding error before. /// +/// 53. **Properties the platform generates (`generatedFrom`)**: the property +/// keyword (meta-schema v3, `apply_generated_from` 0, `GeneratedFrom` on +/// `DocumentProperty`) names a built-in `function` and its `params`, +/// properties of the same document type, as +/// `{ "function": "sys.stringTransformations.homographSafeASCII", "params": ["label"] }`. +/// System functions are named under `sys.`, leaving other names to +/// functions a contract may bring later. The `sys.stringTransformations` +/// functions take one string and change ASCII characters only, keeping +/// every other character, without Unicode tables: `lowercase`, +/// `uppercase`, `capitalize`, `camelCase`, `snakeCase`, and +/// `homographSafeASCII`, which lowercases, then maps `o` to `0` and `i` +/// and `l` to `1`, DPNS's label normalization over ASCII. The parser +/// checks at registration and update that `params` holds as many +/// properties as the function takes, each another string property that +/// is not generated itself, that neither the property nor a param is +/// transient or inside a transient object, and that every param sits +/// inside every object holding the property; a changed declaration is an +/// incompatible schema change, and `validate_update` 1 refuses a property +/// an update adds over params that all already existed +/// (`DocumentTypeUpdateError`, 40212); meta-schema v3 refuses the keyword +/// beside `$ref`, whose definition would replace it. +/// `fill_generated_properties` (0) writes a declared property a document +/// leaves out, from its params, in the action transformers of document +/// create, replace and index-only delete, before the contest resolution +/// and every check read the data, in `Document::try_from_create_transition` +/// and `try_from_replace_transition`, and in +/// `index_only_transition_entry_path_query`, the builder the prover and +/// the verifier share, with which proofs are built and checked. The client +/// transition builders, the SDK's contest fund lookup and the JS and FFI +/// property-constraint pre-checks call `regenerate_generated_properties` +/// (same slot) instead, which replaces a value the document holds and +/// removes it when a param is absent, so a transition built from a +/// fetched and edited document carries the value of its new params and +/// its contest is detected from it. +/// `DataContract::validate_document_properties` 0 calls +/// `validate_generated_from_properties` (`validate_generated_from` 0) +/// after the schema and `maxBytes`, and refuses a supplied value that is +/// not what the function generates, one without its params, or a document +/// repeating a key on the way to the property or a param, with +/// `DocumentPropertyNotGeneratedError` (10424). Every call site was +/// extended in place and is inert before this version, where the three +/// slots are `None` and the meta-schemas refuse the keyword. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's diff --git a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs index 42b77266de8..34e8717b828 100644 --- a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs +++ b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs @@ -37,7 +37,9 @@ use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; -use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dash_sdk::dpp::data_contract::document_type::methods::{ + DocumentTypeBasicMethods, DocumentTypeV0Methods, +}; use dash_sdk::dpp::data_contract::document_type::property_constraints::{ DocumentSystemValues, PropertyRead, }; @@ -349,16 +351,27 @@ fn system_values_for_create(owner_id: Identifier) -> DocumentSystemValues { } /// The first rule of `document_type`'s `propertyConstraints` that `document` -/// breaks, judged as consensus judges a create (its properties, its owner for -/// `$ownerId`, and the system times [`system_values_for_create`] estimates), -/// as the JSON `dash_sdk_data_contract_check_property_constraints` returns: -/// JSON `null` when it meets them all. +/// breaks, judged as consensus judges a create (its properties with every +/// `generatedFrom` property generated from its params, as the transition +/// builders send them, its owner for `$ownerId`, and the system times +/// [`system_values_for_create`] estimates), as the JSON +/// `dash_sdk_data_contract_check_property_constraints` returns: JSON `null` +/// when it meets them all. fn property_constraint_violation_json( document_type: DocumentTypeRef<'_>, document: &Document, platform_version: &PlatformVersion, ) -> Result { - let data = Value::from(document.properties().clone()); + let mut properties = document.properties().clone(); + document_type + .regenerate_generated_properties(&mut properties, platform_version) + .map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::ProtocolError, + format!("Failed to generate the generatedFrom properties: {}", e), + ) + })?; + let data = Value::from(properties); let system = system_values_for_create(document.owner_id()); let result = document_type .validate_property_constraints(&data, &system, platform_version) @@ -1101,4 +1114,65 @@ mod tests { assert_eq!(ended["violation"], "NotMet"); assert_eq!(ending_sold_by_other, serde_json::Value::Null); } + + /// A `handle` type whose `normalizedLabel` is generated from `label`, with a + /// rule reserving the normalized name `dash`. + fn handle_contract_bytes() -> Vec { + let platform_version = PlatformVersion::latest(); + let documents = platform_value!({ + "handle": { + "type": "object", + "properties": { + "label": { "type": "string", "maxLength": 63, "position": 0 }, + "normalizedLabel": { + "type": "string", + "maxLength": 63, + "position": 1, + "generatedFrom": { + "function": "sys.stringTransformations.homographSafeASCII", + "params": ["label"] + } + } + }, + "additionalProperties": false, + "propertyConstraints": { + "notReserved": { "notEqual": ["normalizedLabel", { "const": "dash" }] } + } + } + }); + DataContractFactory::new(platform_version.protocol_version) + .expect("factory for the protocol version") + .create_with_value_config(Identifier::new(OWNER), 1, documents, None, None) + .expect("handle contract") + .data_contract() + .serialize_to_bytes_with_platform_version(platform_version) + .expect("serialized contract") + } + + /// The pre-check judges the rules on the document the transition builders + /// send: a `generatedFrom` property left out, or stale, is generated from + /// its params first, as consensus sees it. + #[test] + fn should_judge_a_generated_property_as_the_builders_send_it() { + let sdk = sdk_handle(PlatformVersion::latest()); + let contract = handle_contract_bytes(); + + let left_out = check(sdk, &contract, "handle", json!({ "label": "DASH" }), OWNER); + let stale = check( + sdk, + &contract, + "handle", + json!({ "label": "DASH", "normalizedLabel": "b0b" }), + OWNER, + ); + let other = check(sdk, &contract, "handle", json!({ "label": "Bob" }), OWNER); + destroy_mock_sdk_handle(sdk); + + for result in [left_out, stale] { + let result = result.expect("checked"); + assert_eq!(result["rule"], "notReserved"); + assert_eq!(result["violation"], "NotMet"); + } + assert_eq!(other.expect("checked"), serde_json::Value::Null); + } } diff --git a/packages/rs-sdk/src/platform/documents/contest_fund.rs b/packages/rs-sdk/src/platform/documents/contest_fund.rs index 699dc5ad29c..fb42840ab0b 100644 --- a/packages/rs-sdk/src/platform/documents/contest_fund.rs +++ b/packages/rs-sdk/src/platform/documents/contest_fund.rs @@ -7,9 +7,10 @@ use crate::platform::fetch_many::FetchMany; use crate::{Error, Sdk}; -use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::document_type::methods::{DocumentTypeBasicMethods, DocumentTypeV0Methods}; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::document::Document; +use dpp::document::DocumentV0Getters; use dpp::fee::Credits; use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use dpp::voting::contender_structs::ContenderWithSerializedDocument; @@ -34,8 +35,12 @@ impl Sdk { document_type: DocumentTypeRef<'_>, document: &Document, ) -> Result, Error> { + // The contest is resolved on the document the transition builder sends, with every + // `generatedFrom` property generated from its params as the platform generates it + let mut document = document.clone(); + document_type.regenerate_generated_properties(document.properties_mut(), self.version())?; let Some(VotePoll::ContestedDocumentResourceVotePoll(vote_poll)) = - document_type.contested_vote_poll_for_document(document, self.version())? + document_type.contested_vote_poll_for_document(&document, self.version())? else { return Ok(None); }; diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index e3fa679fd31..2ddcc85ebbb 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -67,7 +67,7 @@ use dpp::consensus::state::data_trigger::DataTriggerError::{ }; use wasm_bindgen::{JsError, JsValue}; use dpp::consensus::basic::data_contract::{ContestedUniqueIndexOnMutableDocumentTypeError, DataContractInvalidRequiredFieldsUpdateError, ContestedUniqueIndexWithUniqueIndexError, DataContractTokenConfigurationUpdateError, DecimalsOverLimitError, DuplicateKeywordsError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, InvalidDescriptionLengthError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidKeywordCharacterError, InvalidKeywordLengthError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, InvalidTokenDistributionFunctionIncoherenceError, InvalidTokenDistributionFunctionInvalidParameterError, InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, PreProgrammedDistributionAmountOverLimitError, RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, TooManyKeywordsError, UnknownDocumentActionTokenEffectError, UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, UnknownTransferableTypeError}; -use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, DocumentPropertyConstraintViolatedError, DocumentPropertyMaxBytesExceededError, DocumentPropertyNotDistinctError, InvalidEncryptedPropertyShapeError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; +use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, DocumentPropertyConstraintViolatedError, DocumentPropertyMaxBytesExceededError, DocumentPropertyNotDistinctError, DocumentPropertyNotGeneratedError, InvalidEncryptedPropertyShapeError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; use dpp::consensus::basic::group::GroupActionNotAllowedOnTransitionError; use dpp::consensus::basic::identity::{DataContractBoundsNotPresentError, DisablingKeyIdAlsoBeingAddedInSameTransitionError, InvalidIdentityCreditWithdrawalTransitionAmountError, InvalidIdentityUpdateTransitionDisableKeysError, InvalidIdentityUpdateTransitionEmptyError, InvalidKeyPurposeForContractBoundsError, TooManyMasterPublicKeyError, WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError}; use dpp::consensus::basic::overflow_error::OverflowError; @@ -1320,6 +1320,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::DocumentPropertyConstraintViolatedError(e) => { generic_consensus_error!(DocumentPropertyConstraintViolatedError, e).into() } + BasicError::DocumentPropertyNotGeneratedError(e) => { + generic_consensus_error!(DocumentPropertyNotGeneratedError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index dd0278a42e3..b69b2c5ff15 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -271,6 +271,42 @@ impl DocumentPropertyConstraintErrorCodeWasm { } } +/// Consensus error codes emitted by the `generatedFrom` check, which runs +/// from protocol version 14 onward wherever a document is validated, on every +/// create and replace included. +/// +/// Branch on an error's `code` against this instead of matching its message: +/// +/// ```js +/// try { +/// await sdk.documents.create({ document, identityKey, signer }); +/// } catch (e) { +/// if (e.code === DocumentGeneratedFromErrorCode.DocumentPropertyNotGenerated) { +/// // a generated property was sent with a value other than what its +/// // function generates, or without its params; leaving it out lets +/// // the platform generate it +/// } +/// } +/// ``` +#[wasm_bindgen(js_name = "DocumentGeneratedFromErrorCode")] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum DocumentGeneratedFromErrorCodeWasm { + /// A `generatedFrom` string property holds a value other than what its + /// function generates from its params, or is present while a param is + /// absent. + DocumentPropertyNotGenerated = 10424, +} + +impl DocumentGeneratedFromErrorCodeWasm { + /// The generatedFrom error a code names, or `None` for any other code. + fn from_code(code: u32) -> Option { + match code { + 10424 => Some(Self::DocumentPropertyNotGenerated), + _ => None, + } + } +} + #[wasm_bindgen(js_name = "ConsensusError")] pub struct ConsensusErrorWasm(ConsensusError); @@ -338,6 +374,13 @@ impl ConsensusErrorWasm { ) -> Option { DocumentPropertyConstraintErrorCodeWasm::from_code(self.0.code()) } + + /// The generatedFrom error this is, or `undefined` when it is not code + /// 10424. + #[wasm_bindgen(getter = "documentGeneratedFromErrorCode")] + pub fn document_generated_from_error_code(&self) -> Option { + DocumentGeneratedFromErrorCodeWasm::from_code(self.0.code()) + } } impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError); @@ -519,6 +562,38 @@ mod tests { ); } + /// Built from the real DPP error rather than a code literal, like the + /// encryption test above. + #[test] + fn should_mirror_the_dpp_generated_from_error_code() { + use dpp::consensus::basic::BasicError; + use dpp::consensus::basic::document::DocumentPropertyNotGeneratedError; + + let error: ConsensusError = + BasicError::DocumentPropertyNotGeneratedError(DocumentPropertyNotGeneratedError::new( + "domain".to_string(), + "normalizedLabel".to_string(), + "sys.stringTransformations.homographSafeASCII".to_string(), + vec!["label".to_string()], + )) + .into(); + + assert_eq!( + DocumentGeneratedFromErrorCodeWasm::from_code(error.code()), + Some(DocumentGeneratedFromErrorCodeWasm::DocumentPropertyNotGenerated) + ); + assert_eq!( + DocumentGeneratedFromErrorCodeWasm::DocumentPropertyNotGenerated as u32, + error.code() + ); + assert_eq!( + ConsensusErrorWasm(error).document_generated_from_error_code(), + Some(DocumentGeneratedFromErrorCodeWasm::DocumentPropertyNotGenerated) + ); + // A neighbouring code is not claimed. + assert_eq!(DocumentGeneratedFromErrorCodeWasm::from_code(10423), None); + } + /// The six reference-validation errors, paired with the JS enum variant /// each is advertised to be. /// diff --git a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs index de476642d4c..9df961849d3 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs @@ -18,9 +18,11 @@ use crate::error::{WasmDppError, WasmDppResult}; use dpp::consensus::basic::document::PropertyConstraintViolation; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; use dpp::data_contract::document_type::property_constraints::{DocumentSystemValues, PropertyRead}; use dpp::document::{Document, DocumentV0Getters}; use dpp::platform_value::Value; +use dpp::version::PlatformVersion; use js_sys::{Array, BigInt, Object, Reflect}; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::wasm_bindgen; @@ -416,9 +418,10 @@ fn system_values_for_write(document: &Document) -> DocumentSystemValues { /// The first rule of `document_type`'s `propertyConstraints` that `document` /// breaks, in name order, as consensus judges a create or replace: its -/// properties, its owner for `$ownerId`, and its system times and heights as -/// [`system_values_for_write`] estimates them. `undefined` when it meets them -/// all. +/// properties with every `generatedFrom` property generated from its params, +/// as the transition builders send them, its owner for `$ownerId`, and its +/// system times and heights as [`system_values_for_write`] estimates them. +/// `undefined` when it meets them all. pub(crate) fn check_property_constraints( document_type: DocumentTypeRef<'_>, document: &Document, @@ -427,7 +430,9 @@ pub(crate) fn check_property_constraints( if constraints.is_empty() { return Ok(JsValue::UNDEFINED); } - let data = Value::from(document.properties().clone()); + let mut properties = document.properties().clone(); + document_type.regenerate_generated_properties(&mut properties, PlatformVersion::desired())?; + let data = Value::from(properties); let system = system_values_for_write(document); for (name, constraint) in constraints { if let Some(violation) = constraint.violation(&data, &system) { From 06d9675a05f99cf7e1fa5488c579171d0ed66b32 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 13:31:33 +0700 Subject: [PATCH 076/113] fix(drive)!: subscription filters match generated properties a transition leaves out (#5114) Co-authored-by: Claude Opus 5.5 --- book/src/data-model/documents.md | 2 +- .../v3/generated_from_tests.rs | 26 ++ .../document_type/methods/mod.rs | 24 ++ packages/rs-drive/src/query/filter.rs | 247 ++++++++++++++++-- .../src/query/index_only_synthesis.rs | 3 +- 5 files changed, 282 insertions(+), 20 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 4f6e20e18df..f4aa6ba66a6 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -699,7 +699,7 @@ The parser (generation 3, meta-schema v3, `apply_generated_from`) reads the decl Three methods do the work at write time: -- `DocumentTypeBasicMethods::fill_generated_properties` writes every declared property the document leaves out while supplying every param. The action transformers of document create, replace and index-only delete call it first, before the contest resolution and every check read the data, so the stored document, its indexes and its contest all hold the generated value. `Document::try_from_create_transition` and `try_from_replace_transition` call it too, and so does `index_only_transition_entry_path_query`, the one builder the prover and the verifier share for index-only entries, so proofs are built and checked against the document the platform stored. +- `DocumentTypeBasicMethods::fill_generated_properties` writes every declared property the document leaves out while supplying every param. The action transformers of document create, replace and index-only delete call it first, before the contest resolution and every check read the data, so the stored document, its indexes and its contest all hold the generated value. `Document::try_from_create_transition` and `try_from_replace_transition` call it too, and so does `index_only_transition_entry_path_query`, the one builder the prover and the verifier share for index-only entries, so proofs are built and checked against the document the platform stored. `DocumentTypeBasicMethods::data_as_stored` returns a transition's data with the generated properties written into a copy, or borrows it as it is on a type that declares none; the document subscription filter (`DriveDocumentQueryFilter::matches_document_transition`) reads a create's and a replace's data, and an index-only delete's values, through it, so a subscription on a generated property sees the value the platform stores. - `DocumentTypeBasicMethods::regenerate_generated_properties` is the client-side twin: it sets every declared property to what its params generate, replacing a value the document holds and removing it when a param is absent. The transition builders (`from_document` of create, replace and index-only delete), the SDK's contest fund lookup and the property-constraint pre-checks of the JavaScript and FFI SDKs call it, so a document fetched, edited and sent back carries the value of its new params rather than the stale one the platform would refuse, and its contest is detected from it. Random documents call it too, after drawing the params of every generated property they drew. - `DocumentTypeBasicMethods::validate_generated_from_properties` runs in `DataContract::validate_document_properties`, after the JSON schema and `maxBytes`: a declared property must equal what its function generates from its params, and be absent when a param is. A document that repeats a key in an object on the way to the property or to a param is refused too: the schema validation and the stored document keep the last of repeated keys, where the path reads the platform generates from find the first. A property that does not pass refuses the write with `DocumentPropertyNotGeneratedError` (basic code 10424). A document that arrived has been generated, so on the platform the check only refuses a value the client sent, a value sent without its params, or a repeated key. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_tests.rs index 74c3dc70247..42db421733a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_tests.rs @@ -612,6 +612,32 @@ fn should_fill_nothing_before_protocol_version_14() { assert_eq!(properties, data(platform_value!({ "label": "Bob" }))); } +/// The data as the platform stores it: generated properties written into a copy, +/// and the data borrowed as it is on a type that declares none. +#[test] +fn should_read_the_data_as_stored() { + use std::borrow::Cow; + + let data = data(platform_value!({ "label": "Bob" })); + let generating = parse(schema()); + let stored = generating + .data_as_stored(&data, PlatformVersion::latest()) + .expect("the fill runs"); + assert!(matches!(stored, Cow::Owned(_))); + assert_eq!( + stored.get("normalizedLabel"), + Some(&Value::Text("b0b".to_string())) + ); + + let plain = parse(schema_with(string_property(1))); + assert!(matches!( + plain + .data_as_stored(&data, PlatformVersion::latest()) + .expect("nothing to fill"), + Cow::Borrowed(borrowed) if borrowed == &data + )); +} + /// The client-side twin sets every property to what its current params generate, /// replacing a stale value, and removes one whose param is absent. #[test] diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index 1c6ed8ae4f2..f5ed6a76811 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -2,6 +2,7 @@ mod validate_update; mod versioned_methods; +use std::borrow::Cow; use std::collections::BTreeMap; use crate::data_contract::document_type::index::{Index, IndexProperty}; @@ -275,6 +276,29 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { self.generate_properties(data, true, platform_version) } + /// `data` (a created or replaced document's properties, or an indexOnly delete's + /// values, as a transition carries them) as the platform reads it on arrival: with + /// every `generatedFrom` property it leaves out generated (`fill_generated_properties`). + /// Borrowed as it is when the document type declares none, which covers every type + /// parsed before protocol version 14; copied otherwise. For a reader of a transition + /// outside its execution, such as a subscription filter, that must see what the + /// platform stores. + fn data_as_stored<'a>( + &self, + data: &'a BTreeMap, + platform_version: &PlatformVersion, + ) -> Result>, ProtocolError> + where + Self: DocumentTypeV2Getters, + { + if self.generated_from_fields().is_empty() { + return Ok(Cow::Borrowed(data)); + } + let mut stored = data.clone(); + self.fill_generated_properties(&mut stored, platform_version)?; + Ok(Cow::Owned(stored)) + } + /// `fill_generated_properties` (`replace_present` false) and /// `regenerate_generated_properties` (`replace_present` true). fn generate_properties( diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index 5913805cf02..412e349e40b 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -9,7 +9,11 @@ //! - Create: evaluates `new_document_clauses` on the transition's data payload. //! - Replace: evaluates `original_document_clauses` on the original document and //! `new_document_clauses` on the replacement data. -//! - Delete: evaluates `original_document_clauses` on the original document. +//! - Delete: evaluates `original_document_clauses` on the original document, or on an +//! indexOnly delete's values. +//! - A create's and a replace's data, and an indexOnly delete's values, are read as the +//! platform stores them: with every `generatedFrom` property the transition leaves out +//! generated from its params (protocol version 14). //! - Transfer: evaluates `original_document_clauses` and a new `owner_clause` against //! the `recipient_owner_id`. //! - UpdatePrice: evaluates `original_document_clauses` and a `price_clause` against @@ -30,8 +34,10 @@ //! clause where required), and validates operator/value compatibility for scalar clauses //! like `owner_clause` and `price_clause`. +use std::borrow::Cow; use std::collections::BTreeMap; use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; use dpp::data_contract::DataContract; use dpp::platform_value::Value; use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; @@ -46,6 +52,7 @@ use dpp::state_transition::batch_transition::batched_transition::document_update use crate::query::{InternalClauses, QuerySyntaxSimpleValidationResult, ValueClause, WhereOperator}; use crate::error::query::QuerySyntaxError; use dpp::platform_value::ValueMapHelper; +use dpp::version::PlatformVersion; /// Filter used to match document transitions for subscriptions. /// @@ -142,11 +149,16 @@ impl DriveDocumentQueryFilter<'_> { /// - `Fail` if any transition-level check fails (no need to fetch original). /// - `NeedsOriginal` if transition-level checks pass but original clauses are non-empty /// and must be evaluated with the original document. + /// + /// A create's and a replace's data, and an indexOnly delete's values, are judged as + /// the platform stores them at `platform_version`: a `generatedFrom` property the + /// transition leaves out is generated from its params first. #[cfg(any(feature = "server", feature = "verify"))] pub fn matches_document_transition( &self, document_transition: &DocumentTransition, batch_owner_value: Option<&Value>, // Only used for Purchase + platform_version: &PlatformVersion, ) -> TransitionCheckResult { // Fast reject on contract/type mismatch common to all transitions if document_transition.base().data_contract_id() != self.contract.id() @@ -164,7 +176,10 @@ impl DriveDocumentQueryFilter<'_> { new_document_clauses, } = &self.action_clauses { - if self.evaluate_clauses(new_document_clauses, &id_value, create.data()) { + let Some(data) = self.data_as_stored(create.data(), platform_version) else { + return TransitionCheckResult::Fail; + }; + if self.evaluate_clauses(new_document_clauses, &id_value, &data) { TransitionCheckResult::Pass } else { TransitionCheckResult::Fail @@ -182,7 +197,11 @@ impl DriveDocumentQueryFilter<'_> { let final_ok = if new_document_clauses.is_empty() { true } else { - self.evaluate_clauses(new_document_clauses, &id_value, replace.data()) + let Some(data) = self.data_as_stored(replace.data(), platform_version) + else { + return TransitionCheckResult::Fail; + }; + self.evaluate_clauses(new_document_clauses, &id_value, &data) }; if !final_ok { return TransitionCheckResult::Fail; @@ -334,11 +353,12 @@ impl DriveDocumentQueryFilter<'_> { // so the "original document" clauses evaluate // directly against them and no original is ever // needed. - if self.evaluate_clauses( - original_document_clauses, - &id_value, - index_only_delete.data(), - ) { + let Some(values) = + self.data_as_stored(index_only_delete.data(), platform_version) + else { + return TransitionCheckResult::Fail; + }; + if self.evaluate_clauses(original_document_clauses, &id_value, &values) { TransitionCheckResult::Pass } else { TransitionCheckResult::Fail @@ -391,6 +411,23 @@ impl DriveDocumentQueryFilter<'_> { } } + /// A transition's `data` as the platform stores it, every `generatedFrom` property it + /// leaves out generated ([`DocumentTypeBasicMethods::data_as_stored`]). `None`, which + /// fails the match, for a document type the contract lacks (`validate` refuses the + /// filter then) or a version table this build does not know. + #[cfg(any(feature = "server", feature = "verify"))] + fn data_as_stored<'d>( + &self, + data: &'d BTreeMap, + platform_version: &PlatformVersion, + ) -> Option>> { + let document_type = self + .contract + .document_type_for_name(&self.document_type_name) + .ok()?; + document_type.data_as_stored(data, platform_version).ok() + } + /// Single clause evaluator used by both transition and original-document paths. #[cfg(any(feature = "server", feature = "verify"))] fn evaluate_clauses( @@ -1078,7 +1115,7 @@ mod tests { // First check should pass without needing original assert_eq!( - filter.matches_document_transition(&transfer, None), + filter.matches_document_transition(&transfer, None, PlatformVersion::latest()), TransitionCheckResult::Pass ); @@ -1092,7 +1129,7 @@ mod tests { let transfer_mismatch = DocumentTransition::Transfer(DocumentTransferTransition::V0(transfer_v0_mismatch)); assert_eq!( - filter.matches_document_transition(&transfer_mismatch, None), + filter.matches_document_transition(&transfer_mismatch, None, PlatformVersion::latest()), TransitionCheckResult::Fail ); } @@ -1139,13 +1176,17 @@ mod tests { // Without batch owner context, should fail (owner clause requires it) assert_eq!( - filter.matches_document_transition(&purchase, None), + filter.matches_document_transition(&purchase, None, PlatformVersion::latest()), TransitionCheckResult::Fail ); // With batch owner context, should pass let owner_value = Value::Identifier(purchaser.to_buffer()); assert_eq!( - filter.matches_document_transition(&purchase, Some(&owner_value)), + filter.matches_document_transition( + &purchase, + Some(&owner_value), + PlatformVersion::latest() + ), TransitionCheckResult::Pass ); } @@ -1282,7 +1323,7 @@ mod tests { }; // Price-only clause is decided in first check assert_eq!( - filter_price_only.matches_document_transition(&update, None), + filter_price_only.matches_document_transition(&update, None, PlatformVersion::latest()), TransitionCheckResult::Pass ); @@ -1298,7 +1339,11 @@ mod tests { }, }; assert_eq!( - filter_price_only_fail.matches_document_transition(&update, None), + filter_price_only_fail.matches_document_transition( + &update, + None, + PlatformVersion::latest() + ), TransitionCheckResult::Fail ); @@ -1329,7 +1374,7 @@ mod tests { let mut original_doc = BTreeMap::new(); original_doc.insert("kind".to_string(), Value::Text("sale".to_string())); assert_eq!( - filter_with_orig.matches_document_transition(&update, None), + filter_with_orig.matches_document_transition(&update, None, PlatformVersion::latest()), TransitionCheckResult::NeedsOriginal ); let original_document = Document::V0(DocumentV0 { @@ -1412,7 +1457,7 @@ mod tests { let mut original_doc = BTreeMap::new(); original_doc.insert("status".to_string(), Value::Text("active".to_string())); assert_eq!( - filter.matches_document_transition(&replace, None), + filter.matches_document_transition(&replace, None, PlatformVersion::latest()), TransitionCheckResult::NeedsOriginal ); let original_document = Document::V0(DocumentV0 { @@ -1443,12 +1488,180 @@ mod tests { v0.data.insert("score".to_string(), Value::U64(9)); let bad_final = DocumentTransition::Replace(rep); assert_eq!( - filter.matches_document_transition(&bad_final, None), + filter.matches_document_transition(&bad_final, None, PlatformVersion::latest()), TransitionCheckResult::Fail ); } } + /// A `handle` type whose `normalizedLabel` is generated from `label`, and an + /// indexOnly `entry` type whose `normalizedName` is generated from `name` + /// (protocol version 14). + fn generated_from_contract() -> DataContract { + use dpp::data_contract::DataContractFactory; + use dpp::platform_value::platform_value; + + let generated = |source: &str| { + platform_value!({ + "function": "sys.stringTransformations.homographSafeASCII", + "params": [source] + }) + }; + let documents = platform_value!({ + "handle": { + "type": "object", + "documentsMutable": true, + "properties": { + "label": { "type": "string", "maxLength": 32, "position": 0 }, + "normalizedLabel": { + "type": "string", + "maxLength": 32, + "position": 1, + "generatedFrom": generated("label") + } + }, + "additionalProperties": false + }, + "entry": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "indices": [ + { + "name": "byNormalizedName", + "properties": [{ "normalizedName": "asc" }, { "name": "asc" }], + "terminal": "$ownerId" + } + ], + "properties": { + "name": { "type": "string", "maxLength": 32, "position": 0 }, + "normalizedName": { + "type": "string", + "maxLength": 32, + "position": 1, + "generatedFrom": generated("name") + } + }, + "required": ["name", "normalizedName"], + "additionalProperties": false + } + }); + DataContractFactory::new(PlatformVersion::latest().protocol_version) + .expect("factory") + .create_with_value_config(Identifier::from([1u8; 32]), 1, documents, None, None) + .expect("the contract parses") + .data_contract_owned() + } + + /// A clause on a `generatedFrom` property judges a create, a replace and an + /// indexOnly delete that leave the property out as the platform stores them, + /// with the value generated from its param. + #[test] + fn test_matches_a_generated_property_the_transition_leaves_out() { + use dpp::state_transition::batch_transition::batched_transition::document_create_transition::v0::DocumentCreateTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::document_create_transition::DocumentCreateTransition; + use dpp::state_transition::batch_transition::batched_transition::document_index_only_delete_transition::v0::DocumentIndexOnlyDeleteTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::document_index_only_delete_transition::DocumentIndexOnlyDeleteTransition; + use dpp::state_transition::batch_transition::batched_transition::document_replace_transition::v0::DocumentReplaceTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::document_replace_transition::DocumentReplaceTransition; + + let contract = generated_from_contract(); + let platform_version = PlatformVersion::latest(); + let equal = |field: &str, value: &str| InternalClauses { + equal_clauses: BTreeMap::from([( + field.to_string(), + WhereClause { + field: field.to_string(), + operator: WhereOperator::Equal, + value: Value::Text(value.to_string()), + }, + )]), + ..Default::default() + }; + let base = |document_type_name: &str| { + DocumentBaseTransition::V1(DocumentBaseTransitionV1 { + id: Identifier::from([3u8; 32]), + document_type_name: document_type_name.to_string(), + data_contract_id: contract.id(), + identity_contract_nonce: 0, + token_payment_info: None, + }) + }; + let data = |key: &str, value: &str| { + BTreeMap::from([(key.to_string(), Value::Text(value.to_string()))]) + }; + + let create = + DocumentTransition::Create(DocumentCreateTransition::V0(DocumentCreateTransitionV0 { + base: base("handle"), + entropy: [0u8; 32], + data: data("label", "Bob"), + prefunded_voting_balance: None, + })); + let replace = DocumentTransition::Replace(DocumentReplaceTransition::V0( + DocumentReplaceTransitionV0 { + base: base("handle"), + revision: 2, + data: data("label", "Bob"), + }, + )); + let index_only_delete = DocumentTransition::IndexOnlyDelete( + DocumentIndexOnlyDeleteTransition::V0(DocumentIndexOnlyDeleteTransitionV0 { + base: base("entry"), + data: data("name", "Bob"), + }), + ); + + for (normalized, expected) in [ + ("b0b", TransitionCheckResult::Pass), + ("bob", TransitionCheckResult::Fail), + ] { + let create_filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "handle".to_string(), + action_clauses: DocumentActionMatchClauses::Create { + new_document_clauses: equal("normalizedLabel", normalized), + }, + }; + assert_eq!( + create_filter.matches_document_transition(&create, None, platform_version), + expected, + "create, normalizedLabel == {normalized:?}" + ); + + let replace_filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "handle".to_string(), + action_clauses: DocumentActionMatchClauses::Replace { + original_document_clauses: InternalClauses::default(), + new_document_clauses: equal("normalizedLabel", normalized), + }, + }; + assert_eq!( + replace_filter.matches_document_transition(&replace, None, platform_version), + expected, + "replace, normalizedLabel == {normalized:?}" + ); + + let delete_filter = DriveDocumentQueryFilter { + contract: &contract, + document_type_name: "entry".to_string(), + action_clauses: DocumentActionMatchClauses::Delete { + original_document_clauses: equal("normalizedName", normalized), + }, + }; + assert_eq!( + delete_filter.matches_document_transition( + &index_only_delete, + None, + platform_version + ), + expected, + "indexOnly delete, normalizedName == {normalized:?}" + ); + } + } + #[test] fn test_matches_document_with_between_operator() { let fixture = get_data_contract_fixture(None, 0, LATEST_PLATFORM_VERSION.protocol_version); diff --git a/packages/rs-drive/src/query/index_only_synthesis.rs b/packages/rs-drive/src/query/index_only_synthesis.rs index fe3d5507626..bfcfecb51ea 100644 --- a/packages/rs-drive/src/query/index_only_synthesis.rs +++ b/packages/rs-drive/src/query/index_only_synthesis.rs @@ -1105,8 +1105,7 @@ pub fn index_only_transition_entry_path_query( owner_id: Identifier, platform_version: &PlatformVersion, ) -> Result { - let mut data = data.clone(); - document_type.fill_generated_properties(&mut data, platform_version)?; + let data = document_type.data_as_stored(data, platform_version)?; let index = index_only_proof_index(&document_type)?; let (path, member_key) = index_only_entry_path_and_key_from_values( contract_id, From c5dee053c36c408ddf7e85867690679d9ab2684c Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 17:42:44 +0700 Subject: [PATCH 077/113] fix(swift-sdk): free FFI errors in state-transition wrappers (#5117) Co-authored-by: Claude Opus 5.5 --- .../SwiftDashSDK/FFI/StateTransitionExtensions.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift index fdc8b66b7ef..b44ce06b43a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift @@ -319,6 +319,7 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" + dash_sdk_error_free(result.error) continuation.resume(throwing: SDKError.internalError(errorString)) } } @@ -371,6 +372,7 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" + dash_sdk_error_free(result.error) continuation.resume(throwing: SDKError.internalError(errorString)) } } @@ -428,6 +430,7 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" + dash_sdk_error_free(result.error) continuation.resume(throwing: SDKError.internalError(errorString)) } } @@ -676,6 +679,7 @@ extension SDK { let contractHandle = contractResult.data else { if let error = contractResult.error { let errorMsg = String(cString: error.pointee.message) + dash_sdk_error_free(error) print("❌ [DOCUMENT REPLACE] Failed to fetch contract: \(errorMsg)") continuation.resume(throwing: SDKError.protocolError(errorMsg)) } else { @@ -962,6 +966,7 @@ extension SDK { let contractHandle = contractResult.data else { if let error = contractResult.error { let errorMsg = String(cString: error.pointee.message) + dash_sdk_error_free(error) print("❌ [DOCUMENT TRANSFER] Failed to fetch contract: \(errorMsg)") continuation.resume(throwing: SDKError.protocolError(errorMsg)) } else { @@ -996,6 +1001,7 @@ extension SDK { let documentHandle = fetchResult.data else { let error = fetchResult.error.pointee let errorMsg = String(cString: error.message) + dash_sdk_error_free(fetchResult.error) print("❌ [DOCUMENT TRANSFER] Failed to fetch document: \(errorMsg)") continuation.resume(throwing: SDKError.protocolError(errorMsg)) return @@ -1028,6 +1034,7 @@ extension SDK { guard transitionResult.error == nil else { let error = transitionResult.error.pointee let errorMsg = String(cString: error.message) + dash_sdk_error_free(transitionResult.error) print("❌ [DOCUMENT TRANSFER] Failed to create transition: \(errorMsg)") continuation.resume(throwing: SDKError.protocolError(errorMsg)) return @@ -1055,6 +1062,7 @@ extension SDK { if result.error != nil { let error = result.error.pointee let errorMsg = String(cString: error.message) + dash_sdk_error_free(result.error) // Check if it's the "already in chain" error if errorMsg.contains("already in chain") || errorMsg.contains("AlreadyExists") { @@ -1128,6 +1136,7 @@ extension SDK { guard contractResult.error == nil else { let error = contractResult.error.pointee let errorMsg = String(cString: error.message) + dash_sdk_error_free(contractResult.error) print("❌ [DOCUMENT UPDATE PRICE] Failed to fetch contract: \(errorMsg)") continuation.resume(throwing: SDKError.protocolError(errorMsg)) return @@ -1160,6 +1169,7 @@ extension SDK { guard fetchResult.error == nil else { let error = fetchResult.error.pointee let errorMsg = String(cString: error.message) + dash_sdk_error_free(fetchResult.error) print("❌ [DOCUMENT UPDATE PRICE] Failed to fetch document: \(errorMsg)") continuation.resume(throwing: SDKError.protocolError(errorMsg)) return @@ -1211,6 +1221,7 @@ extension SDK { if updateResult.error != nil { let error = updateResult.error.pointee let errorMsg = String(cString: error.message) + dash_sdk_error_free(updateResult.error) print("❌ [DOCUMENT UPDATE PRICE] Failed: \(errorMsg)") continuation.resume(throwing: SDKError.protocolError(errorMsg)) return From c83c2c05e7374160593cfaa455eed2f6f9c7413c Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 17:55:58 +0700 Subject: [PATCH 078/113] fix(sdk): consensus errors reach Swift and Kotlin apps with their code (#5116) Co-authored-by: Claude Opus 5.5 --- packages/kotlin-sdk/CLAUDE.md | 6 +- .../dashsdk/errors/PlatformConsensusError.kt | 13 +- .../dashsdk/ffi/DashSDKException.kt | 6 +- .../dashsdk/errors/DashSdkErrorTest.kt | 32 ++ packages/rs-sdk-ffi/src/data_contract/put.rs | 9 +- packages/rs-sdk-ffi/src/document/delete.rs | 6 +- packages/rs-sdk-ffi/src/document/price.rs | 6 +- packages/rs-sdk-ffi/src/document/purchase.rs | 8 +- packages/rs-sdk-ffi/src/document/put.rs | 12 +- packages/rs-sdk-ffi/src/document/replace.rs | 4 +- packages/rs-sdk-ffi/src/document/transfer.rs | 8 +- packages/rs-sdk-ffi/src/error.rs | 359 +++++++++++++++++- packages/rs-sdk-ffi/src/identity/put.rs | 18 +- packages/rs-sdk-ffi/src/identity/transfer.rs | 2 +- packages/rs-sdk-ffi/src/identity/withdraw.rs | 2 +- packages/rs-sdk-ffi/src/token/burn.rs | 2 +- packages/rs-sdk-ffi/src/token/claim.rs | 2 +- .../rs-sdk-ffi/src/token/config_update.rs | 2 +- .../src/token/destroy_frozen_funds.rs | 2 +- .../rs-sdk-ffi/src/token/emergency_action.rs | 2 +- packages/rs-sdk-ffi/src/token/freeze.rs | 2 +- packages/rs-sdk-ffi/src/token/mint.rs | 2 +- packages/rs-sdk-ffi/src/token/purchase.rs | 2 +- packages/rs-sdk-ffi/src/token/set_price.rs | 2 +- packages/rs-sdk-ffi/src/token/transfer.rs | 2 +- packages/rs-sdk-ffi/src/token/unfreeze.rs | 2 +- packages/rs-unified-sdk-jni/src/queries.rs | 14 +- packages/rs-unified-sdk-jni/src/results.rs | 36 +- packages/rs-unified-sdk-jni/src/support.rs | 62 ++- .../FFI/PlatformQueryExtensions.swift | 3 +- .../FFI/StateTransitionExtensions.swift | 63 ++- .../PlatformWallet/PlatformWalletResult.swift | 28 +- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 38 ++ .../Views/DiagnosticsView.swift | 2 + .../Views/QueryDetailView.swift | 2 + .../ErrorHandlingTests.swift | 111 ++++++ 36 files changed, 753 insertions(+), 119 deletions(-) diff --git a/packages/kotlin-sdk/CLAUDE.md b/packages/kotlin-sdk/CLAUDE.md index bdca8c34ef9..ad7393bc35f 100644 --- a/packages/kotlin-sdk/CLAUDE.md +++ b/packages/kotlin-sdk/CLAUDE.md @@ -32,9 +32,9 @@ are data encrypted under Keystore-wrapped AES keys). `extern "C"` entry points of the FFI crates **as rlib dependencies**, so `DashSDKResult` never crosses JNI by value. Errors throw `org.dashfoundation.dashsdk.ffi.DashSDKException(code, message)`, which - also carries the consensus code and kind when a platform-wallet result - reports a consensus rejection (`DashSdkError.consensusError`; branch on - that, never on the message); panics + also carries the consensus code and kind when a platform-wallet result or + an rs-sdk-ffi `DashSDKError` reports a consensus rejection + (`DashSdkError.consensusError`; branch on that, never on the message); panics are caught at every export (`support::guard`) — the JNI library must never abort the app process (workspace profiles `*-android` keep `panic = "unwind"`). diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/PlatformConsensusError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/PlatformConsensusError.kt index 52cb67eb152..7a26cd981b0 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/PlatformConsensusError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/PlatformConsensusError.kt @@ -7,10 +7,11 @@ package org.dashfoundation.dashsdk.errors * to. Branch on these rather than on the error message, whose wording is not * a contract. * - * Read it from [DashSdkError.consensusError]. It is present for the wallet - * operations whose native error still holds the SDK's consensus verdict (the - * token state transitions among them) and `null` for every failure that was - * not a consensus rejection. + * Read it from [DashSdkError.consensusError]. It is present for the state + * transitions whose native error still holds the SDK's consensus verdict: the + * rs-sdk-ffi document, token, data contract and identity transitions, and the + * platform-wallet operations (the token state transitions among them). It is + * `null` for every failure that was not a consensus rejection. */ data class PlatformConsensusError( val code: Int, @@ -21,7 +22,9 @@ data class PlatformConsensusError( * Which of rs-dpp's consensus error families an error belongs to. The native * layer reports it next to the code, so it is never derived from the number * on this side. Values mirror `PlatformWalletFFIConsensusErrorKind` in - * `rs-platform-wallet-ffi/src/error.rs`. + * `rs-platform-wallet-ffi/src/error.rs` and `DashSDKConsensusErrorKind` in + * `rs-sdk-ffi/src/error.rs`, which a compile-time guard in + * `rs-unified-sdk-jni/src/support.rs` holds equal. */ enum class ConsensusErrorKind { /** Structure or version validation failed; the transition was not executed. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashSDKException.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashSDKException.kt index fe8ab7c976d..ff35b52834f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashSDKException.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashSDKException.kt @@ -16,7 +16,7 @@ import org.dashfoundation.dashsdk.errors.PlatformConsensusError * 8=Timeout, 9=NotImplemented, 10=DriveInternalError, 99=InternalError. * * [consensusError] is the consensus rejection behind the failure when the - * native result carried one, `null` otherwise. + * native result or `DashSDKError` carried one, `null` otherwise. * * Internal: the public API maps this into the * [org.dashfoundation.dashsdk.errors.DashSdkError] hierarchy. @@ -30,7 +30,9 @@ class DashSDKException( /** * JNI entry for a failure that is a consensus rejection. [consensusKind] - * is the `PlatformWalletFFIConsensusErrorKind` discriminant. + * is the discriminant of the native kind: `PlatformWalletFFIConsensusErrorKind` + * for a platform-wallet result, `DashSDKConsensusErrorKind` for an + * rs-sdk-ffi error. The two share their values. */ constructor(code: Int, message: String, consensusCode: Int, consensusKind: Int) : this( code, diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 1254c322813..7129c55f633 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -521,4 +521,36 @@ class DashSdkErrorTest { // An SDK error raised on the Kotlin side has no native cause at all. assertNull(DashSdkError.InvalidParameter("bad argument").consensusError) } + + @Test + fun shouldSurfaceTheConsensusErrorOfAStateTransitionPlatformRejected() { + // What the JNI bridge throws for an rs-sdk-ffi DashSDKError Platform + // refused under a propertyConstraints rule: ProtocolError (5), + // consensus code 10422, kind Basic (1). + val native = DashSDKException( + 5, + "Protocol error: document violates propertyConstraints rule 0", + 10422, + 1, + ) + val mapped = DashSdkError.fromNative(native) + + // The consensus error rides along; the type and message are what the + // code alone maps to. + assertTrue(mapped is DashSdkError.ProtocolError) + assertEquals(native.message, mapped.message) + assertEquals( + PlatformConsensusError(10422, ConsensusErrorKind.BASIC), + mapped.consensusError, + ) + } + + @Test + fun shouldHaveNoConsensusErrorForAnSdkFailureThatWasNotARejection() { + val plain = DashSdkError.fromNative( + DashSDKException(99, "Internal error: Failed to mint token and wait: timed out"), + ) + assertTrue(plain is DashSdkError.InternalError) + assertNull(plain.consensusError) + } } diff --git a/packages/rs-sdk-ffi/src/data_contract/put.rs b/packages/rs-sdk-ffi/src/data_contract/put.rs index 7d1873a41d2..796b4d55070 100644 --- a/packages/rs-sdk-ffi/src/data_contract/put.rs +++ b/packages/rs-sdk-ffi/src/data_contract/put.rs @@ -46,9 +46,7 @@ pub unsafe extern "C" fn dash_sdk_data_contract_put_to_platform( None, // settings (use defaults) ) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to put data contract to platform: {}", e)) - })?; + .map_err(|e| FFIError::sdk_call_failed("Failed to put data contract to platform", e))?; // Serialize the state transition with bincode let config = bincode::config::standard(); @@ -106,10 +104,7 @@ pub unsafe extern "C" fn dash_sdk_data_contract_put_to_platform_and_wait( ) .await .map_err(|e| { - FFIError::InternalError(format!( - "Failed to put data contract to platform and wait: {}", - e - )) + FFIError::sdk_call_failed("Failed to put data contract to platform and wait", e) })?; Ok(confirmed_contract) diff --git a/packages/rs-sdk-ffi/src/document/delete.rs b/packages/rs-sdk-ffi/src/document/delete.rs index 864bc8019bb..adc460b9655 100644 --- a/packages/rs-sdk-ffi/src/document/delete.rs +++ b/packages/rs-sdk-ffi/src/document/delete.rs @@ -161,9 +161,7 @@ pub unsafe extern "C" fn dash_sdk_document_delete( wrapper.sdk.version(), ) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to create delete transition: {}", e)) - })?; + .map_err(|e| FFIError::sdk_call_failed("Failed to create delete transition", e))?; // Serialize the state transition with bincode let config = bincode::config::standard(); @@ -353,7 +351,7 @@ pub unsafe extern "C" fn dash_sdk_document_delete_and_wait( .await .map_err(|e| { error!(error = %e, key_id = identity_public_key.id(), "[DOCUMENT DELETE] SDK call failed"); - FFIError::InternalError(format!("Failed to delete document and wait: {}", e)) + FFIError::sdk_call_failed("Failed to delete document and wait", e) })?; info!("[DOCUMENT DELETE] SDK call completed successfully"); diff --git a/packages/rs-sdk-ffi/src/document/price.rs b/packages/rs-sdk-ffi/src/document/price.rs index 10197aa6a2b..75e3a1052d7 100644 --- a/packages/rs-sdk-ffi/src/document/price.rs +++ b/packages/rs-sdk-ffi/src/document/price.rs @@ -147,9 +147,7 @@ pub unsafe extern "C" fn dash_sdk_document_update_price_of_document( wrapper.sdk.version(), ) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to create set price transition: {}", e)) - })?; + .map_err(|e| FFIError::sdk_call_failed("Failed to create set price transition", e))?; // Serialize the state transition with bincode let config = bincode::config::standard(); @@ -286,7 +284,7 @@ pub unsafe extern "C" fn dash_sdk_document_update_price_of_document_and_wait( .document_set_price(builder, identity_public_key, signer) .await .map_err(|e| { - FFIError::InternalError(format!("Failed to update document price and wait: {}", e)) + FFIError::sdk_call_failed("Failed to update document price and wait", e) })?; let dash_sdk::platform::documents::transitions::DocumentSetPriceResult::Document( diff --git a/packages/rs-sdk-ffi/src/document/purchase.rs b/packages/rs-sdk-ffi/src/document/purchase.rs index 8d45477eb1f..d7aa53c4c69 100644 --- a/packages/rs-sdk-ffi/src/document/purchase.rs +++ b/packages/rs-sdk-ffi/src/document/purchase.rs @@ -164,9 +164,7 @@ pub unsafe extern "C" fn dash_sdk_document_purchase( wrapper.sdk.version(), ) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to create purchase transition: {}", e)) - })?; + .map_err(|e| FFIError::sdk_call_failed("Failed to create purchase transition", e))?; // Serialize the state transition with bincode let config = bincode::config::standard(); @@ -332,9 +330,7 @@ pub unsafe extern "C" fn dash_sdk_document_purchase_and_wait( .sdk .document_purchase(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to purchase document and wait: {}", e)) - })?; + .map_err(|e| FFIError::sdk_call_failed("Failed to purchase document and wait", e))?; let dash_sdk::platform::documents::transitions::DocumentPurchaseResult::Document( purchased_document, diff --git a/packages/rs-sdk-ffi/src/document/put.rs b/packages/rs-sdk-ffi/src/document/put.rs index d492b6bb065..049146549ea 100644 --- a/packages/rs-sdk-ffi/src/document/put.rs +++ b/packages/rs-sdk-ffi/src/document/put.rs @@ -179,9 +179,7 @@ pub unsafe extern "C" fn dash_sdk_document_put_to_platform( ) .await } - .map_err(|e| { - FFIError::InternalError(format!("Failed to create document transition: {}", e)) - })?; + .map_err(|e| FFIError::sdk_call_failed("Failed to create document transition", e))?; // Serialize the state transition with bincode let config = bincode::config::standard(); @@ -314,9 +312,7 @@ pub unsafe extern "C" fn dash_sdk_document_put_to_platform_and_wait( .sdk .document_create(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to create document and wait: {}", e)) - })?; + .map_err(|e| FFIError::sdk_call_failed("Failed to create document and wait", e))?; match result { dash_sdk::platform::documents::transitions::DocumentCreateResult::Document(doc) => { @@ -351,9 +347,7 @@ pub unsafe extern "C" fn dash_sdk_document_put_to_platform_and_wait( .sdk .document_replace(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to replace document and wait: {}", e)) - })?; + .map_err(|e| FFIError::sdk_call_failed("Failed to replace document and wait", e))?; match result { dash_sdk::platform::documents::transitions::DocumentReplaceResult::Document( diff --git a/packages/rs-sdk-ffi/src/document/replace.rs b/packages/rs-sdk-ffi/src/document/replace.rs index 6be96648c02..af3a27e9c1e 100644 --- a/packages/rs-sdk-ffi/src/document/replace.rs +++ b/packages/rs-sdk-ffi/src/document/replace.rs @@ -149,7 +149,7 @@ pub unsafe extern "C" fn dash_sdk_document_replace_on_platform( .await .map_err(|e| { error!(error = %e, key_id = identity_public_key.id(), "[DOCUMENT REPLACE] failed to sign transition"); - FFIError::InternalError(format!("Failed to create replace transition: {}", e)) + FFIError::sdk_call_failed("Failed to create replace transition", e) })?; debug!("[DOCUMENT REPLACE] state transition created, serializing"); @@ -357,7 +357,7 @@ pub unsafe extern "C" fn dash_sdk_document_replace_on_platform_and_wait( "❌ [DOCUMENT REPLACE] Failed with key ID: {}", identity_public_key.id() ); - FFIError::InternalError(format!("Failed to replace document and wait: {}", e)) + FFIError::sdk_call_failed("Failed to replace document and wait", e) })?; eprintln!("✅ [DOCUMENT REPLACE] SDK call completed successfully"); diff --git a/packages/rs-sdk-ffi/src/document/transfer.rs b/packages/rs-sdk-ffi/src/document/transfer.rs index 00523d400a1..ae099014457 100644 --- a/packages/rs-sdk-ffi/src/document/transfer.rs +++ b/packages/rs-sdk-ffi/src/document/transfer.rs @@ -175,9 +175,7 @@ pub unsafe extern "C" fn dash_sdk_document_transfer_to_identity( wrapper.sdk.version(), ) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to create transfer transition: {}", e)) - })?; + .map_err(|e| FFIError::sdk_call_failed("Failed to create transfer transition", e))?; // Serialize the state transition with bincode let config = bincode::config::standard(); @@ -342,9 +340,7 @@ pub unsafe extern "C" fn dash_sdk_document_transfer_to_identity_and_wait( .sdk .document_transfer(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to transfer document and wait: {}", e)) - })?; + .map_err(|e| FFIError::sdk_call_failed("Failed to transfer document and wait", e))?; let dash_sdk::platform::documents::transitions::DocumentTransferResult::Document( transferred_document, diff --git a/packages/rs-sdk-ffi/src/error.rs b/packages/rs-sdk-ffi/src/error.rs index a33583e4e07..a0139871df7 100644 --- a/packages/rs-sdk-ffi/src/error.rs +++ b/packages/rs-sdk-ffi/src/error.rs @@ -1,5 +1,7 @@ //! Error handling for FFI layer +use dash_sdk::dpp::consensus::codes::ErrorWithCode; +use dash_sdk::dpp::ProtocolError; use std::ffi::{CString, NulError}; use std::os::raw::c_char; use thiserror::Error; @@ -34,7 +36,53 @@ pub enum DashSDKErrorCode { InternalError = 99, } +/// Which family a consensus rejection belongs to, paired with the numeric +/// `consensus_code` on [`DashSDKError`]. +/// +/// rs-dpp numbers its consensus errors by family +/// (`packages/rs-dpp/src/errors/consensus/codes.rs`): basic 1xxxx, signature +/// 2xxxx, fee 3xxxx, state 4xxxx. The values are those of +/// `PlatformWalletFFIConsensusErrorKind` in rs-platform-wallet-ffi, so a host +/// decodes the kind of either FFI's error the same way. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DashSDKConsensusErrorKind { + /// Not a consensus rejection; pairs with `consensus_code == 0` + ConsensusErrorKindNone = 0, + /// Structure or version validation refused the transition + ConsensusErrorKindBasic = 1, + /// The transition's signature or signing key was refused + ConsensusErrorKindSignature = 2, + /// The fee could not be covered + ConsensusErrorKindFee = 3, + /// The transition was well formed but Platform state refused it + ConsensusErrorKindState = 4, +} + +impl DashSDKConsensusErrorKind { + /// The family rs-dpp's numbering puts `code` in. `ConsensusErrorKindNone` + /// for any other number: `ConsensusError::DefaultError` (1) names no + /// rejection, and a wait-for-result failure that is not a consensus + /// rejection carries a gRPC status code (1 to 16) instead. + fn of_code(code: u32) -> Self { + match code { + 10_000..=19_999 => Self::ConsensusErrorKindBasic, + 20_000..=29_999 => Self::ConsensusErrorKindSignature, + 30_000..=39_999 => Self::ConsensusErrorKindFee, + 40_000..=49_999 => Self::ConsensusErrorKindState, + _ => Self::ConsensusErrorKindNone, + } + } +} + /// Error structure returned by FFI functions +/// +/// `code` and `message` are the pair every host has always read. +/// `consensus_code` and `consensus_kind` name Platform's own rejection when +/// the failure was one, so a host can branch on the number instead of +/// matching the message. They are the same pair `PlatformWalletFFIResult` +/// carries. Rust allocates and frees every `DashSDKError` and hosts read it +/// through a pointer, so the two fields follow `message`. #[repr(C)] pub struct DashSDKError { /// Error code @@ -42,6 +90,14 @@ pub struct DashSDKError { /// Human-readable error message (null-terminated C string) /// Caller must free this with dash_sdk_error_free pub message: *mut c_char, + /// The rs-dpp consensus error code Platform refused the operation with + /// (10422 for a violated propertyConstraints rule, 41107 for a banned + /// user), or 0 when the failure was not a consensus rejection. Real codes + /// start at 10000, so 0 is unambiguous. + pub consensus_code: u32, + /// The family `consensus_code` belongs to, or `ConsensusErrorKindNone` + /// when there is no code. + pub consensus_kind: DashSDKConsensusErrorKind, } /// Internal error type for FFI operations @@ -53,6 +109,15 @@ pub enum FFIError { #[error("SDK error: {0}")] SDKError(#[from] dash_sdk::Error), + /// An SDK call failed. Renders and classifies as the + /// `InternalError(format!("{context}: {source}"))` it replaces, and keeps + /// `source` so the consensus code it carries reaches the host. + #[error("Internal error: {context}: {source}")] + SDKCallFailed { + context: String, + source: dash_sdk::Error, + }, + #[error("Serialization error: {0}")] SerializationError(#[from] serde_json::Error), @@ -87,6 +152,8 @@ impl DashSDKError { DashSDKError { code, message: c_message.into_raw(), + consensus_code: 0, + consensus_kind: DashSDKConsensusErrorKind::ConsensusErrorKindNone, } } @@ -95,6 +162,56 @@ impl DashSDKError { DashSDKError { code: DashSDKErrorCode::Success, message: std::ptr::null_mut(), + consensus_code: 0, + consensus_kind: DashSDKConsensusErrorKind::ConsensusErrorKindNone, + } + } + + /// Stamp the consensus rejection `error` carries, if any, onto this error. + /// + /// Leaves `code` and `message` alone: hosts already branch on them, so the + /// consensus code goes beside them rather than in place of them. + pub fn with_consensus_error_of(mut self, error: &dash_sdk::Error) -> Self { + if let Some(consensus_code) = consensus_code_of(error) { + let kind = DashSDKConsensusErrorKind::of_code(consensus_code); + if kind != DashSDKConsensusErrorKind::ConsensusErrorKindNone { + self.consensus_code = consensus_code; + self.consensus_kind = kind; + } + } + self + } +} + +/// The consensus error code in the shapes a Platform refusal reaches the SDK +/// in: a CheckTx refusal decoded from the `dash-serialized-consensus-error-bin` +/// gRPC metadata (the same `Protocol(ConsensusError)` the SDK's own checks +/// return before broadcasting), a wait-for-result failure, and the retry +/// envelope around either. A wait-for-result failure keeps its `code` even +/// when its consensus error did not come with it. +fn consensus_code_of(error: &dash_sdk::Error) -> Option { + match error { + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(consensus_error)) => { + Some(consensus_error.code()) + } + dash_sdk::Error::StateTransitionBroadcastError(broadcast_error) => Some( + broadcast_error + .cause + .as_ref() + .map_or(broadcast_error.code, |cause| cause.code()), + ), + dash_sdk::Error::NoAvailableAddressesToRetry(last_error) => consensus_code_of(last_error), + _ => None, + } +} + +impl FFIError { + /// `source` as an internal error with the message `"{context}: {source}"`, + /// keeping the SDK error so its consensus code reaches the host. + pub fn sdk_call_failed(context: impl Into, source: dash_sdk::Error) -> Self { + FFIError::SDKCallFailed { + context: context.into(), + source, } } } @@ -103,6 +220,7 @@ impl From for DashSDKError { fn from(err: FFIError) -> Self { let (code, message) = match &err { FFIError::InvalidParameter(_) => (DashSDKErrorCode::InvalidParameter, err.to_string()), + FFIError::SDKCallFailed { .. } => (DashSDKErrorCode::InternalError, err.to_string()), FFIError::SDKError(sdk_err) => { // Extract more detailed error information let error_str = sdk_err.to_string(); @@ -187,7 +305,13 @@ impl From for DashSDKError { FFIError::NulError(_) => (DashSDKErrorCode::InvalidParameter, err.to_string()), }; - DashSDKError::new(code, message) + let error = DashSDKError::new(code, message); + match &err { + FFIError::SDKError(source) | FFIError::SDKCallFailed { source, .. } => { + error.with_consensus_error_of(source) + } + _ => error, + } } } @@ -321,4 +445,237 @@ mod tests { assert_eq!(rendered, expected); assert!(!rendered.contains("Failed to fetch balances")); } + + mod consensus_code { + use super::*; + use dash_sdk::dapi_client::transport::TransportError; + use dash_sdk::dapi_client::DapiClientError; + use dash_sdk::dapi_grpc::tonic::metadata::{MetadataMap, MetadataValue}; + use dash_sdk::dapi_grpc::tonic::{Code, Status}; + use dash_sdk::dpp::consensus::basic::document::{ + DocumentPropertyConstraintViolatedError, PropertyConstraintViolation, + }; + use dash_sdk::dpp::consensus::state::contract_moderation::ContractUserBannedError; + use dash_sdk::dpp::consensus::ConsensusError; + use dash_sdk::dpp::platform_value::Identifier; + use dash_sdk::dpp::serialization::PlatformSerializableWithPlatformVersion; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::error::StateTransitionBroadcastError; + + /// What a host reads from the error, with the message freed. + struct Converted { + code: DashSDKErrorCode, + message: String, + consensus_code: u32, + consensus_kind: DashSDKConsensusErrorKind, + } + + fn convert(err: FFIError) -> Converted { + let error: DashSDKError = err.into(); + let message = unsafe { + let message = std::ffi::CStr::from_ptr(error.message) + .to_string_lossy() + .into_owned(); + let _ = CString::from_raw(error.message); + message + }; + Converted { + code: error.code, + message, + consensus_code: error.consensus_code, + consensus_kind: error.consensus_kind, + } + } + + fn property_constraint_violated() -> ConsensusError { + DocumentPropertyConstraintViolatedError::new( + "post".to_string(), + "rule 0".to_string(), + PropertyConstraintViolation::NotMet, + ) + .into() + } + + fn contract_user_banned() -> ConsensusError { + ContractUserBannedError::new(Identifier::new([1; 32]), Identifier::new([2; 32])).into() + } + + /// What the SDK makes of DAPI refusing a transition at CheckTx: a gRPC + /// status carrying the serialized consensus error in its metadata. + fn refused_by_platform(consensus_error: &ConsensusError) -> dash_sdk::Error { + let bytes = consensus_error + .serialize_to_bytes_with_platform_version(PlatformVersion::latest()) + .expect("serialize consensus error"); + let mut metadata = MetadataMap::new(); + metadata.insert_bin( + "dash-serialized-consensus-error-bin", + MetadataValue::from_bytes(&bytes), + ); + let status = + Status::with_metadata(Code::InvalidArgument, consensus_error.to_string(), metadata); + dash_sdk::Error::from(DapiClientError::Transport(TransportError::Grpc(status))) + } + + /// What the SDK makes of a transition Platform refused at block + /// execution: the wait-for-result error, with or without the decoded + /// consensus error it was sent with. + fn failed_in_block(code: u32, cause: Option) -> dash_sdk::Error { + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code, + message: "refused".to_string(), + cause, + }) + } + + #[test] + fn should_carry_the_code_of_a_consensus_error_platform_refused_the_transition_with() { + let refused = refused_by_platform(&property_constraint_violated()); + let expected_message = refused.to_string(); + + let error = convert(FFIError::SDKError(refused)); + + assert_eq!(error.consensus_code, 10422); + assert_eq!( + error.consensus_kind, + DashSDKConsensusErrorKind::ConsensusErrorKindBasic + ); + // The code and message hosts already read are the ones they read before. + assert_eq!(error.code, DashSDKErrorCode::ProtocolError); + assert_eq!(error.message, expected_message); + } + + #[test] + fn should_carry_the_code_of_a_state_error_platform_refused_the_transition_with() { + let error = convert(FFIError::SDKError(refused_by_platform( + &contract_user_banned(), + ))); + + assert_eq!(error.consensus_code, 41107); + assert_eq!( + error.consensus_kind, + DashSDKConsensusErrorKind::ConsensusErrorKindState + ); + } + + #[test] + fn should_carry_the_code_through_the_message_of_the_call_that_failed() { + let context = "Failed to mint token and wait"; + let stringified = FFIError::InternalError(format!( + "{}: {}", + context, + refused_by_platform(&contract_user_banned()) + )); + let before = convert(stringified); + + let error = convert(FFIError::sdk_call_failed( + context, + refused_by_platform(&contract_user_banned()), + )); + + assert_eq!(error.consensus_code, 41107); + assert_eq!( + error.consensus_kind, + DashSDKConsensusErrorKind::ConsensusErrorKindState + ); + // Same code and text as the stringified error it replaces, which + // carried no consensus code. + assert_eq!(error.code, before.code); + assert_eq!(error.message, before.message); + assert_eq!(before.consensus_code, 0); + } + + #[test] + fn should_carry_the_code_of_a_transition_platform_refused_in_a_block() { + let with_cause = convert(FFIError::SDKError(failed_in_block( + 41107, + Some(contract_user_banned()), + ))); + let without_cause = convert(FFIError::SDKError(failed_in_block(40132, None))); + + assert_eq!(with_cause.consensus_code, 41107); + assert_eq!( + with_cause.consensus_kind, + DashSDKConsensusErrorKind::ConsensusErrorKindState + ); + assert_eq!(without_cause.consensus_code, 40132); + assert_eq!( + without_cause.consensus_kind, + DashSDKConsensusErrorKind::ConsensusErrorKindState + ); + } + + #[test] + fn should_name_the_family_of_a_code_from_its_range() { + use DashSDKConsensusErrorKind as Kind; + + let families = [ + (10_000, Kind::ConsensusErrorKindBasic), + (10_422, Kind::ConsensusErrorKindBasic), + (19_999, Kind::ConsensusErrorKindBasic), + (20_000, Kind::ConsensusErrorKindSignature), + (20_002, Kind::ConsensusErrorKindSignature), + (29_999, Kind::ConsensusErrorKindSignature), + (30_000, Kind::ConsensusErrorKindFee), + (39_999, Kind::ConsensusErrorKindFee), + (40_000, Kind::ConsensusErrorKindState), + (41_107, Kind::ConsensusErrorKindState), + (49_999, Kind::ConsensusErrorKindState), + ]; + for (code, kind) in families { + let error = convert(FFIError::SDKError(failed_in_block(code, None))); + assert_eq!(error.consensus_code, code); + assert_eq!(error.consensus_kind, kind, "code {code}"); + } + + // A number outside the four families is no consensus code at all. + for code in [0, 1, 16, 9_999, 50_000] { + let error = convert(FFIError::SDKError(failed_in_block(code, None))); + assert_eq!(error.consensus_code, 0, "code {code}"); + assert_eq!( + error.consensus_kind, + Kind::ConsensusErrorKindNone, + "code {code}" + ); + } + } + + #[test] + fn should_carry_the_code_of_the_last_refusal_once_retries_ran_out() { + let exhausted = dash_sdk::Error::NoAvailableAddressesToRetry(Box::new( + refused_by_platform(&property_constraint_violated()), + )); + + let error = convert(FFIError::SDKError(exhausted)); + + assert_eq!(error.consensus_code, 10422); + assert_eq!( + error.consensus_kind, + DashSDKConsensusErrorKind::ConsensusErrorKindBasic + ); + } + + #[test] + fn should_report_no_consensus_code_for_a_failure_that_is_not_a_consensus_refusal() { + let not_refusals = [ + FFIError::SDKError(dash_sdk::Error::Generic("boom".to_string())), + // A wait-for-result failure with a gRPC status code (13, Internal). + FFIError::SDKError(failed_in_block(13, None)), + // The placeholder consensus error names no rule. + FFIError::SDKError(dash_sdk::Error::from(ConsensusError::DefaultError)), + FFIError::InternalError("boom".to_string()), + FFIError::NullPointer, + ]; + + for not_refusal in not_refusals { + let error = convert(not_refusal); + assert_eq!(error.consensus_code, 0, "{}", error.message); + assert_eq!( + error.consensus_kind, + DashSDKConsensusErrorKind::ConsensusErrorKindNone, + "{}", + error.message + ); + } + } + } } diff --git a/packages/rs-sdk-ffi/src/identity/put.rs b/packages/rs-sdk-ffi/src/identity/put.rs index 17b16c32229..e5c4afd4afd 100644 --- a/packages/rs-sdk-ffi/src/identity/put.rs +++ b/packages/rs-sdk-ffi/src/identity/put.rs @@ -82,9 +82,7 @@ pub unsafe extern "C" fn dash_sdk_identity_put_to_platform_with_instant_lock( settings, ) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to put identity to platform: {}", e)) - })?; + .map_err(|e| FFIError::sdk_call_failed("Failed to put identity to platform", e))?; // Serialize the state transition with bincode let config = bincode::config::standard(); @@ -173,10 +171,7 @@ pub unsafe extern "C" fn dash_sdk_identity_put_to_platform_with_instant_lock_and ) .await .map_err(|e| { - FFIError::InternalError(format!( - "Failed to put identity to platform and wait: {}", - e - )) + FFIError::sdk_call_failed("Failed to put identity to platform and wait", e) })?; Ok(confirmed_identity) @@ -254,9 +249,7 @@ pub unsafe extern "C" fn dash_sdk_identity_put_to_platform_with_chain_lock( settings, ) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to put identity to platform: {}", e)) - })?; + .map_err(|e| FFIError::sdk_call_failed("Failed to put identity to platform", e))?; // Serialize the state transition with bincode let config = bincode::config::standard(); @@ -334,10 +327,7 @@ pub unsafe extern "C" fn dash_sdk_identity_put_to_platform_with_chain_lock_and_w ) .await .map_err(|e| { - FFIError::InternalError(format!( - "Failed to put identity to platform and wait: {}", - e - )) + FFIError::sdk_call_failed("Failed to put identity to platform and wait", e) })?; Ok(confirmed_identity) diff --git a/packages/rs-sdk-ffi/src/identity/transfer.rs b/packages/rs-sdk-ffi/src/identity/transfer.rs index 59a1e0d192a..f9fcd047e90 100644 --- a/packages/rs-sdk-ffi/src/identity/transfer.rs +++ b/packages/rs-sdk-ffi/src/identity/transfer.rs @@ -247,7 +247,7 @@ pub unsafe extern "C" fn dash_sdk_identity_transfer_credits( let (sender_balance, receiver_balance) = transfer_result .map_err(|e| { eprintln!("❌ dash_sdk_identity_transfer_credits: transfer_credits failed: {}", e); - FFIError::InternalError(format!("Failed to transfer credits: {}", e)) + FFIError::sdk_call_failed("Failed to transfer credits", e) })?; eprintln!("🔵 dash_sdk_identity_transfer_credits: Transfer successful!"); diff --git a/packages/rs-sdk-ffi/src/identity/withdraw.rs b/packages/rs-sdk-ffi/src/identity/withdraw.rs index 40efa4c20a4..99c84f29b83 100644 --- a/packages/rs-sdk-ffi/src/identity/withdraw.rs +++ b/packages/rs-sdk-ffi/src/identity/withdraw.rs @@ -223,7 +223,7 @@ pub unsafe extern "C" fn dash_sdk_identity_withdraw( .await .map_err(|e| { error!(error = %e, "dash_sdk_identity_withdraw: withdraw failed"); - FFIError::InternalError(format!("Failed to withdraw credits: {}", e)) + FFIError::sdk_call_failed("Failed to withdraw credits", e) })?; info!(new_balance, "dash_sdk_identity_withdraw: withdrawal successful"); diff --git a/packages/rs-sdk-ffi/src/token/burn.rs b/packages/rs-sdk-ffi/src/token/burn.rs index bcba8225203..0598ea9ffed 100644 --- a/packages/rs-sdk-ffi/src/token/burn.rs +++ b/packages/rs-sdk-ffi/src/token/burn.rs @@ -167,7 +167,7 @@ pub unsafe extern "C" fn dash_sdk_token_burn( .token_burn(builder, identity_public_key, signer) .await .map_err(|e| { - FFIError::InternalError(format!("Failed to burn token and wait: {}", e)) + FFIError::sdk_call_failed("Failed to burn token and wait", e) })?; Ok(result) diff --git a/packages/rs-sdk-ffi/src/token/claim.rs b/packages/rs-sdk-ffi/src/token/claim.rs index 9f0717f61b8..1c1cfe1eb41 100644 --- a/packages/rs-sdk-ffi/src/token/claim.rs +++ b/packages/rs-sdk-ffi/src/token/claim.rs @@ -169,7 +169,7 @@ pub unsafe extern "C" fn dash_sdk_token_claim( .token_claim(builder, identity_public_key, signer) .await .map_err(|e| { - FFIError::InternalError(format!("Failed to claim token and wait: {}", e)) + FFIError::sdk_call_failed("Failed to claim token and wait", e) })?; Ok(result) diff --git a/packages/rs-sdk-ffi/src/token/config_update.rs b/packages/rs-sdk-ffi/src/token/config_update.rs index 5a8c6885d23..be3f662332b 100644 --- a/packages/rs-sdk-ffi/src/token/config_update.rs +++ b/packages/rs-sdk-ffi/src/token/config_update.rs @@ -232,7 +232,7 @@ pub unsafe extern "C" fn dash_sdk_token_update_contract_token_configuration( .token_update_contract_token_configuration(builder, identity_public_key, signer) .await .map_err(|e| { - FFIError::InternalError(format!("Failed to update token config and wait: {}", e)) + FFIError::sdk_call_failed("Failed to update token config and wait", e) })?; Ok(result) diff --git a/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs b/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs index c85d0cb8efd..b05621ea221 100644 --- a/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs +++ b/packages/rs-sdk-ffi/src/token/destroy_frozen_funds.rs @@ -179,7 +179,7 @@ pub unsafe extern "C" fn dash_sdk_token_destroy_frozen_funds( .token_destroy_frozen_funds(builder, identity_public_key, signer) .await .map_err(|e| { - FFIError::InternalError(format!("Failed to destroy frozen funds and wait: {}", e)) + FFIError::sdk_call_failed("Failed to destroy frozen funds and wait", e) })?; Ok(result) diff --git a/packages/rs-sdk-ffi/src/token/emergency_action.rs b/packages/rs-sdk-ffi/src/token/emergency_action.rs index 3f75fa7942a..39280ce549c 100644 --- a/packages/rs-sdk-ffi/src/token/emergency_action.rs +++ b/packages/rs-sdk-ffi/src/token/emergency_action.rs @@ -180,7 +180,7 @@ pub unsafe extern "C" fn dash_sdk_token_emergency_action( .token_emergency_action(builder, identity_public_key, signer) .await .map_err(|e| { - FFIError::InternalError(format!("Failed to perform emergency action and wait: {}", e)) + FFIError::sdk_call_failed("Failed to perform emergency action and wait", e) })?; Ok(result) diff --git a/packages/rs-sdk-ffi/src/token/freeze.rs b/packages/rs-sdk-ffi/src/token/freeze.rs index d91939717d8..f315de0c42d 100644 --- a/packages/rs-sdk-ffi/src/token/freeze.rs +++ b/packages/rs-sdk-ffi/src/token/freeze.rs @@ -182,7 +182,7 @@ pub unsafe extern "C" fn dash_sdk_token_freeze( .token_freeze(builder, identity_public_key, signer) .await .map_err(|e| { - FFIError::InternalError(format!("Failed to freeze token and wait: {}", e)) + FFIError::sdk_call_failed("Failed to freeze token and wait", e) })?; Ok(result) diff --git a/packages/rs-sdk-ffi/src/token/mint.rs b/packages/rs-sdk-ffi/src/token/mint.rs index e78585c5835..d36f537c3b9 100644 --- a/packages/rs-sdk-ffi/src/token/mint.rs +++ b/packages/rs-sdk-ffi/src/token/mint.rs @@ -274,7 +274,7 @@ pub unsafe extern "C" fn dash_sdk_token_mint( .await .map_err(|e| { tracing::error!(error = %e, "FFI TOKEN MINT: failed to mint token"); - FFIError::InternalError(format!("Failed to mint token and wait: {}", e)) + FFIError::sdk_call_failed("Failed to mint token and wait", e) })?; tracing::info!("FFI TOKEN MINT: token mint succeeded"); Ok(result) diff --git a/packages/rs-sdk-ffi/src/token/purchase.rs b/packages/rs-sdk-ffi/src/token/purchase.rs index 69631ab48c9..94213605db2 100644 --- a/packages/rs-sdk-ffi/src/token/purchase.rs +++ b/packages/rs-sdk-ffi/src/token/purchase.rs @@ -171,7 +171,7 @@ pub unsafe extern "C" fn dash_sdk_token_purchase( .token_purchase(builder, identity_public_key, signer) .await .map_err(|e| { - FFIError::InternalError(format!("Failed to purchase token and wait: {}", e)) + FFIError::sdk_call_failed("Failed to purchase token and wait", e) })?; Ok(result) diff --git a/packages/rs-sdk-ffi/src/token/set_price.rs b/packages/rs-sdk-ffi/src/token/set_price.rs index 07791eabde7..b4b5e469c04 100644 --- a/packages/rs-sdk-ffi/src/token/set_price.rs +++ b/packages/rs-sdk-ffi/src/token/set_price.rs @@ -219,7 +219,7 @@ pub unsafe extern "C" fn dash_sdk_token_set_price( .token_set_price_for_direct_purchase(builder, identity_public_key, signer) .await .map_err(|e| { - FFIError::InternalError(format!("Failed to set token price and wait: {}", e)) + FFIError::sdk_call_failed("Failed to set token price and wait", e) })?; Ok(result) diff --git a/packages/rs-sdk-ffi/src/token/transfer.rs b/packages/rs-sdk-ffi/src/token/transfer.rs index 1425ecd28c6..86cc0b8790d 100644 --- a/packages/rs-sdk-ffi/src/token/transfer.rs +++ b/packages/rs-sdk-ffi/src/token/transfer.rs @@ -181,7 +181,7 @@ pub unsafe extern "C" fn dash_sdk_token_transfer( .token_transfer(builder, identity_public_key, signer) .await .map_err(|e| { - FFIError::InternalError(format!("Failed to transfer token and wait: {}", e)) + FFIError::sdk_call_failed("Failed to transfer token and wait", e) })?; Ok(result) diff --git a/packages/rs-sdk-ffi/src/token/unfreeze.rs b/packages/rs-sdk-ffi/src/token/unfreeze.rs index e2f47f143a7..d223489c43c 100644 --- a/packages/rs-sdk-ffi/src/token/unfreeze.rs +++ b/packages/rs-sdk-ffi/src/token/unfreeze.rs @@ -179,7 +179,7 @@ pub unsafe extern "C" fn dash_sdk_token_unfreeze( .token_unfreeze_identity(builder, identity_public_key, signer) .await .map_err(|e| { - FFIError::InternalError(format!("Failed to unfreeze token and wait: {}", e)) + FFIError::sdk_call_failed("Failed to unfreeze token and wait", e) })?; Ok(result) diff --git a/packages/rs-unified-sdk-jni/src/queries.rs b/packages/rs-unified-sdk-jni/src/queries.rs index e56c8b934a3..d659d755de1 100644 --- a/packages/rs-unified-sdk-jni/src/queries.rs +++ b/packages/rs-unified-sdk-jni/src/queries.rs @@ -7,8 +7,8 @@ //! Kotlin counterpart: `org.dashfoundation.dashsdk.ffi.QueriesNative`. use crate::results::{ - unwrap_address_info, unwrap_address_info_map, unwrap_handle, unwrap_identity_balance_map, - unwrap_string, + throw_sdk_error, unwrap_address_info, unwrap_address_info_map, unwrap_handle, + unwrap_identity_balance_map, unwrap_string, }; use crate::support::{guard, throw_sdk_exception}; use jni::objects::{JByteArray, JClass, JObject, JObjectArray, JString}; @@ -1513,15 +1513,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_QueriesNative_dataCon // Error path: throw, free inner buffers, bail with null. if !result.error.is_null() { // SAFETY: non-null error is a valid CString-backed DashSDKError. - let err = unsafe { &*result.error }; - let message = if err.message.is_null() { - String::from("Unknown SDK error") - } else { - unsafe { CStr::from_ptr(err.message) } - .to_string_lossy() - .into_owned() - }; - throw_sdk_exception(env, err.code as i32, &message); + unsafe { throw_sdk_error(env, &*result.error) }; unsafe { dash_sdk_data_contract_fetch_result_free(&mut result) }; return ptr::null_mut(); } diff --git a/packages/rs-unified-sdk-jni/src/results.rs b/packages/rs-unified-sdk-jni/src/results.rs index acf6d2cb66e..c0de74fe89d 100644 --- a/packages/rs-unified-sdk-jni/src/results.rs +++ b/packages/rs-unified-sdk-jni/src/results.rs @@ -7,14 +7,16 @@ //! picks them up. #![allow(dead_code)] -use crate::support::throw_sdk_exception; +use crate::support::{throw_sdk_consensus_exception, throw_sdk_exception}; use jni::objects::{JByteArray, JString}; +use jni::sys::jint; use jni::JNIEnv; use rs_sdk_ffi::{ dash_sdk_address_info_free, dash_sdk_address_info_map_free, dash_sdk_binary_data_free, dash_sdk_error_free, dash_sdk_identity_balance_map_free, dash_sdk_signature_free, dash_sdk_string_free, DashSDKAddressInfo, DashSDKAddressInfoMap, DashSDKBinaryData, - DashSDKIdentityBalanceMap, DashSDKResult, DashSDKResultDataType, DashSDKSignature, + DashSDKError, DashSDKIdentityBalanceMap, DashSDKResult, DashSDKResultDataType, + DashSDKSignature, }; use std::ffi::{c_char, CStr}; use std::fmt::Write as _; @@ -29,15 +31,37 @@ pub unsafe fn take_error(env: &mut JNIEnv, r: &DashSDKResult) -> bool { if r.error.is_null() { return false; } - let err = &*r.error; + throw_sdk_error(env, &*r.error); + dash_sdk_error_free(r.error); + true +} + +/// Throw the `DashSDKException` for `err`, leaving it for the caller to free. +/// An error that carries a consensus rejection (`consensus_code != 0`) hands +/// its code and kind to the exception as well, so Kotlin can branch on them +/// instead of on the message (the rs-sdk-ffi counterpart of +/// `support::throw_pwffi_result`). +/// +/// # Safety +/// `err.message`, when non-null, must be a valid C string produced by the +/// FFI layer and not yet freed. +pub unsafe fn throw_sdk_error(env: &mut JNIEnv, err: &DashSDKError) { let message = if err.message.is_null() { String::from("Unknown SDK error") } else { CStr::from_ptr(err.message).to_string_lossy().into_owned() }; - throw_sdk_exception(env, err.code as i32, &message); - dash_sdk_error_free(r.error); - true + if err.consensus_code == 0 { + throw_sdk_exception(env, err.code as i32, &message); + } else { + throw_sdk_consensus_exception( + env, + err.code as i32, + &message, + err.consensus_code, + err.consensus_kind as jint, + ); + } } /// Unwrap a result whose success payload is an opaque handle pointer diff --git a/packages/rs-unified-sdk-jni/src/support.rs b/packages/rs-unified-sdk-jni/src/support.rs index 4bdca664c06..ac58fa512f5 100644 --- a/packages/rs-unified-sdk-jni/src/support.rs +++ b/packages/rs-unified-sdk-jni/src/support.rs @@ -8,10 +8,58 @@ use platform_wallet_ffi::error::{ platform_wallet_ffi_result_free, PlatformWalletFFIConsensusErrorKind, PlatformWalletFFIResult, PlatformWalletFFIResultCode, }; +use rs_sdk_ffi::DashSDKConsensusErrorKind; use std::ffi::CStr; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::sync::OnceLock; +/// The platform-wallet kind that shares each rs-sdk-ffi consensus kind's +/// meaning. The match is exhaustive, so a family added to one FFI alone +/// fails the build here. +const fn wallet_consensus_kind( + kind: DashSDKConsensusErrorKind, +) -> PlatformWalletFFIConsensusErrorKind { + match kind { + DashSDKConsensusErrorKind::ConsensusErrorKindNone => { + PlatformWalletFFIConsensusErrorKind::None + } + DashSDKConsensusErrorKind::ConsensusErrorKindBasic => { + PlatformWalletFFIConsensusErrorKind::Basic + } + DashSDKConsensusErrorKind::ConsensusErrorKindSignature => { + PlatformWalletFFIConsensusErrorKind::Signature + } + DashSDKConsensusErrorKind::ConsensusErrorKindFee => { + PlatformWalletFFIConsensusErrorKind::Fee + } + DashSDKConsensusErrorKind::ConsensusErrorKindState => { + PlatformWalletFFIConsensusErrorKind::State + } + } +} + +const fn crosses_as_wallet_kind(kind: DashSDKConsensusErrorKind) -> bool { + kind as i32 == wallet_consensus_kind(kind) as i32 +} + +/// Compile-time drift guard for the consensus kind discriminant. +/// +/// Both FFIs hand a consensus rejection's kind to +/// [`throw_sdk_consensus_exception`] as its bare discriminant, and Kotlin's +/// `ConsensusErrorKind.fromNative` decodes it with one table. So every +/// `DashSDKConsensusErrorKind` must cross as the same number as the +/// `PlatformWalletFFIConsensusErrorKind` of the same family; a drift is a +/// build failure rather than a rejection Kotlin files under the wrong family. +const _: () = assert!( + crosses_as_wallet_kind(DashSDKConsensusErrorKind::ConsensusErrorKindNone) + && crosses_as_wallet_kind(DashSDKConsensusErrorKind::ConsensusErrorKindBasic) + && crosses_as_wallet_kind(DashSDKConsensusErrorKind::ConsensusErrorKindSignature) + && crosses_as_wallet_kind(DashSDKConsensusErrorKind::ConsensusErrorKindFee) + && crosses_as_wallet_kind(DashSDKConsensusErrorKind::ConsensusErrorKindState), + "rs-sdk-ffi's DashSDKConsensusErrorKind drifted from platform-wallet-ffi's \ + PlatformWalletFFIConsensusErrorKind; Kotlin decodes both with one table" +); + /// Offset added to every `PlatformWalletFFIResultCode` before it is thrown /// as a `DashSDKException` code — see [`take_pwffi_error`]. /// @@ -90,7 +138,7 @@ pub fn throw_pwffi_result(env: &mut JNIEnv, result: &PlatformWalletFFIResult) { code, &message, result.consensus_code, - result.consensus_kind, + result.consensus_kind as jint, ); } } @@ -110,15 +158,17 @@ pub fn throw_sdk_exception(env: &mut JNIEnv, code: i32, message: &str) { } /// Throw `DashSDKException(code, message, consensusCode, consensusKind)` for -/// a failure that is a consensus rejection. `consensus_kind` crosses as its -/// discriminant, which Kotlin's `ConsensusErrorKind.fromNative` decodes. Same -/// `RuntimeException` fallback as [`throw_sdk_exception`]. +/// a failure that is a consensus rejection. `consensus_kind` is the +/// discriminant of either FFI's kind (`PlatformWalletFFIConsensusErrorKind` +/// or rs-sdk-ffi's `DashSDKConsensusErrorKind`, held equal by the drift guard +/// at the top of this module), which Kotlin's `ConsensusErrorKind.fromNative` +/// decodes. Same `RuntimeException` fallback as [`throw_sdk_exception`]. pub fn throw_sdk_consensus_exception( env: &mut JNIEnv, code: i32, message: &str, consensus_code: u32, - consensus_kind: PlatformWalletFFIConsensusErrorKind, + consensus_kind: jint, ) { // Consensus codes top out in the 40000s, far inside a `jint`; one that // somehow is not goes out as `jint::MAX` rather than wrapping negative. @@ -128,7 +178,7 @@ pub fn throw_sdk_consensus_exception( code, message, "(ILjava/lang/String;II)V", - &[consensus_code, consensus_kind as jint], + &[consensus_code, consensus_kind], ); } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift index 4a7ca694e5f..17606d60804 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift @@ -1606,8 +1606,9 @@ extension SDK { let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" + let failure = SDKError.stateTransitionFailure(errorMessage, ffiError: error.pointee) dash_sdk_error_free(error) - throw SDKError.internalError(errorMessage) + throw failure } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift index b44ce06b43a..5fceb0c5d21 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift @@ -372,8 +372,10 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" + let failure = SDKError.stateTransitionFailure( + errorString, ffiError: result.error?.pointee) dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume(throwing: failure) } } } @@ -430,8 +432,10 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" + let failure = SDKError.stateTransitionFailure( + errorString, ffiError: result.error?.pointee) dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume(throwing: failure) } } } @@ -604,8 +608,9 @@ extension SDK { String(cString: error.pointee.message) : "Failed to put document to platform" print("❌ [DOCUMENT CREATE] Platform submission failed: \(errorString)") print("⏱️ [DOCUMENT CREATE] Total operation time: \(Date().timeIntervalSince(startTime)) seconds") + let failure = SDKError.stateTransitionFailure(errorString, ffiError: error.pointee) dash_sdk_error_free(error) - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume(throwing: failure) } else if putResult.data_type == DashSDKFFI.String, let jsonData = putResult.data { // Parse the returned JSON @@ -797,8 +802,9 @@ extension SDK { if let error = replaceResult.error { print("❌ [DOCUMENT REPLACE] Replace failed after \(replaceTime) seconds") let errorString = String(cString: error.pointee.message) + let failure = SDKError.stateTransitionFailure(errorString, ffiError: error.pointee) dash_sdk_error_free(error) - continuation.resume(throwing: SDKError.internalError(errorString)) + continuation.resume(throwing: failure) } else if replaceResult.data_type == DashSDKFFI.ResultDocumentHandle, let resultHandle = replaceResult.data { // Document was successfully replaced @@ -889,8 +895,10 @@ extension SDK { if let error = result.error { let errorMessage = String(cString: error.pointee.message) + let failure = SDKError.stateTransitionFailure( + errorMessage, ffiError: error.pointee, otherwise: SDKError.protocolError) dash_sdk_error_free(error) - throw SDKError.protocolError(errorMessage) + throw failure } let totalTime = Date().timeIntervalSince(startTime) @@ -1036,7 +1044,8 @@ extension SDK { let errorMsg = String(cString: error.message) dash_sdk_error_free(transitionResult.error) print("❌ [DOCUMENT TRANSFER] Failed to create transition: \(errorMsg)") - continuation.resume(throwing: SDKError.protocolError(errorMsg)) + continuation.resume(throwing: SDKError.stateTransitionFailure( + errorMsg, ffiError: error, otherwise: SDKError.protocolError)) return } @@ -1080,7 +1089,8 @@ extension SDK { } print("❌ [DOCUMENT TRANSFER] Broadcast failed: \(errorMsg)") - continuation.resume(throwing: SDKError.protocolError(errorMsg)) + continuation.resume(throwing: SDKError.stateTransitionFailure( + errorMsg, ffiError: error, otherwise: SDKError.protocolError)) return } @@ -1223,7 +1233,8 @@ extension SDK { let errorMsg = String(cString: error.message) dash_sdk_error_free(updateResult.error) print("❌ [DOCUMENT UPDATE PRICE] Failed: \(errorMsg)") - continuation.resume(throwing: SDKError.protocolError(errorMsg)) + continuation.resume(throwing: SDKError.stateTransitionFailure( + errorMsg, ffiError: error, otherwise: SDKError.protocolError)) return } @@ -1360,13 +1371,15 @@ extension SDK { if let error = result.error { let errorMessage = error.pointee.message != nil ? String(cString: error.pointee.message!) : "Unknown error" + let failure = SDKError.stateTransitionFailure( + "Document purchase failed: \(errorMessage)", ffiError: error.pointee) dash_sdk_error_free(error) print("❌ [DOCUMENT PURCHASE] Failed: \(errorMessage)") let totalTime = Date().timeIntervalSince(startTime) print("❌ [DOCUMENT PURCHASE] Total time: \(totalTime) seconds") - continuation.resume(throwing: SDKError.internalError("Document purchase failed: \(errorMessage)")) + continuation.resume(throwing: failure) return } @@ -1608,8 +1621,10 @@ extension SDK { String(cString: result.error!.pointee.message) : "Unknown error" let errorCode = result.error?.pointee.code.rawValue ?? 0 print("❌ TOKEN MINT: Failed with error code \(errorCode): \(errorString)") + let failure = SDKError.stateTransitionFailure( + "Token mint failed: \(errorString)", ffiError: result.error?.pointee) dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token mint failed: \(errorString)")) + continuation.resume(throwing: failure) } } } @@ -1740,8 +1755,10 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" + let failure = SDKError.stateTransitionFailure( + "Token freeze failed: \(errorString)", ffiError: result.error?.pointee) dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token freeze failed: \(errorString)")) + continuation.resume(throwing: failure) } } } @@ -1872,8 +1889,10 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" + let failure = SDKError.stateTransitionFailure( + "Token unfreeze failed: \(errorString)", ffiError: result.error?.pointee) dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token unfreeze failed: \(errorString)")) + continuation.resume(throwing: failure) } } } @@ -1992,8 +2011,10 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" + let failure = SDKError.stateTransitionFailure( + "Token burn failed: \(errorString)", ffiError: result.error?.pointee) dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token burn failed: \(errorString)")) + continuation.resume(throwing: failure) } } } @@ -2124,8 +2145,10 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" + let failure = SDKError.stateTransitionFailure( + "Token destroy frozen funds failed: \(errorString)", ffiError: result.error?.pointee) dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token destroy frozen funds failed: \(errorString)")) + continuation.resume(throwing: failure) } } } @@ -2251,8 +2274,10 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" + let failure = SDKError.stateTransitionFailure( + "Token claim failed: \(errorString)", ffiError: result.error?.pointee) dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token claim failed: \(errorString)")) + continuation.resume(throwing: failure) } } } @@ -2380,8 +2405,10 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" + let failure = SDKError.stateTransitionFailure( + "Token transfer failed: \(errorString)", ffiError: result.error?.pointee) dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token transfer failed: \(errorString)")) + continuation.resume(throwing: failure) } } } @@ -2520,8 +2547,10 @@ extension SDK { } else { let errorString = result.error?.pointee.message != nil ? String(cString: result.error!.pointee.message) : "Unknown error" + let failure = SDKError.stateTransitionFailure( + "Token set price failed: \(errorString)", ffiError: result.error?.pointee) dash_sdk_error_free(result.error) - continuation.resume(throwing: SDKError.internalError("Token set price failed: \(errorString)")) + continuation.resume(throwing: failure) } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index a68427794de..7fcfa941f44 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -403,7 +403,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// numeric `code` names the exact rule that refused it (40722 is a second /// once-per-identity token claim), and its `kind` says which family of rule /// that was. Rust reads both off the consensus error and puts them on the FFI -/// result, so a host branches on the code rather than recognising the +/// result (`PlatformWalletFFIResult` from the wallet FFI, `DashSDKError` from +/// rs-sdk-ffi), so a host branches on the code rather than recognising the /// rejection in its rendered text. public struct PlatformConsensusError: Equatable, Sendable { /// The family a consensus rejection belongs to. @@ -411,7 +412,8 @@ public struct PlatformConsensusError: Equatable, Sendable { /// rs-dpp groups its codes by family (basic 1xxxx, signature 2xxxx, fee /// 3xxxx, state 4xxxx) and hands the grouping over as a value of its own, /// so this side never derives it from the digits of `code`. Mirror of - /// `PlatformWalletFFIConsensusErrorKind`. + /// `PlatformWalletFFIConsensusErrorKind` and rs-sdk-ffi's + /// `DashSDKConsensusErrorKind`, which name the same families. public enum Kind: Equatable, Sendable { case basic case signature @@ -430,6 +432,19 @@ public struct PlatformConsensusError: Equatable, Sendable { default: return nil } } + + /// The same decoding for rs-sdk-ffi's kind: `nil` for + /// `ConsensusErrorKindNone` and for any family this mirror does not + /// know yet. + init?(ffi: DashSDKConsensusErrorKind) { + switch ffi { + case ConsensusErrorKindBasic: self = .basic + case ConsensusErrorKindSignature: self = .signature + case ConsensusErrorKindFee: self = .fee + case ConsensusErrorKindState: self = .state + default: return nil + } + } } /// The rs-dpp consensus error code @@ -452,6 +467,15 @@ public struct PlatformConsensusError: Equatable, Sendable { } self.init(code: ffi.consensus_code, kind: kind) } + + /// The rejection an rs-sdk-ffi error carries, or `nil` when it carries + /// none. + init?(ffi: DashSDKError) { + guard ffi.consensus_code != 0, let kind = Kind(ffi: ffi.consensus_kind) else { + return nil + } + self.init(code: ffi.consensus_code, kind: kind) + } } // MARK: - Class wrapper diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index c54ff66e1a5..ed62fecd35e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -611,11 +611,31 @@ public enum SDKError: Error { case timeout(String) case notImplemented(String) case internalError(String) + /// Platform refused the state transition. Carries the rs-dpp consensus + /// code and family, so a host can branch on the exact rule that refused it + /// (`error.consensusError?.code == 10422` is a violated propertyConstraints + /// rule), plus the rendered message the coarse case would have carried. + case consensusRejection(PlatformConsensusError, String) case unknown(String) + /// Platform's verdict when this error is a consensus rejection, so a caller + /// can write `error.consensusError?.code == 10422` without pattern + /// matching. `nil` for every other case. + public var consensusError: PlatformConsensusError? { + guard case .consensusRejection(let consensus, _) = self else { return nil } + return consensus + } + public static func fromDashSDKError(_ error: DashSDKError) -> SDKError { let message = error.message != nil ? String(cString: error.message!) : "Unknown error" + // Platform's verdict wins over the coarse code, which rs-sdk-ffi picks + // from the rendered text: the same refusal can arrive as ProtocolError or + // InternalError depending on its wording. + if let consensus = PlatformConsensusError(ffi: error) { + return .consensusRejection(consensus, message) + } + switch error.code { case DashSDKErrorCode(rawValue: 1): // Invalid parameter return .invalidParameter(message) @@ -641,6 +661,22 @@ public enum SDKError: Error { return .unknown(message) } } + + /// The error to throw under `message` when an FFI call that signs, + /// broadcasts or waits for a state transition fails: `.consensusRejection` + /// when `ffiError` carries Platform's verdict, otherwise + /// `fallback(message)`, the case the call site threw before the verdict + /// crossed the FFI. Read `ffiError` before freeing it. + static func stateTransitionFailure( + _ message: String, + ffiError: DashSDKError?, + otherwise fallback: (String) -> SDKError = SDKError.internalError + ) -> SDKError { + if let ffiError, let consensus = PlatformConsensusError(ffi: ffiError) { + return .consensusRejection(consensus, message) + } + return fallback(message) + } } extension SDKError: LocalizedError { @@ -666,6 +702,8 @@ extension SDKError: LocalizedError { return "Feature Not Implemented: \(message)" case .internalError(let message): return "Internal Error: \(message)" + case .consensusRejection(_, let message): + return "Rejected by Platform: \(message)" case .unknown(let message): return "Unknown Error: \(message)" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift index 316c76b62be..ebcac4b725c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift @@ -621,6 +621,8 @@ struct DiagnosticsView: View { return "Not Implemented: \(msg)" case .internalError(let msg): return "Internal Error: \(msg)" + case .consensusRejection(let consensus, let msg): + return "Rejected by Platform (code \(consensus.code)): \(msg)" case .unknown(let msg): return "Unknown Error: \(msg)" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift index ec57071ba6e..e4c190df25c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift @@ -201,6 +201,8 @@ struct QueryDetailView: View { self.error = "Not Implemented: \(message)" case .internalError(let message): self.error = "Internal Error: \(message)" + case .consensusRejection(let consensus, let message): + self.error = "Rejected by Platform (code \(consensus.code)): \(message)" case .unknown(let message): self.error = "Unknown Error: \(message)" } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index d9cba56b996..4f0290f3ede 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -285,6 +285,117 @@ final class ErrorHandlingTests: XCTestCase { XCTAssertNil(invented.consensusError) } + /// rs-sdk-ffi's kind decodes to the same four families from its own + /// generated C constants, and its `None` value is absence rather than a + /// family. + func testShouldMapEveryDashSDKConsensusErrorKindFromFFI() { + let mappings: [(DashSDKConsensusErrorKind, PlatformConsensusError.Kind)] = [ + (ConsensusErrorKindBasic, .basic), + (ConsensusErrorKindSignature, .signature), + (ConsensusErrorKindFee, .fee), + (ConsensusErrorKindState, .state), + ] + for (ffi, expected) in mappings { + XCTAssertEqual(PlatformConsensusError.Kind(ffi: ffi), expected) + } + XCTAssertNil(PlatformConsensusError.Kind(ffi: ConsensusErrorKindNone)) + } + + /// An rs-sdk-ffi error that carries Platform's verdict becomes the typed + /// consensus case, whatever coarse code the message earned it. 10422 is a + /// violated propertyConstraints rule. + func testShouldSurfaceAConsensusRejectionFromAnSDKError() { + let rendered = "Protocol error: document violates propertyConstraints rule 0" + let message = strdup(rendered) + defer { free(message) } + + let error = SDKError.fromDashSDKError( + DashSDKError( + code: DashSDKErrorCode(rawValue: 5), // ProtocolError + message: message, + consensus_code: 10422, + consensus_kind: ConsensusErrorKindBasic + ) + ) + + guard case .consensusRejection(let consensus, let text) = error else { + return XCTFail("expected typed consensusRejection error, got \(error)") + } + XCTAssertEqual(consensus, PlatformConsensusError(code: 10422, kind: .basic)) + XCTAssertEqual(text, rendered) + XCTAssertEqual(error.errorDescription, "Rejected by Platform: \(rendered)") + // The accessor is what callers branch on, so it must agree. + XCTAssertEqual(error.consensusError, consensus) + } + + /// Without a verdict the coarse code still picks the case. + func testShouldKeepTheCoarseCaseOfAnSDKErrorWithoutAVerdict() { + let rendered = "Protocol error: unexpected response" + let message = strdup(rendered) + defer { free(message) } + + let error = SDKError.fromDashSDKError( + DashSDKError( + code: DashSDKErrorCode(rawValue: 5), // ProtocolError + message: message, + consensus_code: 0, + consensus_kind: ConsensusErrorKindNone + ) + ) + + guard case .protocolError(let text) = error else { + return XCTFail("an error with no verdict must keep its coarse case, got \(error)") + } + XCTAssertEqual(text, rendered) + XCTAssertNil(error.consensusError) + } + + /// A failed state transition call throws Platform's verdict when the FFI + /// error carries one, and the case the call site always threw when it + /// does not, with the call site's message either way. + func testShouldThrowTheVerdictOfAFailedStateTransitionOnlyWhenThereIsOne() { + let refusedByPlatform = DashSDKError( + code: DashSDKErrorCode(rawValue: 99), // InternalError + message: nil, + consensus_code: 41107, + consensus_kind: ConsensusErrorKindState + ) + let refused = SDKError.stateTransitionFailure( + "Token mint failed: user banned", ffiError: refusedByPlatform) + guard case .consensusRejection(let consensus, let text) = refused else { + return XCTFail("expected typed consensusRejection error, got \(refused)") + } + XCTAssertEqual(consensus, PlatformConsensusError(code: 41107, kind: .state)) + XCTAssertEqual(text, "Token mint failed: user banned") + + let noVerdict = DashSDKError( + code: DashSDKErrorCode(rawValue: 99), // InternalError + message: nil, + consensus_code: 0, + consensus_kind: ConsensusErrorKindNone + ) + let failed = SDKError.stateTransitionFailure( + "Token mint failed: timed out", ffiError: noVerdict) + guard case .internalError(let failedText) = failed else { + return XCTFail("a failure with no verdict must stay internalError, got \(failed)") + } + XCTAssertEqual(failedText, "Token mint failed: timed out") + XCTAssertNil(failed.consensusError) + + // A call site that threw protocolError keeps it, and a missing FFI + // error is no verdict either. + guard case .protocolError = SDKError.stateTransitionFailure( + "Broadcast failed", ffiError: noVerdict, otherwise: SDKError.protocolError + ) else { + return XCTFail("the call site's own case must survive without a verdict") + } + guard case .internalError = SDKError.stateTransitionFailure( + "Unknown error", ffiError: nil + ) else { + return XCTFail("a missing FFI error must not become a rejection") + } + } + func testPlatformWalletNotFoundFFIResultMapping() { // Code 98 (the blanket Option→result miss) stays typed inside the // wallet-error family — the mapping Kotlin now converges on From b3d59b8055d0b69b6bb6038fa43b6b81d85be648 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 18:13:26 +0700 Subject: [PATCH 079/113] fix(swift-sdk): documentTransfer handles a missing document and signs once (#5120) Co-authored-by: Claude Opus 5.5 --- .../queries/fetch_with_serialization.rs | 6 +- packages/rs-sdk-ffi/src/document/mod.rs | 2 +- packages/rs-sdk-ffi/src/document/util.rs | 38 ----------- .../FFI/PlatformQueryExtensions.swift | 4 +- .../FFI/StateTransitionExtensions.swift | 64 ++++++++----------- 5 files changed, 36 insertions(+), 78 deletions(-) diff --git a/packages/rs-sdk-ffi/src/data_contract/queries/fetch_with_serialization.rs b/packages/rs-sdk-ffi/src/data_contract/queries/fetch_with_serialization.rs index 9ca5467a88a..b9c51ae96bd 100644 --- a/packages/rs-sdk-ffi/src/data_contract/queries/fetch_with_serialization.rs +++ b/packages/rs-sdk-ffi/src/data_contract/queries/fetch_with_serialization.rs @@ -1,5 +1,7 @@ use crate::sdk::SDKWrapper; -use crate::{DashSDKError, DashSDKErrorCode, DataContractHandle, FFIError, SDKHandle}; +use crate::{ + dash_sdk_error_free, DashSDKError, DashSDKErrorCode, DataContractHandle, FFIError, SDKHandle, +}; use dash_sdk::dpp::data_contract::serialized_version::DataContractInSerializationFormat; use dash_sdk::dpp::data_contract::DataContractWithSerialization; use dash_sdk::dpp::platform_value::string_encoding::Encoding; @@ -222,7 +224,7 @@ pub unsafe extern "C" fn dash_sdk_data_contract_fetch_result_free( } if !result.error.is_null() { - let _ = Box::from_raw(result.error); + dash_sdk_error_free(result.error); result.error = std::ptr::null_mut(); } } diff --git a/packages/rs-sdk-ffi/src/document/mod.rs b/packages/rs-sdk-ffi/src/document/mod.rs index 95c168486e1..2a388bae42d 100644 --- a/packages/rs-sdk-ffi/src/document/mod.rs +++ b/packages/rs-sdk-ffi/src/document/mod.rs @@ -32,7 +32,7 @@ pub use replace::{ pub use transfer::{ dash_sdk_document_transfer_to_identity, dash_sdk_document_transfer_to_identity_and_wait, }; -pub use util::{dash_sdk_document_destroy, dash_sdk_document_handle_destroy}; +pub use util::dash_sdk_document_handle_destroy; // Re-export helper functions for use by submodules pub(crate) use helpers::{build_document_from_properties, parse_document_properties_json}; diff --git a/packages/rs-sdk-ffi/src/document/util.rs b/packages/rs-sdk-ffi/src/document/util.rs index 75d8c5ab5be..ad4d3e537d6 100644 --- a/packages/rs-sdk-ffi/src/document/util.rs +++ b/packages/rs-sdk-ffi/src/document/util.rs @@ -11,44 +11,6 @@ use std::collections::BTreeMap; use std::ffi::CStr; use std::os::raw::c_char; -/// Destroy a document -/// -/// # Safety -/// - `sdk_handle` and `document_handle` must be valid, non-null pointers. -/// - Returns a pointer to an error structure on failure; caller must free with `dash_sdk_error_free`. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_document_destroy( - sdk_handle: *mut SDKHandle, - document_handle: *mut DocumentHandle, -) -> *mut DashSDKError { - if sdk_handle.is_null() || document_handle.is_null() { - return Box::into_raw(Box::new(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - "Invalid parameters".to_string(), - ))); - } - - let wrapper = &mut *(sdk_handle as *mut SDKWrapper); - let _document = &*(document_handle as *const Document); - - let result: Result<(), FFIError> = wrapper.runtime.block_on(async { - // Use DocumentDeleteTransitionBuilder to delete the document - // We need to get the data contract and document type information - // This is a simplified implementation - in practice you might need more context - - // For now, return not implemented as we need more context about the data contract - Err(FFIError::InternalError( - "Document deletion requires data contract context - use specific delete function" - .to_string(), - )) - }); - - match result { - Ok(_) => std::ptr::null_mut(), - Err(e) => Box::into_raw(Box::new(e.into())), - } -} - /// Destroy a document handle /// /// # Safety diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift index 17606d60804..73b0b4409c5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift @@ -479,6 +479,8 @@ extension SDK { // Get the JSON string guard result.json_string != nil else { + var mutableResult = result + dash_sdk_data_contract_fetch_result_free(&mutableResult) throw SDKError.internalError("No JSON data returned from contract fetch") } @@ -691,7 +693,7 @@ extension SDK { defer { // Clean up document handle - dash_sdk_document_destroy(handle, OpaquePointer(documentHandle)) + dash_sdk_document_free(OpaquePointer(documentHandle)) } // Get document info to convert to JSON diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift index 5fceb0c5d21..a06225fe9dd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift @@ -543,7 +543,7 @@ extension SDK { defer { // Clean up document handle when done - dash_sdk_document_handle_destroy(documentHandle) + dash_sdk_document_free(documentHandle) } // 2. Create identity public key handle directly from our local data (no network fetch) @@ -628,6 +628,10 @@ extension SDK { continuation.resume(returning: ["status": "success", "raw": jsonString]) } } else { + if putResult.data_type == DashSDKFFI.ResultDocumentHandle, + let createdHandle = putResult.data { + dash_sdk_document_free(OpaquePointer(createdHandle)) + } print("✅ [DOCUMENT CREATE] Success! Total operation time: \(Date().timeIntervalSince(startTime)) seconds") continuation.resume(returning: ["status": "success", "message": "Document created successfully"]) } @@ -1005,8 +1009,7 @@ extension SDK { let docFetchTime = Date().timeIntervalSince(docFetchStartTime) print("📝 [DOCUMENT TRANSFER] Document fetch took \(docFetchTime) seconds") - guard fetchResult.error == nil, - let documentHandle = fetchResult.data else { + guard fetchResult.error == nil else { let error = fetchResult.error.pointee let errorMsg = String(cString: error.message) dash_sdk_error_free(fetchResult.error) @@ -1015,43 +1018,22 @@ extension SDK { return } + guard let documentHandle = fetchResult.data else { + print("❌ [DOCUMENT TRANSFER] Document not found") + continuation.resume(throwing: SDKError.notFound("Document not found")) + return + } + defer { - dash_sdk_document_destroy(handle, OpaquePointer(documentHandle)) + dash_sdk_document_free(OpaquePointer(documentHandle)) } print("✅ [DOCUMENT TRANSFER] Document fetched successfully") - print("🔄 [DOCUMENT TRANSFER] Step 3: Creating transfer transition...") + print("🔄 [DOCUMENT TRANSFER] Step 3: Signing, broadcasting and waiting for confirmation...") let transferStartTime = Date() - // First, try to create the state transition without waiting - print("🔄 [DOCUMENT TRANSFER] Creating state transition...") - let transitionResult = dash_sdk_document_transfer_to_identity( - handle, - OpaquePointer(documentHandle), - toIdentityCString, - contractIdCString, - documentTypeCString, - keyHandle, - signerBox.p, - nil, // token_payment_info - nil, // put_settings - nil // state_transition_creation_options - ) - - guard transitionResult.error == nil else { - let error = transitionResult.error.pointee - let errorMsg = String(cString: error.message) - dash_sdk_error_free(transitionResult.error) - print("❌ [DOCUMENT TRANSFER] Failed to create transition: \(errorMsg)") - continuation.resume(throwing: SDKError.stateTransitionFailure( - errorMsg, ffiError: error, otherwise: SDKError.protocolError)) - return - } - - - // Now try the _and_wait version which handles broadcasting internally - print("🔄 [DOCUMENT TRANSFER] Broadcasting and waiting for confirmation...") + // Signs the transfer once, broadcasts it and waits for the result. let result = dash_sdk_document_transfer_to_identity_and_wait( handle, OpaquePointer(documentHandle), @@ -1094,6 +1076,11 @@ extension SDK { return } + if result.data_type == DashSDKFFI.ResultDocumentHandle, + let transferredHandle = result.data { + dash_sdk_document_free(OpaquePointer(transferredHandle)) + } + // Document transfer was successful let totalTime = Date().timeIntervalSince(startTime) print("✅ [DOCUMENT TRANSFER] Successfully transferred in \(totalTime) seconds") @@ -1192,7 +1179,7 @@ extension SDK { } defer { - dash_sdk_document_destroy(handle, OpaquePointer(documentHandle)) + dash_sdk_document_free(OpaquePointer(documentHandle)) } print("✅ [DOCUMENT UPDATE PRICE] Document fetched successfully") @@ -1238,6 +1225,11 @@ extension SDK { return } + if updateResult.data_type == DashSDKFFI.ResultDocumentHandle, + let updatedHandle = updateResult.data { + dash_sdk_document_free(OpaquePointer(updatedHandle)) + } + let totalTime = Date().timeIntervalSince(startTime) print("✅ [DOCUMENT UPDATE PRICE] Successfully updated in \(totalTime) seconds") @@ -1342,7 +1334,7 @@ extension SDK { } defer { - dash_sdk_document_destroy(handle, OpaquePointer(documentHandle)) + dash_sdk_document_free(OpaquePointer(documentHandle)) } print("📝 [DOCUMENT PURCHASE] Document fetched in \(Date().timeIntervalSince(documentFetchStart)) seconds") @@ -1401,7 +1393,7 @@ extension SDK { } // Clean up the purchased document handle - dash_sdk_document_destroy(handle, purchasedDocHandle) + dash_sdk_document_free(purchasedDocHandle) let totalTime = Date().timeIntervalSince(startTime) print("✅ [DOCUMENT PURCHASE] Purchase completed and confirmed in \(totalTime) seconds") From aba908bc7d653957331e2b01e236c765fcf312a8 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 18:15:49 +0700 Subject: [PATCH 080/113] fix(platform)!: refuse own-type totals on contested types and fail loudly on unread totals (PV14) (#5115) Co-authored-by: Claude Opus 5.5 --- book/src/contract-keywords.md | 6 + .../contract-keywords/property-constraints.md | 4 +- book/src/data-model/documents.md | 4 +- .../document/v3/document-meta.json | 2 +- .../class_methods/try_from_schema/mod.rs | 19 +- .../property_constraint_aggregates_tests.rs | 264 +++++++++++++++++- .../document_type/methods/mod.rs | 4 +- .../methods/versioned_methods.rs | 45 ++- .../document_type/property_constraints/mod.rs | 126 +++++++-- .../property_constraints/tests.rs | 18 +- .../advanced_structure_v1/mod.rs | 2 +- .../advanced_structure_v0/mod.rs | 2 +- .../advanced_structure_v0/mod.rs | 2 +- .../advanced_structure_v0/mod.rs | 2 +- .../advanced_structure_v0/mod.rs | 2 +- .../tests/document/property_constraints.rs | 159 ++++++++++- .../batch/transformer/v0/mod.rs | 195 ++++--------- .../v0/property_constraint_aggregates.rs | 113 +++++++- .../src/version/system_limits/mod.rs | 6 +- .../rs-platform-version/src/version/v14.rs | 9 +- 20 files changed, 792 insertions(+), 192 deletions(-) diff --git a/book/src/contract-keywords.md b/book/src/contract-keywords.md index 82d27bd2ddd..eb81f0d1431 100644 --- a/book/src/contract-keywords.md +++ b/book/src/contract-keywords.md @@ -236,6 +236,10 @@ A rule is one condition. Conditions: | `present`, `absent` | a path | The document holds the property, or leaves it out (or null, or an object with no member present). | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | | `anyOf`, `allOf` | two or more conditions | At least one, or every, condition holds, checked in order. | 14 | [Evaluation order](contract-keywords/property-constraints.md#evaluation-order-and-short-circuiting) | | `not` | a condition | The condition does not hold. | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | +| `ifThen`, `ifThenElse` | `[if, then]`, `[if, then, else]` | The second condition holds when the first does (and, for `ifThenElse`, the third when it does not); only the branch taken is evaluated. | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | +| `notIn` | `[a, [values]]` | `a` takes none of the listed values. | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | +| `startsWith`, `endsWith` | `[text, affix]` | A string starts or ends with another, byte for byte. | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | +| `contains` | `[array, value]` | A typed array holds an element equal to the value. | 14 | [Conditions](contract-keywords/property-constraints.md#conditions) | Expressions: @@ -245,6 +249,8 @@ Expressions: | a path | `"price"`, `"meta.total"` | An integer or boolean property's value; 0 when left out. | 14 | [Expressions](contract-keywords/property-constraints.md#expressions) | | `add`, `multiply` | two or more operands | The sum or product. | 14 | [Arithmetic](contract-keywords/property-constraints.md#arithmetic) | | `subtract`, `divide`, `modulo`, `power` | `[a, b]` | The difference, Euclidean quotient or remainder, or power. | 14 | [Arithmetic](contract-keywords/property-constraints.md#arithmetic) | +| `min`, `max`, `abs` | two or more operands, or one for `abs` | The least, the greatest, or the absolute value. | 14 | [Expressions](contract-keywords/property-constraints.md#expressions) | +| `countOf`, `sumOf` | `[type, filter?]`, `[type, property, filter?]` | How many documents of a type of the contract match the filter, or the total of an integer property over them, from its count or sum trees. | 14 | [Totals of other documents](contract-keywords/property-constraints.md#totals-of-other-documents) | | `ifAbsent` | `[path, default]` | The property's value, or the default when left out (an integer, or a string for a string property). | 14 | [Expressions](contract-keywords/property-constraints.md#expressions) | | `length`, `byteLength` | a string path | A string's length in characters, or in UTF-8 bytes. | 14 | [Expressions](contract-keywords/property-constraints.md#expressions) | | `count` | an array path | The elements of a typed array, or the bytes of a byte array. | 14 | [Expressions](contract-keywords/property-constraints.md#expressions) | diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index 1105dbfe94e..e12417b9893 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -198,7 +198,7 @@ A filter maps each key, a property of the counted type or `$ownerId`, to the val ``` - **As it will be after the write.** The total is the stored one with the write applied. When the counted type is the rule's own, a create adds the document, a replace swaps its stored version for the new one, and a transfer or purchase moves it to its new owner. So `atMostTenListings`, declared on `listing`, keeps every owner at ten or fewer, and a replace of one of ten is allowed. -- **Judged when the rule's own type is written.** A rule is never judged on writes of the type it counts. On its own type it holds for good, since every write that could raise the total is judged. On another type it is only checked when its own type is written, and can go stale later: deleting a `profile` does not undo a `post` that needed one. Deletes are not judged, so a lower bound can be broken by deleting documents. +- **Judged when the rule's own type is written.** A rule is never judged on writes of the type it counts. On its own type it holds for good, since every write that could raise the total is judged; a type with a contested index cannot total its own documents, since a document a contest awards is stored without any rule judged. On another type it is only checked when its own type is written, and can go stale later: deleting a `profile` does not undo a `post` that needed one. Deletes are not judged, so a lower bound can be broken by deleting documents. - **Transfers, purchases and price updates.** A total that depends on the owner (a filter value of `$ownerId`, or a `$ownerId` key on the rule's own type) is read again for a transfer or purchase, the document counted toward its new owner. A rule a price update judges, one reading `$updatedAt…`, reads its totals too. - **Billed.** Each total is a state read billed with the write. A total two rules read alike is read once. - **Every earlier write counts.** A document batch carries one transition, and each state transition of a block is applied before the next is validated, so a total includes every write before it. @@ -246,7 +246,7 @@ The parser then checks the rules against the document type (`InvalidContractStru - `present` and `absent` do not name `$ownerId` or a time or height, and an index-only type has no rule reading any of them; - no `anyOf` or `allOf` lists two conditions that parse alike, such as `1` and `1.0`, or two `in` conditions listing the same values in another order, and no `ifThen` or `ifThenElse` holds two alike conditions; - no condition or operand nests more than 64 levels deep; -- once every document type of the contract is parsed, every `countOf` and `sumOf` counts a type of the contract that is not index-only, with a tree that keeps the total as set out in [Totals of other documents](#totals-of-other-documents). A unique, contested, ranked, time-range or index-only-terminal index keeps no such total, nor does one with more properties than the filter has keys; +- once every document type of the contract is parsed, every `countOf` and `sumOf` counts a type of the contract that is not index-only, and not its own type when that has a contested index, with a tree that keeps the total as set out in [Totals of other documents](#totals-of-other-documents). A unique, contested, ranked, time-range or index-only-terminal index keeps no such total, nor does one with more properties than the filter has keys; - every key of a filter is `$ownerId` or an integer, string or identifier property of the counted type, and its value is of the same kind; a string constant is in the key's `enum` when it has one, and an identifier constant is base58; - every property a filter value reads is listed in `required`, with every object around it, so a write always has the value; an index-only type has no rule reading a total. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index f4aa6ba66a6..e173a4c39ae 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -768,9 +768,9 @@ The arithmetic is exact over `i128`. Operands are evaluated left to right, and e Conditions are checked in declared order and no further than the outcome needs: a comparison evaluates its left side, then its right; `anyOf` stops at the first condition that holds and `allOf` at the first that fails. A fault in a condition that is checked breaks the rule whatever the others would say, and `not` does not turn it into a pass. So an earlier condition guards a later one: `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` holds for a `b` of 0 without dividing by it, while the same two conditions the other way round divide by zero and break the rule. -The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type, every path `length` or `byteLength` measures a string property, every path `count` counts an array or byte array property, every system time or height a rule reads is one the type lists in `required` (and an indexOnly type reads none), every path compared with a string names a string property (and every constant compared with one that declares an `enum` is one of its values) (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `const`, every `present` or `absent`, every arithmetic operator and every operand (a size is one), that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value), and that the rules of one type read at most `max_property_constraint_aggregates` (4) distinct totals. Once every document type of the contract is parsed, a registration also checks every `countOf` and `sumOf`: the type it counts is one of the contract's and not indexOnly, a tree of it keeps the total, the filter's keys and values are integers, strings or identifiers of the same kind, and every property a value reads is required. The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. +The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer or boolean property of the type, every path `length` or `byteLength` measures a string property, every path `count` counts an array or byte array property, every system time or height a rule reads is one the type lists in `required` (and an indexOnly type reads none), every path compared with a string names a string property (and every constant compared with one that declares an `enum` is one of its values) (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison and `in` reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `in` lists a value twice, that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `in` and each value it lists, every `const`, every `present` or `absent`, every arithmetic operator and every operand (a size is one), that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value), and that the rules of one type read at most `max_property_constraint_aggregates` (4) distinct totals. Once every document type of the contract is parsed, a registration also checks every `countOf` and `sumOf`: the type it counts is one of the contract's and not indexOnly, nor the declaring type when it has a contested index (a document a contest awards is stored without the rules judged), a tree of it keeps the total, the filter's keys and values are integers, strings or identifiers of the same kind, and every property a value reads is required. The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. -Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check changes nothing stored. It reads state only for the `countOf` and `sumOf` totals, which the document batch transformer reads when it builds the action (`Drive::fetch_property_constraint_aggregate`, billed with the write) and hands the check in `DocumentSystemValues::aggregates`; the limits bound its cost. `$ownerId` and the system times and heights read the values of the document version being written (`validate_document_properties` takes them as `DocumentSystemValues`): on a create the writer and the block's time and heights, on a replace the writer, the stored creation and transfer values and the block's as the update. A client passes what it knows: an owner it does not know equals no identifier, and a rule reading a time, a height or a total it is not given is not judged. The SDK pre-checks use the device clock for the times a write records, and leave a rule reading a block height unjudged, since the height is unknown until the block. Transfers and purchases change no property, only the owner and the transfer's time and heights, so only the rules reading those are judged again, with the new values (`DocumentTypeV0Methods::validate_property_constraints_for_system_change`, next to the `distinctFrom` check). Price updates change only the update's time and heights, and are judged against the rules reading those the same way. +Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check changes nothing stored. It reads state only for the `countOf` and `sumOf` totals, which the document batch transformer reads when it builds the action (`Drive::fetch_property_constraint_aggregate`, billed with the write) and hands the check in `DocumentSystemValues::aggregates`; the limits bound its cost. `$ownerId` and the system times and heights read the values of the document version being written (`validate_document_properties` takes them as `DocumentSystemValues`): on a create the writer and the block's time and heights, on a replace the writer, the stored creation and transfer values and the block's as the update. A client passes what it knows: an owner it does not know equals no identifier, and a rule reading a time, a height or a total it is not given is not judged. Consensus reads every total the rules it judges read (`DocumentSystemValues::aggregates` is `Some`), so one missing there is an error in the code building the write, never a skipped rule. The SDK pre-checks use the device clock for the times a write records, and leave a rule reading a block height unjudged, since the height is unknown until the block. Transfers and purchases change no property, only the owner and the transfer's time and heights, so only the rules reading those are judged again, with the new values (`DocumentTypeV0Methods::validate_property_constraints_for_system_change`, next to the `distinctFrom` check). Price updates change only the update's time and heights, and are judged against the rules reading those the same way. In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, an `in`, a string comparison (`TextCompare`, `TextIn`), a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and how: by value, by presence, by size (`Length`, `Count`) or in a comparison of strings or identifiers, `system_reads` the system times and heights it reads, and `aggregate_reads` the totals, each an `AggregateRead`), each rule's `holds` and `violation` evaluate it against a document's data and `DocumentSystemValues`, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index dda80481298..31114081bd8 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -2298,7 +2298,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, startsWith or endsWith listing two strings, a const or a string property each, at least one a property, and holding if the first starts or ends with the second, byte for byte (a constant looked for in a string property that declares enum must start or end one of its values), contains listing the path of a typed array property and the value one of its elements must equal (an integer expression among integers, a const string or string property among strings, a const base58 identifier, identifier property or $ownerId among identifiers; an array left out holds nothing, and a constant must be one of the elements' enum values when they declare one), present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf, not, ifThen or ifThenElse over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not, ifThen if its second condition holds whenever its first does (the second evaluated only then), ifThenElse if its second holds when its first does and its third when it does not (only that branch evaluated); notIn lists what in lists and holds if the expression takes none of the values. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, a system time or height the document type records by listing it in required ($createdAt, $updatedAt and $transferredAt, block times in milliseconds, and each with BlockHeight or CoreBlockHeight appended, the Platform and Core block heights: those of the create, of the last create, replace or price update, and of the last create, transfer or purchase), or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add, multiply, min or max, two or more operands; subtract, divide, modulo or power, exactly two; abs, one; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out ({ \"lessThanOrEqual\": [{ \"count\": \"tags\" }, \"maxTags\"] }). A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property length or byteLength measures a string property, every property count counts an array or byte array property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not or a notIn, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every contains and its array, every const, every present or absent, every arithmetic operator and every operand, a size included; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. Likewise a transfer or a purchase is judged against the rules reading the transfer's time and heights, and a price update against those reading the update's, since each sets them; an indexOnly type reads no system time or height. The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, startsWith or endsWith listing two strings, a const or a string property each, at least one a property, and holding if the first starts or ends with the second, byte for byte (a constant looked for in a string property that declares enum must start or end one of its values), contains listing the path of a typed array property and the value one of its elements must equal (an integer expression among integers, a const string or string property among strings, a const base58 identifier, identifier property or $ownerId among identifiers; an array left out holds nothing, and a constant must be one of the elements' enum values when they declare one), present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf, not, ifThen or ifThenElse over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not, ifThen if its second condition holds whenever its first does (the second evaluated only then), ifThenElse if its second holds when its first does and its third when it does not (only that branch evaluated); notIn lists what in lists and holds if the expression takes none of the values. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, a system time or height the document type records by listing it in required ($createdAt, $updatedAt and $transferredAt, block times in milliseconds, and each with BlockHeight or CoreBlockHeight appended, the Platform and Core block heights: those of the create, of the last create, replace or price update, and of the last create, transfer or purchase), or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add, multiply, min or max, two or more operands; subtract, divide, modulo or power, exactly two; abs, one; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out ({ \"lessThanOrEqual\": [{ \"count\": \"tags\" }, \"maxTags\"] }); countOf or sumOf, how many documents of a document type of this contract match a filter (keys of that type or $ownerId, each mapped to a value read from the document written) or the total of an integer property over them, as that type's count or sum trees keep it once the write is done, which needs documentsCountable or documentsSummable for a whole type and otherwise an index whose properties are exactly the filter's keys ({ \"lessThanOrEqual\": [{ \"countOf\": [\"listing\", { \"$ownerId\": \"$ownerId\" }] }, 10] }). A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer or boolean property, every property length or byteLength measures a string property, every property count counts an array or byte array property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not or a notIn, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every in and each value it lists, every contains and its array, every const, every present or absent, every arithmetic operator and every operand, a size included; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. Likewise a transfer or a purchase is judged against the rules reading the transfer's time and heights, and a price update against those reading the update's, since each sets them; an indexOnly type reads no system time or height. The rules change nothing stored, and read state only for countOf and sumOf, each total a billed read of a count or sum tree, at most max_property_constraint_aggregates distinct totals per type (4); a type with a contested index totals no documents of its own type, since a document a contest awards is stored without the rules judged. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 8059967bce2..e82adf09b2a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -2634,7 +2634,8 @@ impl AggregateKeyKind { /// Checks every `countOf` and `sumOf` the `propertyConstraints` rules of /// `document_types`, one contract's, read, once all of them are parsed: the -/// type it totals is one of them, and not an indexOnly one; a key of its filter +/// type it totals is one of them, and not an indexOnly one, nor the declaring +/// type itself when it has a contested index; a key of its filter /// is `$ownerId` or an integer, string or identifier property of that type, /// and the value matched against it is of the same kind: a property of the /// declaring type, `$ownerId`, an integer, a string the key's `enum` lists, or @@ -2676,6 +2677,22 @@ pub(in crate::data_contract::document_type::class_methods) fn validate_property_ total" ))); } + // A document of the type a contest is opened for waits in the contest's + // storage, outside the count and sum trees, and the one a contest awards + // is stored without any rule judged, so a total of the type's own + // documents could pass the rule + if read.of_own_type + && counted + .indexes() + .values() + .any(|index| index.contested_index.is_some()) + { + return Err(error(format!( + "{totals}, its own type, which has a contested index: a document a \ + contest awards is stored without the rules being judged, so the total \ + could pass the rule" + ))); + } if read.filter.is_empty() { if !read.whole_type_kept(counted) { return Err(error(match &read.kind { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraint_aggregates_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraint_aggregates_tests.rs index 805697009ea..9bd8858c652 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraint_aggregates_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraint_aggregates_tests.rs @@ -9,8 +9,10 @@ use crate::consensus::basic::basic_error::BasicError; use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; +use crate::data_contract::document_type::methods::DocumentTypeV0Methods; use crate::data_contract::document_type::property_constraints::{ - AggregateBinding, AggregateKind, AggregateRead, EqualityKind, PropertyRead, + AggregateBinding, AggregateKind, AggregateRead, DocumentSystemValues, EqualityKind, + PropertyRead, SystemChange, }; use crate::data_contract::DataContract; use platform_value::string_encoding::Encoding; @@ -704,3 +706,263 @@ fn should_hold_a_constant_to_the_enum_of_a_key_declared_by_reference() { "with \"status\" at \"opne\", which is not one of its enum values", ); } + +/// A type with a contested index cannot total its own documents: a contested +/// create waits in its contest's storage, outside the count and sum trees, and +/// the document a contest awards is stored without the rules being judged, so +/// the rule could be passed. Another type may still total it, as it totals any +/// type whose writes it does not judge. +#[test] +fn should_refuse_a_total_of_its_own_type_with_a_contested_index() { + let contract = |name_rules: Option, + note_rules: Option| { + let mut name = json!({ + "type": "object", + "documentsMutable": false, + "indices": [ + { + "name": "byLabel", + "properties": [{ "normalizedLabel": "asc" }], + "unique": true, + "contested": { + "fieldMatches": [ + { "field": "normalizedLabel", "regexPattern": "^[a-z]{3,}$" } + ], + "resolution": 0 + } + }, + { + "name": "byOwner", + "properties": [{ "$ownerId": "asc" }], + "countable": "countable" + } + ], + "properties": { + "normalizedLabel": { "type": "string", "maxLength": 50, "position": 0 } + }, + "required": ["normalizedLabel"], + "additionalProperties": false + }); + if let Some(rules) = name_rules { + name["propertyConstraints"] = rules; + } + let mut note = json!({ + "type": "object", + "properties": { + "text": { "type": "string", "maxLength": 50, "position": 0 } + }, + "additionalProperties": false + }); + if let Some(rules) = note_rules { + note["propertyConstraints"] = rules; + } + let contract = json!({ + "$formatVersion": "1", + "id": Identifier::from([7; 32]).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { "name": name, "note": note } + }); + DataContract::from_value( + platform_value::to_value(contract).expect("the contract converts"), + true, + PlatformVersion::latest(), + ) + }; + let one_per_owner = json!({ + "onePerOwner": { + "lessThanOrEqual": [{ "countOf": ["name", { "$ownerId": "$ownerId" }] }, 1] + } + }); + + expect_structure_error( + contract(Some(one_per_owner.clone()), None), + "rule \"onePerOwner\" counts \"name\", its own type, which has a contested index", + ); + contract(None, Some(one_per_owner)).expect("another type may count it"); +} + +/// A client gives no totals, and a rule reading one is not judged; consensus +/// gives the totals of every rule it judges, so one missing there is an error +/// in the code building the write rather than a rule silently skipped. +#[test] +fn should_refuse_to_skip_a_rule_whose_total_consensus_did_not_read() { + let platform_version = PlatformVersion::latest(); + let contract = contract( + None, + Some(json!({ + "atMostTenPerOwner": { + "lessThanOrEqual": [{ "countOf": ["listing", { "$ownerId": "$ownerId" }] }, 10] + } + })), + true, + ) + .expect("the contract registers"); + let listing = contract.document_type_for_name("listing").expect("listing"); + let data = Value::from(std::collections::BTreeMap::from([ + ("price".to_string(), Value::U64(40)), + ("category".to_string(), Value::U64(1)), + ("status".to_string(), Value::Text("open".to_string())), + ])); + let owner = Identifier::from([3; 32]); + let read = listing.property_constraints()["atMostTenPerOwner"].aggregate_reads()[0].clone(); + let with = |aggregates| DocumentSystemValues { + aggregates, + ..DocumentSystemValues::owned_by(owner) + }; + + let client = listing + .validate_property_constraints(&data, &with(None), platform_version) + .expect("a client's check runs"); + assert!(client.is_valid(), "a client skips the rule"); + + let refused = listing + .validate_property_constraints( + &data, + &with(Some([(read.clone(), 11)].into())), + platform_version, + ) + .expect("consensus judges the rule"); + assert!(!refused.is_valid(), "11 is above the cap"); + + let missing = listing.validate_property_constraints( + &data, + &with(Some(Default::default())), + platform_version, + ); + assert!( + matches!( + &missing, + Err(ProtocolError::CorruptedCodeExecution(message)) + if message.contains("rule atMostTenPerOwner of document type listing reads a countOf of listing that consensus did not read") + ), + "{missing:?}" + ); +} + +/// A transfer, a purchase or a price update judges only the rules it can +/// break, so consensus reads only their totals: a missing total of a rule it +/// judges is an error, one of a rule it does not judge is not, and a client +/// skips both. +#[test] +fn should_refuse_to_skip_a_rule_a_system_change_judges_without_its_total() { + let platform_version = PlatformVersion::latest(); + let contract = json!({ + "$formatVersion": "1", + "id": Identifier::from([7; 32]).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { + "offer": { + "type": "object", + "documentsMutable": true, + "transferable": 1, + "tradeMode": 1, + "documentsCountable": true, + "properties": { + "category": { "type": "integer", "minimum": 0, "maximum": 100, "position": 0 }, + "price": { "type": "integer", "minimum": 0, "maximum": 1000000000, "position": 1 }, + "endsAt": { "type": "integer", "minimum": 0, "position": 2 } + }, + "required": ["category", "price", "endsAt", "$updatedAt"], + "indices": [ + { + "name": "byOwner", + "properties": [{ "$ownerId": "asc" }], + "countable": "countable" + }, + { + "name": "byCategory", + "properties": [{ "category": "asc" }], + "summable": "price" + } + ], + "propertyConstraints": { + "ownerCap": { + "lessThanOrEqual": [{ "countOf": ["offer", { "$ownerId": "$ownerId" }] }, 10] + }, + "openWindow": { + "allOf": [ + { "lessThanOrEqual": ["$updatedAt", "endsAt"] }, + { "lessThanOrEqual": [{ "countOf": ["offer"] }, 100] } + ] + }, + "categoryCap": { + "lessThanOrEqual": [ + { "sumOf": ["offer", "price", { "category": "category" }] }, + 1000 + ] + } + }, + "additionalProperties": false + } + } + }); + let contract = DataContract::from_value( + platform_value::to_value(contract).expect("the contract converts"), + true, + platform_version, + ) + .expect("the contract registers"); + let offer = contract.document_type_for_name("offer").expect("offer"); + let rules = offer.property_constraints(); + let owner_cap = rules["ownerCap"].aggregate_reads()[0].clone(); + let open_window = rules["openWindow"].aggregate_reads()[0].clone(); + let data = BTreeMap::from([ + ("category".to_string(), Value::U64(1)), + ("price".to_string(), Value::U64(40)), + ("endsAt".to_string(), Value::U64(1000)), + ]); + let judge = |change: SystemChange, aggregates: Option>| { + let system = DocumentSystemValues { + updated_at: Some(500), + aggregates: aggregates.map(|aggregates| aggregates.into_iter().collect()), + ..DocumentSystemValues::owned_by(Identifier::from([3; 32])) + }; + offer.validate_property_constraints_for_system_change( + &data, + &system, + change, + platform_version, + ) + }; + let is_unread = |result: &Result<_, ProtocolError>, rule: &str| { + matches!( + result, + Err(ProtocolError::CorruptedCodeExecution(message)) + if message.starts_with(&format!("rule {rule} of document type offer reads a countOf")) + ) + }; + + // A transfer judges the owner's cap alone + let transfer_without = judge(SystemChange::Transfer, Some(vec![])); + assert!( + is_unread(&transfer_without, "ownerCap"), + "{transfer_without:?}" + ); + let transfer = judge(SystemChange::Transfer, Some(vec![(owner_cap.clone(), 3)])) + .expect("the transfer's rules are judged"); + assert!( + transfer.is_valid(), + "openWindow and categoryCap are not judged" + ); + + // A price update judges the window alone + let price_update_without = judge(SystemChange::PriceUpdate, Some(vec![])); + assert!( + is_unread(&price_update_without, "openWindow"), + "{price_update_without:?}" + ); + let price_update = judge(SystemChange::PriceUpdate, Some(vec![(open_window, 101)])) + .expect("the price update's rules are judged"); + assert!( + !price_update.is_valid(), + "101 offers is above the window's 100" + ); + + // A client skips the rules reading totals + for change in [SystemChange::Transfer, SystemChange::PriceUpdate] { + let skipped = judge(change, None).expect("a client's check runs"); + assert!(skipped.is_valid()); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index f5ed6a76811..2656f148371 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -995,7 +995,7 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe .validate_property_constraints { None => Ok(SimpleConsensusValidationResult::default()), - Some(0) => Ok(self.validate_property_constraints_v0(data, system)), + Some(0) => self.validate_property_constraints_v0(data, system), Some(version) => Err(ProtocolError::UnknownVersionMismatch { method: "validate_property_constraints".to_string(), known_versions: vec![0], @@ -1037,7 +1037,7 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe { None => Ok(SimpleConsensusValidationResult::default()), Some(0) => { - Ok(self.validate_property_constraints_for_system_change_v0(data, system, change)) + self.validate_property_constraints_for_system_change_v0(data, system, change) } Some(version) => Err(ProtocolError::UnknownVersionMismatch { method: "validate_property_constraints_for_system_change".to_string(), diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs index 37d9b66b67d..ee35d2ee1dc 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs @@ -4,7 +4,7 @@ use crate::data_contract::document_type::accessors::{ }; use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; use crate::data_contract::document_type::property_constraints::{ - DocumentSystemValues, SystemChange, + DocumentSystemValues, PropertyConstraint, SystemChange, }; use crate::data_contract::document_type::v0::DocumentTypeV0; use crate::data_contract::document_type::v1::DocumentTypeV1; @@ -865,28 +865,50 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa /// `validate_property_constraints` version 0: every rule of the document type's /// `propertyConstraints` is evaluated against `data` in name order, and the first one - /// broken is reported. A type without rules costs nothing. + /// broken is reported. A type without rules costs nothing. A rule reading a total + /// consensus did not read ([`PropertyConstraint::unread_aggregate`]) is an error. fn validate_property_constraints_v0( &self, data: &Value, system: &DocumentSystemValues, - ) -> SimpleConsensusValidationResult + ) -> Result where Self: DocumentTypeV2Getters, { for (name, constraint) in self.property_constraints() { + self.expect_every_aggregate_read(name, constraint, system)?; if let Some(violation) = constraint.violation(data, system) { - return SimpleConsensusValidationResult::new_with_error( + return Ok(SimpleConsensusValidationResult::new_with_error( DocumentPropertyConstraintViolatedError::new( self.name().clone(), name.clone(), violation, ) .into(), - ); + )); } } - SimpleConsensusValidationResult::default() + Ok(SimpleConsensusValidationResult::default()) + } + + /// An error when `system` holds consensus's totals and lacks one the rule `name` + /// reads, which would otherwise leave the rule unjudged. + fn expect_every_aggregate_read( + &self, + name: &str, + constraint: &PropertyConstraint, + system: &DocumentSystemValues, + ) -> Result<(), ProtocolError> { + match constraint.unread_aggregate(system) { + None => Ok(()), + Some(read) => Err(ProtocolError::CorruptedCodeExecution(format!( + "rule {name} of document type {} reads a {} of {} that consensus did not read: \ + {read:?}", + self.name(), + read.wire_name(), + read.document_type + ))), + } } /// `validate_property_constraints_for_system_change` version 0: every rule of the @@ -898,7 +920,7 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa data: &BTreeMap, system: &DocumentSystemValues, change: SystemChange, - ) -> SimpleConsensusValidationResult + ) -> Result where Self: DocumentTypeV2Getters, { @@ -908,22 +930,23 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa .filter(|(_, constraint)| constraint.reads_change(change)) .peekable(); if changed_rules.peek().is_none() { - return SimpleConsensusValidationResult::default(); + return Ok(SimpleConsensusValidationResult::default()); } let data = Value::from(data.clone()); for (name, constraint) in changed_rules { + self.expect_every_aggregate_read(name, constraint, system)?; if let Some(violation) = constraint.violation(&data, system) { - return SimpleConsensusValidationResult::new_with_error( + return Ok(SimpleConsensusValidationResult::new_with_error( DocumentPropertyConstraintViolatedError::new( self.name().clone(), name.clone(), violation, ) .into(), - ); + )); } } - SimpleConsensusValidationResult::default() + Ok(SimpleConsensusValidationResult::default()) } } diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index 99cd1943f29..8f0a0fc429d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -62,8 +62,8 @@ //! integer property over them, from the count and sum trees their indexes keep //! (`{ "countOf": ["listing", { "$ownerId": "$ownerId" }] }`), as the total will //! be once the write is done. Consensus reads them before judging the rules -//! ([`DocumentSystemValues::aggregates`]); a rule reading one it is not given is -//! not judged. +//! ([`DocumentSystemValues::aggregates`]), and a total missing there is an +//! error; a client, which reads none, does not judge a rule reading one. //! How the arithmetic treats overflow, division and powers is set out on //! [`ConstraintExpression::evaluate`], and how conditions combine on //! [`PropertyConstraint::holds`]. @@ -312,9 +312,13 @@ pub struct DocumentSystemValues { pub updated_at_core_block_height: Option, pub transferred_at_core_block_height: Option, /// The `countOf` and `sumOf` totals the rules read, each as it will be once - /// the write is done ([`AggregateRead`]). Consensus reads every one the - /// rules judged against the write read; a client gives none. - pub aggregates: BTreeMap, + /// the write is done ([`AggregateRead`]). `None` for a client, which reads + /// no state: a rule reading a total is then not judged. `Some` for + /// consensus, which reads every total the rules judging the write read, so + /// that one missing there is a fault in the code building the write, which + /// `validate_property_constraints` reports as an error rather than skip the + /// rule ([`PropertyConstraint::unread_aggregate`]). + pub aggregates: Option>, } impl DocumentSystemValues { @@ -341,7 +345,7 @@ impl DocumentSystemValues { created_at_core_block_height: Some(block_info.core_height), updated_at_core_block_height: Some(block_info.core_height), transferred_at_core_block_height: Some(block_info.core_height), - aggregates: BTreeMap::new(), + aggregates: None, } } @@ -358,7 +362,7 @@ impl DocumentSystemValues { created_at_core_block_height: document.created_at_core_block_height(), updated_at_core_block_height: document.updated_at_core_block_height(), transferred_at_core_block_height: document.transferred_at_core_block_height(), - aggregates: BTreeMap::new(), + aggregates: None, } } @@ -573,9 +577,12 @@ impl ConstraintExpression { match self { ConstraintExpression::Value(value) => Ok(*value), ConstraintExpression::System(property) => Ok(system.value(*property).unwrap_or(0)), - ConstraintExpression::Aggregate(read) => { - Ok(system.aggregates.get(read).copied().unwrap_or(0)) - } + ConstraintExpression::Aggregate(read) => Ok(system + .aggregates + .as_ref() + .and_then(|aggregates| aggregates.get(read)) + .copied() + .unwrap_or(0)), ConstraintExpression::Property { path, if_absent } => { property_value(data, path, *if_absent) } @@ -793,12 +800,38 @@ impl ConstraintExpression { } } + /// The first aggregate the expression reads, in the order it reads them, + /// that `matches`, the walk stopping there. + fn find_aggregate<'a>( + &'a self, + matches: &dyn Fn(&AggregateRead) -> bool, + ) -> Option<&'a AggregateRead> { + match self { + ConstraintExpression::Aggregate(read) => matches(read).then_some(read), + ConstraintExpression::Value(_) + | ConstraintExpression::Property { .. } + | ConstraintExpression::Size { .. } + | ConstraintExpression::System(_) => None, + ConstraintExpression::Add(operands) + | ConstraintExpression::Multiply(operands) + | ConstraintExpression::Min(operands) + | ConstraintExpression::Max(operands) => operands + .iter() + .find_map(|operand| operand.find_aggregate(matches)), + ConstraintExpression::Abs(operand) => operand.find_aggregate(matches), + ConstraintExpression::Subtract(left, right) + | ConstraintExpression::Divide(left, right) + | ConstraintExpression::Modulo(left, right) + | ConstraintExpression::Power(left, right) => left + .find_aggregate(matches) + .or_else(|| right.find_aggregate(matches)), + } + } + /// Whether an aggregate the expression reads depends on the document's /// owner ([`AggregateRead::reads_owner`]). fn reads_owner(&self) -> bool { - let mut reads = Vec::new(); - self.collect_aggregate_reads(&mut reads); - reads.into_iter().any(AggregateRead::reads_owner) + self.find_aggregate(&AggregateRead::reads_owner).is_some() } } @@ -1240,9 +1273,13 @@ impl PropertyConstraint { .into_iter() .any(|property| system.value(property).is_none()) || self - .aggregate_reads() - .into_iter() - .any(|read| !system.aggregates.contains_key(read)) + .find_aggregate(&|read| { + !system + .aggregates + .as_ref() + .is_some_and(|aggregates| aggregates.contains_key(read)) + }) + .is_some() { return None; } @@ -1369,6 +1406,63 @@ impl PropertyConstraint { } } + /// A total the rule reads that consensus did not read: `None` unless + /// `system` holds consensus's totals (`Some`) and lacks one the rule reads. + /// Every write consensus judges reads the totals of the rules it judges, so + /// one missing is a fault in the code building the write, not a rule to + /// skip. + pub fn unread_aggregate<'a>( + &'a self, + system: &DocumentSystemValues, + ) -> Option<&'a AggregateRead> { + let aggregates = system.aggregates.as_ref()?; + self.find_aggregate(&|read| !aggregates.contains_key(read)) + } + + /// The first aggregate the rule reads, in declared order, that `matches`, + /// the walk stopping there rather than collecting every one. + fn find_aggregate<'a>( + &'a self, + matches: &dyn Fn(&AggregateRead) -> bool, + ) -> Option<&'a AggregateRead> { + match self { + PropertyConstraint::Compare { left, right, .. } => left + .find_aggregate(matches) + .or_else(|| right.find_aggregate(matches)), + PropertyConstraint::In { operand, .. } => operand.find_aggregate(matches), + PropertyConstraint::Contains { + needle: ContainsNeedle::Integer(expression), + .. + } => expression.find_aggregate(matches), + PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { + conditions + .iter() + .find_map(|condition| condition.find_aggregate(matches)) + } + PropertyConstraint::Not(condition) | PropertyConstraint::NotIn(condition) => { + condition.find_aggregate(matches) + } + PropertyConstraint::IfThen { + condition, + then, + otherwise, + } => [Some(condition), Some(then), otherwise.as_ref()] + .into_iter() + .flatten() + .find_map(|part| part.find_aggregate(matches)), + PropertyConstraint::TextCompare { .. } + | PropertyConstraint::TextCompareProperties { .. } + | PropertyConstraint::TextIn { .. } + | PropertyConstraint::TextAffix { .. } + | PropertyConstraint::IdentifierCompare { .. } + | PropertyConstraint::IdentifierCompareProperties { .. } + | PropertyConstraint::IdentifierIn { .. } + | PropertyConstraint::Contains { .. } + | PropertyConstraint::Present(_) + | PropertyConstraint::Absent(_) => None, + } + } + /// The aggregates the rule reads (`countOf`, `sumOf`), in declared order, /// one read twice listed twice. pub fn aggregate_reads(&self) -> Vec<&AggregateRead> { diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index 9e4cac9dca0..81e07348973 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -2438,7 +2438,7 @@ fn should_read_the_system_values_the_rule_is_judged_with() { created_at_core_block_height: Some(7), updated_at_core_block_height: Some(8), transferred_at_core_block_height: Some(9), - aggregates: BTreeMap::new(), + aggregates: None, }; for (index, property) in SystemProperty::ALL.into_iter().enumerate() { let expected = i128::try_from(index + 1).expect("small"); @@ -3574,7 +3574,7 @@ fn should_read_an_aggregate_from_the_system_values_and_skip_a_rule_not_given_one let read = rule.aggregate_reads()[0].clone(); let cheap = data(&[("price", Value::U64(5))]); let with_total = |total: i128| DocumentSystemValues { - aggregates: BTreeMap::from([(read.clone(), total)]), + aggregates: Some(BTreeMap::from([(read.clone(), total)])), ..DocumentSystemValues::default() }; @@ -3590,16 +3590,24 @@ fn should_read_an_aggregate_from_the_system_values_and_skip_a_rule_not_given_one // The first condition holds, so the total is never compared let dear = data(&[("price", Value::U64(5000))]); assert_eq!(rule.violation(&dear, &with_total(11)), None); - // A total given for another read leaves this rule unjudged + // Consensus's totals lacking this one: the rule is not evaluated, and the + // missing total is reported, which `validate_property_constraints` turns + // into an error; a client's, which reads none, only skips the rule let other = DocumentSystemValues { - aggregates: BTreeMap::from([( + aggregates: Some(BTreeMap::from([( AggregateRead { document_type: "listing".to_string(), ..read.clone() }, 99, - )]), + )])), ..DocumentSystemValues::default() }; assert_eq!(rule.violation(&cheap, &other), None); + assert_eq!(rule.unread_aggregate(&other), Some(&read)); + assert_eq!(rule.unread_aggregate(&with_total(3)), None); + assert_eq!( + rule.unread_aggregate(&DocumentSystemValues::default()), + None + ); } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs index 878cfddcc43..33645511f98 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs @@ -146,7 +146,7 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans document_type_name, self.data().into(), &DocumentSystemValues { - aggregates: self.property_constraint_aggregates().clone(), + aggregates: Some(self.property_constraint_aggregates().clone()), ..DocumentSystemValues::created_in_block(owner_id, &self.block_info()) }, platform_version, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs index 627c41a169f..0278c88752d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs @@ -89,7 +89,7 @@ impl DocumentPurchaseTransitionActionStructureValidationV0 for DocumentPurchaseT .validate_property_constraints_for_system_change( self.document().properties(), &DocumentSystemValues { - aggregates: self.property_constraint_aggregates().clone(), + aggregates: Some(self.property_constraint_aggregates().clone()), ..DocumentSystemValues::of_document(self.document()) }, SystemChange::Transfer, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs index f4f0ba27fc7..2294916eaad 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs @@ -62,7 +62,7 @@ impl DocumentReplaceTransitionActionStructureValidationV0 for DocumentReplaceTra created_at_core_block_height: self.created_at_core_block_height(), updated_at_core_block_height: self.updated_at_core_block_height(), transferred_at_core_block_height: self.transferred_at_core_block_height(), - aggregates: self.property_constraint_aggregates().clone(), + aggregates: Some(self.property_constraint_aggregates().clone()), }; let result = data_contract .validate_document_properties( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs index 1388d0b272f..de758205c71 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs @@ -75,7 +75,7 @@ impl DocumentTransferTransitionActionStructureValidationV0 for DocumentTransferT .validate_property_constraints_for_system_change( self.document().properties(), &DocumentSystemValues { - aggregates: self.property_constraint_aggregates().clone(), + aggregates: Some(self.property_constraint_aggregates().clone()), ..DocumentSystemValues::of_document(self.document()) }, SystemChange::Transfer, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/advanced_structure_v0/mod.rs index 0c1cad5e300..c86518e1e70 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/advanced_structure_v0/mod.rs @@ -58,7 +58,7 @@ impl DocumentUpdatePriceTransitionActionStructureValidationV0 .validate_property_constraints_for_system_change( self.document().properties(), &DocumentSystemValues { - aggregates: self.property_constraint_aggregates().clone(), + aggregates: Some(self.property_constraint_aggregates().clone()), ..DocumentSystemValues::of_document(self.document()) }, SystemChange::PriceUpdate, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index 8fc7624fa8e..3746c22fe4f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -2141,8 +2141,9 @@ mod property_constraints_tests { /// A mutable, transferable and purchasable `offer` type with the integers /// [`set_valid_offer`] fills (a price of at most 10^9, which a sum tree /// takes) and a required `category`, whose trees keep the count of each - /// owner's offers (`byOwner`) and the total price of each category - /// (`byCategory`), declaring `rules`. + /// owner's offers (`byOwner`), of each owner's offers in each category + /// (`byOwnerCategory`) and the total price of each category (`byCategory`), + /// declaring `rules`. fn counted_offer_schema(rules: Value) -> Value { platform_value!({ "type": "object", @@ -2163,6 +2164,11 @@ mod property_constraints_tests { "properties": [{ "$ownerId": "asc" }], "countable": "countable" }, + { + "name": "byOwnerCategory", + "properties": [{ "$ownerId": "asc" }, { "category": "asc" }], + "countable": "countable" + }, { "name": "byCategory", "properties": [{ "category": "asc" }], @@ -2461,6 +2467,155 @@ mod property_constraints_tests { fixture.set_price(1000).await; } + /// `schema` with the document-type-level `entries` added. + fn with_keys(mut schema: Value, entries: [(&str, Value); N]) -> Value { + let Value::Map(map) = &mut schema else { + panic!("a schema is an object"); + }; + for (key, value) in entries { + map.push((Value::Text(key.to_string()), value)); + } + schema + } + + /// `countOf` and `sumOf` over every document of the type, read from the + /// primary-key trees `documentsCountable` and `documentsSummable` keep: at + /// most two offers, whose prices total at most 250. + #[tokio::test] + async fn should_cap_the_whole_type_by_its_count_and_total() { + let mut fixture = OfferFixture::with_schema(with_keys( + counted_offer_schema(platform_value!({ + "fewOffers": { "lessThanOrEqual": [{ "countOf": ["offer"] }, 2] }, + "priceBudget": { "lessThanOrEqual": [{ "sumOf": ["offer", "price"] }, 250] } + })), + [ + ("documentsCountable", Value::Bool(true)), + ("documentsSummable", Value::Text("price".to_string())), + ], + )); + for category in [1, 2] { + assert_matches!( + fixture.create(priced_in(category, 100)).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + let result = fixture.create(priced_in(3, 10)).await; + expect_violated(result, "fewOffers", PropertyConstraintViolation::NotMet); + // 200 - 100 + 150, the count unchanged + assert_matches!( + fixture.replace(priced_in(2, 150)).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + // 250 - 150 + 151 + let result = fixture.replace(priced_in(2, 151)).await; + expect_violated(result, "priceBudget", PropertyConstraintViolation::NotMet); + assert_eq!(fixture.stored_offers().len(), 2); + } + + /// `countOf` by two keys, `$ownerId` and `category`: one offer per owner and + /// category. The first read finds no branch for the owner yet, which reads + /// as 0, and so does a category the owner has no offer in. + #[tokio::test] + async fn should_count_by_two_keys_from_an_empty_branch() { + let mut fixture = OfferFixture::with_schema(counted_offer_schema(platform_value!({ + "onePerCategory": { + "lessThanOrEqual": [ + { + "countOf": [ + "offer", + { "$ownerId": "$ownerId", "category": "category" } + ] + }, + 1 + ] + } + }))); + assert_matches!( + fixture.create(priced_in(1, 100)).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let result = fixture.create(priced_in(1, 100)).await; + expect_violated( + result, + "onePerCategory", + PropertyConstraintViolation::NotMet, + ); + assert_matches!( + fixture.create(priced_in(2, 100)).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 2); + } + + /// `sumOf` over another type of the contract: an offer's price is at most + /// what the deposits of its category total. + #[tokio::test] + async fn should_total_a_property_of_another_type() { + let mut fixture = OfferFixture::with_schemas( + counted_offer_schema(platform_value!({ + "coveredByDeposits": { + "lessThanOrEqual": [ + "price", + { "sumOf": ["deposit", "amount", { "category": "category" }] } + ] + } + })), + [( + "deposit", + platform_value!({ + "type": "object", + "properties": { + "category": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "position": 0 + }, + "amount": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000, + "position": 1 + } + }, + "required": ["category", "amount"], + "indices": [{ + "name": "byCategory", + "properties": [{ "category": "asc" }], + "summable": "amount" + }], + "additionalProperties": false + }), + )], + ); + let result = fixture.create(priced_in(1, 100)).await; + expect_violated( + result, + "coveredByDeposits", + PropertyConstraintViolation::NotMet, + ); + assert_matches!( + fixture + .create_of("deposit", |document| { + document.set("category", Value::U64(1)); + document.set("amount", Value::U64(150)); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_matches!( + fixture.create(priced_in(1, 100)).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let result = fixture.create(priced_in(2, 100)).await; + expect_violated( + result, + "coveredByDeposits", + PropertyConstraintViolation::NotMet, + ); + assert_eq!(fixture.stored_offers().len(), 1); + } + /// The totals a rule reads leave out the other writes of its batch, which is /// sound only while a document batch carries one transition: raising the /// limit needs the batch's own writes added to them. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index 1c4dc094031..6c1ccaf2d41 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -26,14 +26,7 @@ mod contract_moderation_gate; mod property_constraint_aggregates; use contract_moderation_gate::{BatchTransitionContractModerationGate, ContractModerationRefusal}; -use dpp::data_contract::document_type::property_constraints::SystemChange; -use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentCreateTransitionActionAccessorsV0; -use drive::state_transition_action::batch::batched_transition::document_transition::document_purchase_transition_action::DocumentPurchaseTransitionActionAccessorsV0; -use drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::DocumentReplaceTransitionActionAccessorsV0; -use drive::state_transition_action::batch::batched_transition::document_transition::document_transfer_transition_action::DocumentTransferTransitionActionAccessorsV0; -use drive::state_transition_action::batch::batched_transition::document_transition::document_update_price_transition_action::DocumentUpdatePriceTransitionActionAccessorsV0; -use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; -use property_constraint_aggregates::{read_property_constraint_aggregates, DocumentVersion}; +use property_constraint_aggregates::attach_property_constraint_aggregates; use std::borrow::Cow; use std::collections::btree_map::Entry; use std::collections::{BTreeMap, BTreeSet}; @@ -823,28 +816,18 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - // The `countOf` and `sumOf` totals the rules read, the new document counted - if let Some(BatchedTransitionAction::DocumentAction( - DocumentTransitionAction::CreateAction(action), - )) = document_create_action.data.as_mut() - { - let aggregates = read_property_constraint_aggregates( - drive, - &data_contract_fetch_info.contract, - document_create_transition.base().document_type_name(), - DocumentVersion { - properties: action.data(), - owner_id, - }, - None, - None, - block_info, - execution_context, - transaction, - platform_version, - )?; - action.set_property_constraint_aggregates(aggregates); - } + // The `countOf` and `sumOf` totals the rules judging the write read + attach_property_constraint_aggregates( + drive, + &data_contract_fetch_info.contract, + &mut document_create_action, + None, + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )?; Ok(document_create_action) } DocumentTransition::Replace(document_replace_transition) => { @@ -918,32 +901,18 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - // The `countOf` and `sumOf` totals the rules read, the document counted as it will be - // stored and no longer as it was - if let Some(BatchedTransitionAction::DocumentAction( - DocumentTransitionAction::ReplaceAction(action), - )) = document_replace_action.data.as_mut() - { - let aggregates = read_property_constraint_aggregates( - drive, - &data_contract_fetch_info.contract, - document_replace_transition.base().document_type_name(), - DocumentVersion { - properties: action.data(), - owner_id, - }, - Some(DocumentVersion { - properties: original_document.properties(), - owner_id: original_document.owner_id(), - }), - None, - block_info, - execution_context, - transaction, - platform_version, - )?; - action.set_property_constraint_aggregates(aggregates); - } + // The `countOf` and `sumOf` totals the rules judging the write read + attach_property_constraint_aggregates( + drive, + &data_contract_fetch_info.contract, + &mut document_replace_action, + Some(original_document), + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )?; Ok(document_replace_action) } @@ -1049,32 +1018,18 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - // The `countOf` and `sumOf` totals of the rules the new owner can break, the document - // counted as its new owner's and no longer as the old one's - if let Some(BatchedTransitionAction::DocumentAction( - DocumentTransitionAction::TransferAction(action), - )) = document_transfer_action.data.as_mut() - { - let aggregates = read_property_constraint_aggregates( - drive, - &data_contract_fetch_info.contract, - document_transfer_transition.base().document_type_name(), - DocumentVersion { - properties: action.document().properties(), - owner_id: action.document().owner_id(), - }, - Some(DocumentVersion { - properties: original_document.properties(), - owner_id: original_document.owner_id(), - }), - Some(SystemChange::Transfer), - block_info, - execution_context, - transaction, - platform_version, - )?; - action.set_property_constraint_aggregates(aggregates); - } + // The `countOf` and `sumOf` totals the rules judging the write read + attach_property_constraint_aggregates( + drive, + &data_contract_fetch_info.contract, + &mut document_transfer_action, + Some(original_document), + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )?; Ok(document_transfer_action) } @@ -1141,32 +1096,18 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - // The `countOf` and `sumOf` totals of the rules the update's time can break, - // which the document's own count leaves as they are - if let Some(BatchedTransitionAction::DocumentAction( - DocumentTransitionAction::UpdatePriceAction(action), - )) = document_update_price_action.data.as_mut() - { - let aggregates = read_property_constraint_aggregates( - drive, - &data_contract_fetch_info.contract, - document_update_price_transition.base().document_type_name(), - DocumentVersion { - properties: action.document().properties(), - owner_id: action.document().owner_id(), - }, - Some(DocumentVersion { - properties: original_document.properties(), - owner_id: original_document.owner_id(), - }), - Some(SystemChange::PriceUpdate), - block_info, - execution_context, - transaction, - platform_version, - )?; - action.set_property_constraint_aggregates(aggregates); - } + // The `countOf` and `sumOf` totals the rules judging the write read + attach_property_constraint_aggregates( + drive, + &data_contract_fetch_info.contract, + &mut document_update_price_action, + Some(original_document), + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )?; Ok(document_update_price_action) } @@ -1271,32 +1212,18 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - // The `countOf` and `sumOf` totals of the rules the buyer can break, the document - // counted as the buyer's and no longer as the seller's - if let Some(BatchedTransitionAction::DocumentAction( - DocumentTransitionAction::PurchaseAction(action), - )) = document_purchase_action.data.as_mut() - { - let aggregates = read_property_constraint_aggregates( - drive, - &data_contract_fetch_info.contract, - document_purchase_transition.base().document_type_name(), - DocumentVersion { - properties: action.document().properties(), - owner_id: action.document().owner_id(), - }, - Some(DocumentVersion { - properties: original_document.properties(), - owner_id: original_document.owner_id(), - }), - Some(SystemChange::Transfer), - block_info, - execution_context, - transaction, - platform_version, - )?; - action.set_property_constraint_aggregates(aggregates); - } + // The `countOf` and `sumOf` totals the rules judging the write read + attach_property_constraint_aggregates( + drive, + &data_contract_fetch_info.contract, + &mut document_purchase_action, + Some(original_document), + owner_id, + block_info, + execution_context, + transaction, + platform_version, + )?; Ok(document_purchase_action) } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs index 106e35c6d6e..571b164211d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs @@ -9,16 +9,121 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; use dpp::data_contract::document_type::property_constraints::{AggregateRead, SystemChange}; use dpp::data_contract::DataContract; +use dpp::document::{Document, DocumentV0Getters}; use dpp::platform_value::{Identifier, Value}; +use dpp::prelude::ConsensusValidationResult; use dpp::version::PlatformVersion; use drive::drive::Drive; use drive::grovedb::TransactionArg; +use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentCreateTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_purchase_transition_action::DocumentPurchaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::DocumentReplaceTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_transfer_transition_action::DocumentTransferTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_update_price_transition_action::DocumentUpdatePriceTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; +use drive::state_transition_action::batch::batched_transition::BatchedTransitionAction; use std::collections::{BTreeMap, BTreeSet}; /// A document version a write stores or replaces: its properties and its owner. -pub(super) struct DocumentVersion<'a> { - pub properties: &'a BTreeMap, - pub owner_id: Identifier, +#[derive(Clone, Copy)] +struct DocumentVersion<'a> { + properties: &'a BTreeMap, + owner_id: Identifier, +} + +impl<'a> DocumentVersion<'a> { + /// `document` as it stands, with its own owner. + fn of(document: &'a Document) -> Self { + DocumentVersion { + properties: document.properties(), + owner_id: document.owner_id(), + } + } +} + +/// Reads the `countOf` and `sumOf` totals the rules judging the write in `result` +/// read ([`read_property_constraint_aggregates`]) and hands them to its action: a +/// create or a replace of a document of `writer`'s, stored as `stored` before +/// (`None` for a create), or a transfer, a purchase or a price update of `stored`, +/// judged by the rules the change can break. Any other action, a refused write +/// included, reads nothing. +#[allow(clippy::too_many_arguments)] +pub(super) fn attach_property_constraint_aggregates( + drive: &Drive, + contract: &DataContract, + result: &mut ConsensusValidationResult, + stored: Option<&Document>, + writer: Identifier, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result<(), Error> { + let Some(BatchedTransitionAction::DocumentAction(action)) = result.data.as_mut() else { + return Ok(()); + }; + let stored = stored.map(DocumentVersion::of); + let mut read = |document_type_name: &str, written: DocumentVersion, change| { + read_property_constraint_aggregates( + drive, + contract, + document_type_name, + written, + stored, + change, + block_info, + execution_context, + transaction, + platform_version, + ) + }; + match action { + DocumentTransitionAction::CreateAction(action) => { + let written = DocumentVersion { + properties: action.data(), + owner_id: writer, + }; + let aggregates = read(action.base().document_type_name(), written, None)?; + action.set_property_constraint_aggregates(aggregates); + } + DocumentTransitionAction::ReplaceAction(action) => { + let written = DocumentVersion { + properties: action.data(), + owner_id: writer, + }; + let aggregates = read(action.base().document_type_name(), written, None)?; + action.set_property_constraint_aggregates(aggregates); + } + // The action's document carries its new owner + DocumentTransitionAction::TransferAction(action) => { + let aggregates = read( + action.base().document_type_name(), + DocumentVersion::of(action.document()), + Some(SystemChange::Transfer), + )?; + action.set_property_constraint_aggregates(aggregates); + } + DocumentTransitionAction::PurchaseAction(action) => { + let aggregates = read( + action.base().document_type_name(), + DocumentVersion::of(action.document()), + Some(SystemChange::Transfer), + )?; + action.set_property_constraint_aggregates(aggregates); + } + DocumentTransitionAction::UpdatePriceAction(action) => { + let aggregates = read( + action.base().document_type_name(), + DocumentVersion::of(action.document()), + Some(SystemChange::PriceUpdate), + )?; + action.set_property_constraint_aggregates(aggregates); + } + DocumentTransitionAction::DeleteAction(_) + | DocumentTransitionAction::IndexOnlyDeleteAction(_) => {} + } + Ok(()) } /// Reads from state the `countOf` and `sumOf` totals the `propertyConstraints` rules of @@ -41,7 +146,7 @@ pub(super) struct DocumentVersion<'a> { /// /// [`DocumentSystemValues::aggregates`]: dpp::data_contract::document_type::property_constraints::DocumentSystemValues::aggregates #[allow(clippy::too_many_arguments)] -pub(super) fn read_property_constraint_aggregates( +fn read_property_constraint_aggregates( drive: &Drive, contract: &DataContract, document_type_name: &str, 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 22be76822a6..0567a7c2f6f 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -65,9 +65,9 @@ pub struct SystemLimits { pub max_property_constraint_nodes: u16, /// Maximum number of distinct `countOf` and `sumOf` totals the `propertyConstraints` /// rules of one document type read. Each is a billed read of a count or sum tree on - /// every create or replace of a document of the type (and on a transfer or purchase - /// when it depends on the owner), so this bounds the state one document write reads for - /// its rules. A total two rules read alike counts once. Refused under full validation + /// every create or replace of a document of the type, and on a transfer, a purchase or + /// a price update judged against a rule reading it, so this bounds the state one + /// document write reads for its rules. A total two rules read alike counts once. Refused under full validation /// only, like `max_property_constraints`. Read by document type parser generation 3 /// (protocol version 14) and never reached before. pub max_property_constraint_aggregates: u16, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index a4722dfb0c7..2e86b04dc0c 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1091,8 +1091,10 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// the batch transformer reads them into the action /// (`Drive::fetch_property_constraint_aggregate`, billed; in place in /// transformer 0, reading nothing before this version), a transfer or -/// purchase reads again those depending on the owner, and a rule reading -/// one it is not given (an SDK pre-check) is not judged. +/// purchase reads again those depending on the owner, a price update those +/// of the rules it judges, and a rule reading one it is not given is not +/// judged by an SDK pre-check and an error in consensus, which reads them +/// all. /// Arithmetic is exact `i128`: `divide` and `modulo` are Euclidean (the /// remainder is never negative), and an overflow, a zero divisor, a /// negative exponent or a value that is not an integer refuses the document @@ -1129,7 +1131,8 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// condition twice, and at most `max_property_constraint_aggregates` (4) /// distinct totals per type; once every type is parsed, that a tree keeps /// each total (`documentsCountable` or `documentsSummable`, or an index -/// whose properties are exactly the filter's keys), in +/// whose properties are exactly the filter's keys) and that no type with a +/// contested index totals its own documents, in /// `create_document_types_from_document_schemas` 1, in place and inert /// before this version. `DataContract::validate_document_properties` 0 /// (extended in place, inert before this version, and taking the document's From 779949754f760913cd096eff3558689d93d9e302 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 18:36:54 +0700 Subject: [PATCH 081/113] feat(sdk)!: countOf and sumOf totals in the rule descriptors of the JS, Swift and Kotlin SDKs (#5121) Co-authored-by: Claude Opus 5.5 --- packages/js-evo-sdk/README.md | 4 +- .../ui/contracts/DocumentTypeDetailsScreen.kt | 16 +- .../ui/contracts/PropertyConstraints.kt | 33 +++ .../ui/contracts/PropertyConstraintsTest.kt | 56 +++++ .../dashsdk/ffi/QueriesNative.kt | 11 +- .../queries/DocumentPropertyConstraints.kt | 101 +++++++- .../dashsdk/queries/PlatformQueries.kt | 11 +- .../DocumentPropertyConstraintsTest.kt | 153 ++++++++++++ .../src/data_contract/property_constraints.rs | 173 +++++++++++-- .../Utils/DocumentPropertyConstraints.swift | 158 +++++++++++- .../Models/PersistentDocumentType.swift | 5 +- .../Views/DocumentTypeDetailsView.swift | 43 +++- .../SwiftExampleApp/Views/DocumentsView.swift | 3 +- .../DocumentPropertyConstraintsTests.swift | 234 ++++++++++++++++++ .../document_type_property_constraints.rs | 51 +++- packages/wasm-dpp2/src/data_contract/model.rs | 3 +- .../unit/DocumentPropertyConstraints.spec.ts | 28 ++- 17 files changed, 1028 insertions(+), 55 deletions(-) diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index b54bb1e153f..76707484d0f 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -441,13 +441,13 @@ try { } ``` -To find a broken rule before paying for a refused transition, a contract lists a document type's rules and checks a document against them with the code consensus runs. The check covers the rules alone, not the JSON schema. It reads the document's owner for `$ownerId`, and the device clock for the times the write will record (`readsSystem` lists the ones a rule reads); a rule reading a block height is not checked, since the height is unknown until the block, and neither is a rule reading a `countOf` or `sumOf` total, which only the platform reads from state: +To find a broken rule before paying for a refused transition, a contract lists a document type's rules and checks a document against them with the code consensus runs. The check covers the rules alone, not the JSON schema. It reads the document's owner for `$ownerId`, and the device clock for the times the write will record (`readsSystem` lists the ones a rule reads); a rule reading a block height is not checked, since the height is unknown until the block, and neither is a rule reading a `countOf` or `sumOf` total, which only the platform reads from state (`readsTotals` lists the ones a rule reads, each with its `kind`, `documentType`, the summed `property` of a `sumOf` and the `filter` keys): ```ts contract.documentTypePropertyConstraints('offer'); // [{ name: 'discountBelowPrice', rule: { lessThan: ['discount', 'price'] }, // reads: [{ path: 'discount', kind: 'value' }, { path: 'price', kind: 'value' }], -// readsOwner: false }, ...] +// readsOwner: false, readsSystem: [], readsTotals: [] }, ...] const broken = contract.checkDocumentPropertyConstraints(document); if (broken) { diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt index 9c150513ca4..e76d0dc928d 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt @@ -404,7 +404,7 @@ private fun PropertyConstraintsFormSection(section: PropertyConstraintsSection) /** * One `propertyConstraints` rule: its name, the rule as declared, what it - * reads, the system times and heights it reads, and whether an owner change + * reads, the system times and heights and the totals it reads, and whether an owner change * is judged against it too * (← `PropertyConstraintRowView` in DocumentTypeDetailsView.swift). */ @@ -413,6 +413,7 @@ private fun PropertyConstraintRow(rule: DocumentPropertyConstraint) { val prettyRule = remember(rule) { rule.prettyRuleJson } val reads = remember(rule) { propertyConstraintReadsText(rule) } val systemReads = remember(rule) { propertyConstraintSystemReadsText(rule) } + val totals = remember(rule) { propertyConstraintTotalsText(rule) } Column( modifier = Modifier .fillMaxWidth() @@ -461,6 +462,19 @@ private fun PropertyConstraintRow(rule: DocumentPropertyConstraint) { color = MaterialTheme.colorScheme.tertiary, ) } + if (totals != null) { + Text( + totals, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.testTag("documentType.propertyConstraint.${rule.name}.readsTotals"), + ) + Text( + PROPERTY_CONSTRAINT_TOTALS_NOTE, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } if (rule.readsOwner) { Text( "Reads \$ownerId, the document's owner: transfers and purchases are judged " + diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt index 47da8df01a7..afca1f27267 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt @@ -3,6 +3,7 @@ package org.dashfoundation.example.ui.contracts import kotlinx.coroutines.CancellationException import kotlinx.serialization.json.JsonObject import org.dashfoundation.dashsdk.queries.DocumentPropertyConstraint +import org.dashfoundation.dashsdk.queries.PropertyConstraintTotalRead import org.dashfoundation.dashsdk.queries.PropertyConstraintViolation // Screen-side glue for a document type's `propertyConstraints` rules @@ -149,6 +150,38 @@ internal const val PROPERTY_CONSTRAINT_SYSTEM_READS_NOTE = "A price update is judged against the rules reading \$updatedAt or its block heights, " + "and a transfer or purchase against those reading \$transferredAt or its block heights." +/** + * The line naming the `countOf` and `sumOf` totals [rule] reads, each once: + * `Reads totals: countOf listing by $ownerId; sumOf price of listing by category`. + * `null` for a rule reading none. The SwiftExampleApp builds the same text. + */ +internal fun propertyConstraintTotalsText(rule: DocumentPropertyConstraint): String? = + rule.readsTotals + .map(::totalText) + .distinct() + .takeIf { it.isNotEmpty() } + ?.joinToString("; ", prefix = "Reads totals: ") + +/** + * One total as [propertyConstraintTotalsText] names it: its kind, the property + * it sums (a `sumOf` only), its type, and the keys it filters by. + */ +private fun totalText(total: PropertyConstraintTotalRead): String = buildString { + append(total.kind.name) + total.property?.let { append(" $it of") } + append(" ${total.documentType}") + if (total.filter.isNotEmpty()) append(" by ${total.filter.joinToString(", ")}") +} + +/** + * The note under a rule reading a total ([propertyConstraintTotalsText] not + * `null`): the check before sending reads no state, so it cannot catch the + * rule. The same text as the SwiftExampleApp's, for cross-platform UAT. + */ +internal const val PROPERTY_CONSTRAINT_TOTALS_NOTE = + "The platform reads these totals when the document is sent; the check before sending " + + "does not, so it cannot catch this rule." + /** Title of the alert a broken rule raises instead of a broadcast. */ internal const val PROPERTY_CONSTRAINT_BROKEN_TITLE = "Not sent: a property constraint is broken" diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt index 98f7b410e5f..192178a948d 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt @@ -8,6 +8,7 @@ import kotlinx.serialization.json.putJsonObject import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.queries.DocumentPropertyConstraint import org.dashfoundation.dashsdk.queries.PropertyConstraintRead +import org.dashfoundation.dashsdk.queries.PropertyConstraintTotalRead import org.dashfoundation.dashsdk.queries.PropertyConstraintViolation import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals @@ -248,6 +249,61 @@ class PropertyConstraintsTest { assertNull(propertyConstraintSystemReadsText(rule)) } + /** A rule reading a count by owner twice, a whole-type count and a sum by category. */ + private val totalledRule = DocumentPropertyConstraint( + name = "withinLimits", + ruleJson = "{}", + reads = emptyList(), + readsOwner = true, + readsTotals = listOf( + PropertyConstraintTotalRead( + PropertyConstraintTotalRead.Kind.CountOf, + "listing", + null, + listOf("${'$'}ownerId"), + ), + PropertyConstraintTotalRead( + PropertyConstraintTotalRead.Kind.SumOf, + "listing", + "price", + listOf("category", "status"), + ), + PropertyConstraintTotalRead( + PropertyConstraintTotalRead.Kind.CountOf, + "listing", + null, + listOf("${'$'}ownerId"), + ), + PropertyConstraintTotalRead( + PropertyConstraintTotalRead.Kind.CountOf, + "listing", + null, + emptyList(), + ), + ), + ) + + /** Each total once, its filter keys after "by"; the SwiftExampleApp builds the same text. */ + @Test + fun `should list each total a rule reads once`() { + assertEquals( + "Reads totals: countOf listing by ${'$'}ownerId; sumOf price of listing by " + + "category, status; countOf listing", + propertyConstraintTotalsText(totalledRule), + ) + assertNull(propertyConstraintTotalsText(timedRule)) + } + + /** The note under a rule reading a total; the SwiftExampleApp shows the same text. */ + @Test + fun `should say the check before sending cannot catch a rule reading a total`() { + assertEquals( + "The platform reads these totals when the document is sent; the check before " + + "sending does not, so it cannot catch this rule.", + PROPERTY_CONSTRAINT_TOTALS_NOTE, + ) + } + /** The note under a rule reading a system value; the SwiftExampleApp shows the same text. */ @Test fun `should say which writes the update and transfer times answer to`() { diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt index e2985ed4916..bf961597ad8 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt @@ -75,10 +75,12 @@ internal object QueriesNative { /** * The `propertyConstraints` rules (protocol version 14) of [documentType] * as a JSON array in name order, each - * `{"name", "rule", "reads": [{"path", "kind"}], "readsOwner", "readsSystem"}`: + * `{"name", "rule", "reads": [{"path", "kind"}], "readsOwner", "readsSystem", "readsTotals"}`: * `kind` is `value`, `presence`, `text`, `identifier`, `length`, `count` - * or `elements`, and `readsSystem` names the system times and heights the - * rule reads (`$createdAt`, `$updatedAtBlockHeight`, ...). + * or `elements`, `readsSystem` names the system times and heights the + * rule reads (`$createdAt`, `$updatedAtBlockHeight`, ...), and + * `readsTotals` lists its `countOf` and `sumOf` totals as + * `{"kind", "documentType", "property" (a sumOf only), "filter"}`. * [serializedContract] is the contract's platform serialization (what * [dataContractFetchWithSerialization] returns), read by Rust at the SDK's * protocol version; no network call. Throws on error (unknown document @@ -98,7 +100,8 @@ internal object QueriesNative { * for [dataContractGetPropertyConstraints]. The device clock stands in for * the block time the create records (`$createdAt`, `$updatedAt`, * `$transferredAt`), and a rule reading a block height is not judged, the - * height being unknown until the block. No network call. Throws on + * height being unknown until the block, nor is one reading a `countOf` or + * `sumOf` total, which only the platform reads from state. No network call. Throws on * error (as above, plus an owner id that is not 32 bytes or properties * that are not a JSON object). */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt index 7ed8f7db694..38a868337a3 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt @@ -51,9 +51,9 @@ data class DocumentPropertyConstraint( */ val reads: List, /** - * Whether the rule compares the document's owner, `$ownerId`: then a - * transfer or a purchase, which changes the owner, is judged against it - * too. + * Whether the rule compares the document's owner, `$ownerId`, or reads a + * total that depends on it ([readsTotals]): then a transfer or a purchase, + * which changes the owner, is judged against it too. */ val readsOwner: Boolean, /** @@ -75,6 +75,18 @@ data class DocumentPropertyConstraint( * library predates the field. */ val readsSystem: List = emptyList(), + /** + * The `countOf` and `sumOf` totals the rule reads, in declared order, one + * read twice listed twice: how many documents of a type of the same + * contract match a filter, or the total of their integer property. The + * platform reads them from state when the document is sent; the pre-check + * ([Contracts.checkPropertyConstraints]) reads no state and does not judge + * a rule reading one. + * + * Empty for a rule reading none, and for every rule when the native + * library predates the field. + */ + val readsTotals: List = emptyList(), ) { /** [ruleJson] indented for display, or [ruleJson] itself should it not parse back. */ val prettyRuleJson: String @@ -105,8 +117,9 @@ data class DocumentPropertyConstraint( val reads = rule?.get("reads") as? JsonArray val readsOwner = rule?.get("readsOwner")?.jsonBooleanOrNull() val readsSystem = rule?.let(::readsSystemOf) + val readsTotals = rule?.let(::readsTotalsOf) if (name == null || declaration == null || reads == null || readsOwner == null || - readsSystem == null + readsSystem == null || readsTotals == null ) { throw DashSdkError.SerializationError("Malformed propertyConstraints rule: $entry") } @@ -116,6 +129,7 @@ data class DocumentPropertyConstraint( reads = reads.map(PropertyConstraintRead::fromJson), readsOwner = readsOwner, readsSystem = readsSystem, + readsTotals = readsTotals, ) } } @@ -129,6 +143,80 @@ data class DocumentPropertyConstraint( val names = value as? JsonArray ?: return null return names.map { it.jsonStringOrNull() ?: return null } } + + /** + * The totals [rule]'s `readsTotals` lists: empty when the key is + * missing, `null` when it or an entry is malformed. + */ + private fun readsTotalsOf(rule: JsonObject): List? { + val value = rule["readsTotals"] ?: return emptyList() + val entries = value as? JsonArray ?: return null + return entries.map { PropertyConstraintTotalRead.fromJsonOrNull(it) ?: return null } + } + } +} + +/** + * A `countOf` or `sumOf` total a `propertyConstraints` rule reads: how many + * documents of [documentType], a type of the same contract, match the filter, + * or the total of their integer [property] (a `sumOf` only). The fields mirror + * wasm-dpp2's `PropertyConstraintTotalRead` and the Swift SDK's. + */ +data class PropertyConstraintTotalRead( + val kind: Kind, + /** The document type the total is over. */ + val documentType: String, + /** The summed integer property of a `sumOf`; `null` for a `countOf`. */ + val property: String?, + /** + * The keys the documents are matched by, properties of [documentType] or + * `$ownerId`, in the order Rust gives; empty for a total over every + * document of the type. The values they must take are in the rule. + */ + val filter: List, +) { + /** What the total counts; the names are the operators'. */ + sealed interface Kind { + /** The kind's name, as Rust reports it. */ + val name: String + + /** `countOf`: how many documents match. */ + data object CountOf : Kind { + override val name: String get() = "countOf" + } + + /** `sumOf`: the total of an integer property over them. */ + data object SumOf : Kind { + override val name: String get() = "sumOf" + } + + /** A kind this build does not know, by its name: one a later native library reports. */ + data class Other(override val name: String) : Kind + + companion object { + /** The kind named [name], or [Other] for a name this build does not know. */ + fun fromName(name: String): Kind = when (name) { + CountOf.name -> CountOf + SumOf.name -> SumOf + else -> Other(name) + } + } + } + + internal companion object { + /** The total [entry] describes, or `null` when it is malformed. */ + fun fromJsonOrNull(entry: JsonElement): PropertyConstraintTotalRead? { + val total = entry as? JsonObject ?: return null + val kind = total["kind"]?.jsonStringOrNull() ?: return null + val documentType = total["documentType"]?.jsonStringOrNull() ?: return null + val property = when (val value = total["property"]) { + null -> null + else -> value.jsonStringOrNull() ?: return null + } + val keys = total["filter"] as? JsonArray ?: return null + val filter = keys.map { it.jsonStringOrNull() ?: return null } + return PropertyConstraintTotalRead(Kind.fromName(kind), documentType, property, filter) + } } } @@ -221,8 +309,9 @@ data class PropertyConstraintRead( * * Rust judges the document (`dash_sdk_data_contract_check_property_constraints`, * through [Contracts.checkPropertyConstraints]) with the check consensus runs, - * the device clock standing in for the times the create records and a rule - * reading a block height left unjudged; this type only carries the verdict. + * the device clock standing in for the times the create records, and a rule + * reading a block height or a `countOf` or `sumOf` total left unjudged; this + * type only carries the verdict. * The fields mirror wasm-dpp2's `DocumentPropertyConstraintViolation` and the * Swift SDK's `PropertyConstraintViolation`. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt index 530dd266ab2..dac3171d26e 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt @@ -668,8 +668,9 @@ class Contracts internal constructor(private val sdk: Sdk) { * * Each rule lists the properties it reads and how * ([DocumentPropertyConstraint.reads]), whether it reads the owner - * ([DocumentPropertyConstraint.readsOwner]) and the system times and - * heights it reads ([DocumentPropertyConstraint.readsSystem]). + * ([DocumentPropertyConstraint.readsOwner]), the system times and + * heights it reads ([DocumentPropertyConstraint.readsSystem]) and the + * `countOf` and `sumOf` totals it reads ([DocumentPropertyConstraint.readsTotals]). * * [serializedContract] is the contract's platform serialization, the bytes * kept beside a fetched contract ([ContractWithSerialization.binarySerialization], @@ -708,8 +709,10 @@ class Contracts internal constructor(private val sdk: Sdk) { * the block time the create records (`$createdAt`, `$updatedAt`, * `$transferredAt`), so a rule comparing one is judged as of now; a rule * reading a block height (`$createdAtBlockHeight`, ...) is not judged, - * since the height is unknown until the block, and consensus may still - * refuse the document for it. [serializedContract] is as for + * since the height is unknown until the block, nor is a rule reading a + * `countOf` or `sumOf` total ([DocumentPropertyConstraint.readsTotals]), + * which the platform reads from state when the document is sent; consensus + * may still refuse the document for either. [serializedContract] is as for * [propertyConstraints]; no network call. * * @throws DashSdkError.InvalidParameter for empty contract bytes, an owner diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt index afe65362c8d..d5354a0ec7e 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt @@ -404,6 +404,159 @@ class DocumentPropertyConstraintsTest { } } + /** + * The totals rules read, as rs-sdk-ffi's + * `should_list_the_totals_a_rule_reads_and_leave_it_unjudged` reports them: + * a whole-type count, a count by owner, a sum by category, and none. + */ + private val totalRulesJson = """ + [ + { + "name": "allListings", + "readsOwner": false, + "reads": [], + "readsSystem": [], + "readsTotals": [{ "kind": "countOf", "documentType": "listing", "filter": [] }], + "rule": { "lessThan": [{ "countOf": ["listing"] }, 1000] } + }, + { + "name": "atMostTwoPerOwner", + "readsOwner": true, + "reads": [], + "readsSystem": [], + "readsTotals": [ + { "kind": "countOf", "documentType": "listing", "filter": ["${'$'}ownerId"] } + ], + "rule": { + "lessThanOrEqual": [{ "countOf": ["listing", { "${'$'}ownerId": "${'$'}ownerId" }] }, 2] + } + }, + { + "name": "categoryBudget", + "readsOwner": false, + "reads": [{ "kind": "value", "path": "category" }], + "readsSystem": [], + "readsTotals": [ + { + "kind": "sumOf", + "documentType": "listing", + "property": "price", + "filter": ["category"] + } + ], + "rule": { + "lessThanOrEqual": [{ "sumOf": ["listing", "price", { "category": "category" }] }, 250] + } + }, + { + "name": "priceCap", + "readsOwner": false, + "reads": [{ "kind": "value", "path": "price" }], + "readsSystem": [], + "readsTotals": [], + "rule": { "lessThanOrEqual": ["price", 1000] } + } + ] + """.trimIndent() + + // Totals + + @Test + fun `should decode the totals each rule reads`() { + val rules = DocumentPropertyConstraint.listFromJson(totalRulesJson) + + assertEquals( + listOf("allListings", "atMostTwoPerOwner", "categoryBudget", "priceCap"), + rules.map { it.name }, + ) + assertEquals( + listOf( + listOf( + PropertyConstraintTotalRead( + PropertyConstraintTotalRead.Kind.CountOf, + "listing", + null, + emptyList(), + ), + ), + listOf( + PropertyConstraintTotalRead( + PropertyConstraintTotalRead.Kind.CountOf, + "listing", + null, + listOf("${'$'}ownerId"), + ), + ), + listOf( + PropertyConstraintTotalRead( + PropertyConstraintTotalRead.Kind.SumOf, + "listing", + "price", + listOf("category"), + ), + ), + emptyList(), + ), + rules.map { it.readsTotals }, + ) + assertEquals(listOf(false, true, false, false), rules.map { it.readsOwner }) + assertEquals( + listOf(PropertyConstraintRead("category", PropertyConstraintRead.Kind.Value)), + rules[2].reads, + ) + } + + /** A native library built before `readsTotals` leaves the key out. */ + @Test + fun `should read a rule without readsTotals as reading no total`() { + val rules = DocumentPropertyConstraint.listFromJson( + """[{"name":"r","readsOwner":false,"reads":[],"readsSystem":[],"rule":{"present":"a"}}]""", + ) + + assertEquals(emptyList(), rules.single().readsTotals) + assertEquals( + DocumentPropertyConstraint("r", """{"present":"a"}""", emptyList(), readsOwner = false), + rules.single(), + ) + } + + @Test + fun `should round trip every total kind name and keep an unknown one`() { + for (kind in listOf(PropertyConstraintTotalRead.Kind.CountOf, PropertyConstraintTotalRead.Kind.SumOf)) { + assertEquals(kind, PropertyConstraintTotalRead.Kind.fromName(kind.name)) + } + assertEquals( + PropertyConstraintTotalRead.Kind.Other("averageOf"), + PropertyConstraintTotalRead.Kind.fromName("averageOf"), + ) + } + + @Test + fun `should refuse a malformed readsTotals`() { + val malformed = listOf( + "null", + "{}", + "[1]", + "[null]", + // Every total names its kind, type and filter + """[{"documentType":"listing","filter":[]}]""", + """[{"kind":"countOf","filter":[]}]""", + """[{"kind":"countOf","documentType":"listing"}]""", + // The filter lists strings, and a property is one + """[{"kind":"countOf","documentType":"listing","filter":"${'$'}ownerId"}]""", + """[{"kind":"countOf","documentType":"listing","filter":[7]}]""", + """[{"kind":"sumOf","documentType":"listing","property":7,"filter":[]}]""", + """[{"kind":7,"documentType":"listing","filter":[]}]""", + ) + for (readsTotals in malformed) { + val json = + """[{"name":"r","readsOwner":false,"reads":[],"readsSystem":[],"readsTotals":$readsTotals,"rule":{"present":"a"}}]""" + assertThrows(json, DashSdkError.SerializationError::class.java) { + DocumentPropertyConstraint.listFromJson(json) + } + } + } + // Violations @Test diff --git a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs index 34e8717b828..839edde2cb3 100644 --- a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs +++ b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs @@ -41,7 +41,7 @@ use dash_sdk::dpp::data_contract::document_type::methods::{ DocumentTypeBasicMethods, DocumentTypeV0Methods, }; use dash_sdk::dpp::data_contract::document_type::property_constraints::{ - DocumentSystemValues, PropertyRead, + AggregateKind, AggregateRead, DocumentSystemValues, PropertyRead, }; use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; use dash_sdk::dpp::document::{Document, DocumentV0Getters}; @@ -63,17 +63,21 @@ const PROPERTY_CONSTRAINTS_KEYWORD: &str = "propertyConstraints"; /// Get the `propertyConstraints` rules of a document type as a JSON array /// /// Each element is -/// `{ "name": string, "rule": object, "reads": [{ "path": string, "kind": string }], "readsOwner": bool, "readsSystem": [string] }`: +/// `{ "name": string, "rule": object, "reads": [{ "path": string, "kind": string }], "readsOwner": bool, "readsSystem": [string], "readsTotals": [{ "kind": string, "documentType": string, "property"?: string, "filter": [string] }] }`: /// the rule's name (its key in `propertyConstraints`), the rule exactly as the /// document type's schema declares it, every property it reads in declared /// order (`kind` is `"value"` for an integer operand, `"presence"` for /// `present` / `absent`, `"text"` for a string comparison, `"identifier"` for /// an identifier comparison, `"length"` for a `length` or `byteLength` operand, /// `"count"` for a `count` operand and `"elements"` for the array a `contains` -/// looks in; `$ownerId` is no property and is not listed), whether it reads `$ownerId`, which makes a transfer or a -/// purchase answer to it too, and the system times and heights it reads -/// (`"$createdAt"`, ...), which make a price update answer to a rule reading -/// the update's and a transfer or purchase one reading the transfer's. Rules +/// looks in; `$ownerId` is no property and is not listed), whether it reads `$ownerId` (or a +/// total that depends on the owner), which makes a transfer or a purchase answer to it too, +/// the system times and heights it reads (`"$createdAt"`, ...), which make a price update +/// answer to a rule reading the update's and a transfer or purchase one reading the +/// transfer's, and the `countOf` and `sumOf` totals it reads in declared order (`kind` +/// `"countOf"` or `"sumOf"`, the type of the contract it totals, the summed `property` of +/// a `sumOf` only, and the keys its `filter` matches documents by; a total the platform +/// reads from state when the document is sent, which the pre-check does not). Rules /// are listed in name order, the order /// consensus checks them in. A document type declaring none gives `[]`, and so /// does every document type when the SDK's protocol version is below 14. @@ -139,7 +143,9 @@ pub unsafe extern "C" fn dash_sdk_data_contract_get_property_constraints( /// every rule, in name order, evaluated by `PropertyConstraint::violation`. /// The device clock stands in for the block time the create records /// (`$createdAt`, `$updatedAt`, `$transferredAt`), and a rule reading a block -/// height is not judged, since the height is unknown until the block. +/// height is not judged, since the height is unknown until the block, and +/// neither is a rule reading a `countOf` or `sumOf` total (`"readsTotals"`), +/// which the platform reads from state when the document is sent. /// Nothing but the rules is checked: not the JSON schema, not the state. /// /// The result is the first rule broken, as @@ -326,11 +332,31 @@ fn property_constraints_json( .into_iter() .map(|property| property.name()) .collect::>(), + "readsTotals": constraint + .aggregate_reads() + .into_iter() + .map(total_read_json) + .collect::>(), })); } Ok(serde_json::Value::Array(rules)) } +/// A `countOf` or `sumOf` a rule reads, as the descriptor lists it: its operator, +/// the type it totals, the summed property of a `sumOf`, and the keys of its +/// filter. +fn total_read_json(read: &AggregateRead) -> serde_json::Value { + let mut entry = json!({ + "kind": read.wire_name(), + "documentType": read.document_type, + "filter": read.filter.keys().collect::>(), + }); + if let AggregateKind::Sum { property } = &read.kind { + entry["property"] = json!(property); + } + entry +} + /// The system values a create of a document owned by `owner_id` will have, as /// far as a client can tell before its block: the device clock stands in for /// the block time it records as its creation, update and transfer, and the @@ -630,7 +656,8 @@ mod tests { { "path": "closedAt", "kind": "presence" } ], "readsOwner": false, - "readsSystem": [] + "readsSystem": [], + "readsTotals": [] }, { "name": "discountBelowPrice", @@ -640,7 +667,8 @@ mod tests { { "path": "price", "kind": "value" } ], "readsOwner": false, - "readsSystem": [] + "readsSystem": [], + "readsTotals": [] }, { "name": "perUnitFee", @@ -650,7 +678,8 @@ mod tests { { "path": "fee", "kind": "value" } ], "readsOwner": false, - "readsSystem": [] + "readsSystem": [], + "readsTotals": [] }, { "name": "sellerIsOwner", @@ -660,14 +689,16 @@ mod tests { { "path": "sellerId", "kind": "identifier" } ], "readsOwner": true, - "readsSystem": [] + "readsSystem": [], + "readsTotals": [] }, { "name": "tieredFee", "rule": declared["tieredFee"], "reads": [{ "path": "fee", "kind": "value" }], "readsOwner": false, - "readsSystem": [] + "readsSystem": [], + "readsTotals": [] } ]) ); @@ -1076,7 +1107,8 @@ mod tests { { "path": "price", "kind": "value" } ], "readsOwner": false, - "readsSystem": [] + "readsSystem": [], + "readsTotals": [] }, { "name": "openEndedSoldByOwner", @@ -1093,7 +1125,8 @@ mod tests { { "path": "endsAt", "kind": "value" } ], "readsOwner": true, - "readsSystem": ["$createdAt"] + "readsSystem": ["$createdAt"], + "readsTotals": [] } ]) ); @@ -1175,4 +1208,116 @@ mod tests { } assert_eq!(other.expect("checked"), serde_json::Value::Null); } + /// A `listing` type whose trees keep its count, each owner's count and each + /// category's total price, with three rules reading those totals and one, + /// `priceCap`, reading only the price. + fn totalled_contract_bytes() -> Vec { + let platform_version = PlatformVersion::latest(); + let documents = platform_value!({ + "listing": { + "type": "object", + "documentsCountable": true, + "properties": { + "price": { "type": "integer", "minimum": 0, "maximum": 1000000000, "position": 0 }, + "category": { "type": "integer", "minimum": 0, "maximum": 100, "position": 1 } + }, + "required": ["price", "category"], + "indices": [ + { + "name": "byOwner", + "properties": [{ "$ownerId": "asc" }], + "countable": "countable" + }, + { + "name": "byCategory", + "properties": [{ "category": "asc" }], + "summable": "price" + } + ], + "propertyConstraints": { + "allListings": { "lessThan": [{ "countOf": ["listing"] }, 1000] }, + "atMostTwoPerOwner": { + "lessThanOrEqual": [ + { "countOf": ["listing", { "$ownerId": "$ownerId" }] }, + 2 + ] + }, + "categoryBudget": { + "lessThanOrEqual": [ + { "sumOf": ["listing", "price", { "category": "category" }] }, + 250 + ] + }, + "priceCap": { "lessThanOrEqual": ["price", 1000] } + }, + "additionalProperties": false + } + }); + DataContractFactory::new(platform_version.protocol_version) + .expect("factory for the protocol version") + .create_with_value_config(Identifier::new(OWNER), 1, documents, None, None) + .expect("listing contract") + .data_contract() + .serialize_to_bytes_with_platform_version(platform_version) + .expect("serialized contract") + } + + /// The descriptor lists the `countOf` and `sumOf` totals each rule reads, + /// and the pre-check, which reads no state, leaves a rule reading one + /// unjudged while it still judges the others. + #[test] + fn should_list_the_totals_a_rule_reads_and_leave_it_unjudged() { + let sdk = sdk_handle(PlatformVersion::latest()); + let contract = totalled_contract_bytes(); + + let rules = rules_of(sdk, &contract, "listing"); + // 500 is past the category budget of 250 even alone, but the budget's + // total is read from state, which the pre-check does not do + let over_budget = check( + sdk, + &contract, + "listing", + json!({ "price": 500, "category": 1 }), + OWNER, + ); + let over_cap = check( + sdk, + &contract, + "listing", + json!({ "price": 2000, "category": 1 }), + OWNER, + ); + destroy_mock_sdk_handle(sdk); + + let rules = rules.expect("rules of listing"); + let totals = |index: usize| rules[index]["readsTotals"].clone(); + assert_eq!( + totals(0), + json!([{ "kind": "countOf", "documentType": "listing", "filter": [] }]) + ); + assert_eq!( + totals(1), + json!([{ "kind": "countOf", "documentType": "listing", "filter": ["$ownerId"] }]) + ); + assert_eq!(rules[1]["readsOwner"], json!(true)); + assert_eq!( + totals(2), + json!([{ + "kind": "sumOf", + "documentType": "listing", + "property": "price", + "filter": ["category"] + }]) + ); + assert_eq!( + rules[2]["reads"], + json!([{ "path": "category", "kind": "value" }]) + ); + assert_eq!(totals(3), json!([])); + + assert_eq!(over_budget.expect("checked"), serde_json::Value::Null); + let over_cap = over_cap.expect("checked"); + assert_eq!(over_cap["rule"], "priceCap"); + assert_eq!(over_cap["violation"], "NotMet"); + } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift index d4f34e6425f..3d43664a250 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift @@ -26,20 +26,27 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { /// (`"$createdAt"`), `{ "contains": [arrayPath, value] }`, /// `{ "startsWith": [a, b] }`, `{ "endsWith": [a, b] }`, /// `{ "notIn": [operand, [values]] }`, `{ "min": [a, b, ...] }`, - /// `{ "max": [a, b, ...] }`, `{ "abs": a }`, `{ "ifThen": [if, then] }` - /// and `{ "ifThenElse": [if, then, else] }`. + /// `{ "max": [a, b, ...] }`, `{ "abs": a }`, `{ "ifThen": [if, then] }`, + /// `{ "ifThenElse": [if, then, else] }`, and the totals + /// `{ "countOf": [documentType, filter] }` and + /// `{ "sumOf": [documentType, property, filter] }` (the filter optional). public let ruleJSON: String /// Every property the rule reads, in declared order, a property read twice /// listed twice. The list describes the rule, not one document: it covers /// every branch of an `ifThen` or `ifThenElse`, whichever one a document /// takes. `$ownerId` is no property and is not listed: see `readsOwner`; - /// nor are the system times and heights: see `readsSystem`. + /// nor are the system times and heights: see `readsSystem`. A property a + /// total's filter takes a value from is listed (`category` in + /// `{ "sumOf": ["listing", "price", { "category": "category" }] }`), the + /// total itself is not: see `readsTotals`. public let reads: [PropertyConstraintRead] - /// Whether the rule compares the document's owner, `$ownerId`, in any - /// branch, as in `reads`: then a transfer or a purchase, which changes - /// the owner, is judged against it too. + /// Whether the rule reads the document's owner, `$ownerId`, in any branch, + /// as in `reads`: by comparing it, or through a total that depends on it + /// (a `countOf` or `sumOf` filtered by `$ownerId`, see `readsTotals`). + /// Then a transfer or a purchase, which changes the owner, is judged + /// against it too. public let readsOwner: Bool /// The system times and heights the rule reads, by name, in declared @@ -55,18 +62,35 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { /// built before the field existed. public let readsSystem: [String] + /// The `countOf` and `sumOf` totals the rule reads, in declared order, one + /// read twice listed twice, every branch included as in `reads`: how many + /// documents of a type of the same contract match a filter, or the total + /// of an integer property over them, as the count and sum trees Drive + /// keeps hold them. + /// + /// The platform reads these totals from state when the document is sent. + /// The pre-check (`SDK.checkDocumentPropertyConstraints`) reads no state, + /// so it does not judge a rule reading one: such a rule can still refuse + /// a document the pre-check passes. + /// + /// Empty for a rule reading none, and for every rule reported by a library + /// built before the field existed. + public let readsTotals: [PropertyConstraintTotalRead] + public init( name: String, ruleJSON: String, reads: [PropertyConstraintRead], readsOwner: Bool, - readsSystem: [String] = [] + readsSystem: [String] = [], + readsTotals: [PropertyConstraintTotalRead] = [] ) { self.name = name self.ruleJSON = ruleJSON self.reads = reads self.readsOwner = readsOwner self.readsSystem = readsSystem + self.readsTotals = readsTotals } /// `ruleJSON` indented for display, or `ruleJSON` itself should it not @@ -96,7 +120,8 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { let declaration = rule["rule"], let reads = rule["reads"] as? [Any], let readsOwner = DocumentTypedArray.jsonBool(rule["readsOwner"]), - let readsSystem = systemReads(rule["readsSystem"]) + let readsSystem = systemReads(rule["readsSystem"]), + let readsTotals = totalReads(rule["readsTotals"]) else { throw SDKError.serializationError("Malformed propertyConstraints rule: \(entry)") } @@ -105,7 +130,8 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { ruleJSON: try PropertyConstraintJSON.compactText(declaration), reads: try reads.map(PropertyConstraintRead.init(jsonEntry:)), readsOwner: readsOwner, - readsSystem: readsSystem + readsSystem: readsSystem, + readsTotals: readsTotals ) } } @@ -129,6 +155,109 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { } return names } + + /// A rule's `readsTotals`, `[]` when the key is missing (a library built + /// before it), or `nil` for anything but an array of well-formed totals. + private static func totalReads(_ value: Any?) -> [PropertyConstraintTotalRead]? { + guard let value else { + return [] + } + guard let entries = value as? [Any] else { + return nil + } + var totals: [PropertyConstraintTotalRead] = [] + totals.reserveCapacity(entries.count) + for entry in entries { + guard let total = PropertyConstraintTotalRead(jsonEntry: entry) else { + return nil + } + totals.append(total) + } + return totals + } +} + +/// A `countOf` or `sumOf` total a `propertyConstraints` rule reads: how many +/// documents of `documentType`, a type of the same contract, match the +/// filter, or the total of their integer `property` (a `sumOf` only). The +/// fields mirror wasm-dpp2's `PropertyConstraintTotalRead` key for key. +public struct PropertyConstraintTotalRead: Equatable, Sendable { + /// Which total a rule reads; the names are the operators', as wasm-dpp2's + /// `PropertyConstraintTotalRead.kind` gives them. + public enum Kind: Hashable, Sendable { + /// `countOf`: how many documents match. + case countOf + /// `sumOf`: the total of an integer property over the documents that + /// match. + case sumOf + /// A total this build does not know, by its name. + case other(String) + + public init(name: String) { + switch name { + case "countOf": self = .countOf + case "sumOf": self = .sumOf + default: self = .other(name) + } + } + + /// The total's name, as Rust reports it. + public var name: String { + switch self { + case .countOf: return "countOf" + case .sumOf: return "sumOf" + case let .other(name): return name + } + } + } + + public let kind: Kind + /// The name of the type whose documents are counted or totalled. + public let documentType: String + /// The integer property of `documentType` a `sumOf` totals; `nil` for a + /// `countOf`. + public let property: String? + /// The keys the documents are matched by, in the order Rust gives them: + /// properties of `documentType`, or `$ownerId`. The values they must take + /// are in the rule (`ruleJSON`). Empty for a total over every document of + /// the type. + public let filter: [String] + + public init(kind: Kind, documentType: String, property: String? = nil, filter: [String]) { + self.kind = kind + self.documentType = documentType + self.property = property + self.filter = filter + } + + /// A `readsTotals` entry, or `nil` for one that is no object, lacks + /// `kind`, `documentType` or `filter`, or holds a value of the wrong type + /// (`property` included, when present). + init?(jsonEntry entry: Any) { + guard let total = entry as? [String: Any], + let kind = total["kind"] as? String, + let documentType = total["documentType"] as? String, + let keys = total["filter"] as? [Any] + else { + return nil + } + var property: String? + if let value = total["property"] { + guard let name = value as? String else { + return nil + } + property = name + } + var filter: [String] = [] + filter.reserveCapacity(keys.count) + for key in keys { + guard let key = key as? String else { + return nil + } + filter.append(key) + } + self.init(kind: Kind(name: kind), documentType: documentType, property: property, filter: filter) + } } /// A property a `propertyConstraints` rule reads, and how it reads it. @@ -338,10 +467,13 @@ extension SDK { /// system values: the device clock stands in for the block time the create /// records as `$createdAt`, `$updatedAt` and `$transferredAt`, and a rule /// reading a block height (a `readsSystem` name ending in `BlockHeight` - /// or `CoreBlockHeight`) is not judged at all. So `nil` does not promise - /// consensus accepts the document: a rule reading a block height, or a - /// time rule the device clock judges differently from the block time, can - /// still refuse it. + /// or `CoreBlockHeight`) is not judged at all. Nor is a rule reading a + /// `countOf` or `sumOf` total (its `readsTotals` not empty): the platform + /// reads the total from state when the document is sent, and this check + /// reads no state. So `nil` does not promise consensus accepts the + /// document: a rule reading a block height or a total, or a time rule the + /// device clock judges differently from the block time, can still refuse + /// it. /// /// - Throws: `SDKError.invalidParameter` for an owner id that is not 32 /// bytes or properties that are not a JSON object, `SDKError.notFound` for diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift index 01cf24268a0..e2a2ac2ae7e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift @@ -180,8 +180,9 @@ extension PersistentDocumentType { /// when it meets every rule judged: what /// `SDK.checkDocumentPropertyConstraints(serializedContract:documentType:propertiesJSON:ownerId:)` /// reports for the parent contract's stored platform serialization. As - /// there, the device clock stands in for the create's block time and a - /// rule reading a block height is not judged. + /// there, the device clock stands in for the create's block time, and a + /// rule reading a block height or a `countOf` or `sumOf` total is not + /// judged. /// /// - Throws: `SDKError.invalidState` when the parent contract has no /// stored serialization, or what the SDK call throws. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift index 55a453408f7..4212b0addcb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift @@ -451,8 +451,8 @@ struct ExpandableIndexRowView: View { } /// One `propertyConstraints` rule: its name, the rule as declared, what it -/// reads (properties, `$ownerId`, system times and heights), and whether an -/// owner change is judged against it too. +/// reads (properties, `$ownerId`, system times and heights, `countOf` and +/// `sumOf` totals), and whether an owner change is judged against it too. struct PropertyConstraintRowView: View { let rule: DocumentPropertyConstraint @@ -504,10 +504,21 @@ struct PropertyConstraintRowView: View { .font(.caption2) .foregroundColor(.secondary) } + + if !totalReadsText.isEmpty { + Label("Reads totals: \(totalReadsText)", systemImage: "sum") + .font(.caption2) + .foregroundColor(.indigo) + .accessibilityIdentifier("documentType.propertyConstraint.\(rule.name).readsTotals") + Text("The platform reads these totals when the document is sent; the check before sending does not, so it cannot catch this rule.") + .font(.caption2) + .foregroundColor(.secondary) + } } .padding(.vertical, 4) - // Keeps the row's identifier on the row and the readsSystem line's on - // that line, rather than the row's on every child + // Keeps the row's identifier on the row and the readsSystem and + // readsTotals lines' on those lines, rather than the row's on every + // child .accessibilityElement(children: .contain) .accessibilityIdentifier("documentType.propertyConstraint.\(rule.name)") } @@ -528,6 +539,30 @@ struct PropertyConstraintRowView: View { .filter { seen.insert($0).inserted } .joined(separator: ", ") } + + /// The `countOf` and `sumOf` totals the rule reads, repeats dropped, each + /// as `countOf ` or `sumOf of `, followed by + /// `by ` when it filters: `countOf listing by $ownerId; + /// sumOf price of listing by category`. The Android example app builds the + /// same text, for cross-platform UAT: keep the two identical. + private var totalReadsText: String { + var seen = Set() + return rule.readsTotals + .map { total in + // Only a `sumOf` names a property + var text = total.kind.name + if let property = total.property { + text += " \(property) of" + } + text += " \(total.documentType)" + if !total.filter.isEmpty { + text += " by \(total.filter.joined(separator: ", "))" + } + return text + } + .filter { seen.insert($0).inserted } + .joined(separator: "; ") + } } struct PropertyRowView: View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift index b28d5a0633e..4f907c5cdb4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift @@ -1835,7 +1835,8 @@ struct CreateDocumentView: View { /// The first propertyConstraints rule the document would break, or `nil` /// when it meets every rule judged or has none (the device clock stands in - /// for the block time, and a rule reading a block height is not judged). + /// for the block time, and a rule reading a block height or a `countOf` or + /// `sumOf` total is not judged). /// A check that cannot run (no SDK, no stored contract serialization) /// blocks nothing: consensus judges the document either way. private func propertyConstraintViolation( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift index 2dbb9914832..077ec13a436 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift @@ -140,6 +140,69 @@ final class DocumentPropertyConstraintsTests: XCTestCase { ] """ + /// Rules reading `countOf` and `sumOf` totals, as the FFI reports them: + /// the `listing` type of rs-sdk-ffi's + /// `should_list_the_totals_a_rule_reads_and_leave_it_unjudged`, whose + /// trees keep its count, each owner's count and each category's total + /// price, and a rule, `priceCap`, reading only the price. + private let totalsRulesJSON = """ + [ + { + "name": "allListings", + "rule": { "lessThan": [{ "countOf": ["listing"] }, 1000] }, + "reads": [], + "readsOwner": false, + "readsSystem": [], + "readsTotals": [ + { "kind": "countOf", "documentType": "listing", "filter": [] } + ] + }, + { + "name": "atMostTwoPerOwner", + "rule": { + "lessThanOrEqual": [ + { "countOf": ["listing", { "$ownerId": "$ownerId" }] }, + 2 + ] + }, + "reads": [], + "readsOwner": true, + "readsSystem": [], + "readsTotals": [ + { "kind": "countOf", "documentType": "listing", "filter": ["$ownerId"] } + ] + }, + { + "name": "categoryBudget", + "rule": { + "lessThanOrEqual": [ + { "sumOf": ["listing", "price", { "category": "category" }] }, + 250 + ] + }, + "reads": [{ "path": "category", "kind": "value" }], + "readsOwner": false, + "readsSystem": [], + "readsTotals": [ + { + "kind": "sumOf", + "documentType": "listing", + "property": "price", + "filter": ["category"] + } + ] + }, + { + "name": "priceCap", + "rule": { "lessThanOrEqual": ["price", 1000] }, + "reads": [{ "path": "price", "kind": "value" }], + "readsOwner": false, + "readsSystem": [], + "readsTotals": [] + } + ] + """ + /// The platform serialization of a contract created at protocol version /// 13, owned by `[7; 32]`, declaring one `note` type with a string /// `message` and no rules (generated by rs-sdk-ffi's @@ -419,6 +482,177 @@ final class DocumentPropertyConstraintsTests: XCTestCase { ) } + // MARK: - Total reads + + func testTotalReadsDecodeWithTheirTypeFilterAndProperty() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: totalsRulesJSON) + + XCTAssertEqual( + rules.map(\.name), + ["allListings", "atMostTwoPerOwner", "categoryBudget", "priceCap"] + ) + XCTAssertEqual( + rules.map(\.readsTotals), + [ + [PropertyConstraintTotalRead(kind: .countOf, documentType: "listing", filter: [])], + [PropertyConstraintTotalRead(kind: .countOf, documentType: "listing", filter: ["$ownerId"])], + [ + PropertyConstraintTotalRead( + kind: .sumOf, + documentType: "listing", + property: "price", + filter: ["category"] + ) + ], + [] + ] + ) + // A `countOf` sums nothing + XCTAssertNil(rules[0].readsTotals.first?.property) + XCTAssertNil(rules[1].readsTotals.first?.property) + // A total is no property of the document; the property its filter + // takes a value from is + XCTAssertEqual(rules[0].reads, []) + XCTAssertEqual(rules[1].reads, []) + XCTAssertEqual(rules[2].reads, [PropertyConstraintRead(path: "category", kind: .value)]) + XCTAssertEqual(rules[3].reads, [PropertyConstraintRead(path: "price", kind: .value)]) + // Counting the owner's listings depends on the owner + XCTAssertEqual(rules.map(\.readsOwner), [false, true, false, false]) + XCTAssertEqual(rules.map(\.readsSystem), [[], [], [], []]) + XCTAssertEqual( + rules[1].ruleJSON, + #"{"lessThanOrEqual":[{"countOf":["listing",{"$ownerId":"$ownerId"}]},2]}"# + ) + } + + /// Totals are kept in declared order, one read twice listed twice, and a + /// total added by a later protocol version is kept by name rather than + /// failing the whole list. + func testTotalReadsKeepRepeatsAndUnknownKinds() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: """ + [{ "name": "r", "readsOwner": false, "reads": [], "readsSystem": [], + "readsTotals": [ + { "kind": "sumOf", "documentType": "pledge", "property": "amount", "filter": [] }, + { "kind": "averageOf", "documentType": "pledge", "property": "amount", + "filter": ["campaignId", "$ownerId"] }, + { "kind": "sumOf", "documentType": "pledge", "property": "amount", "filter": [] } + ], + "rule": { "lessThan": [{ "sumOf": ["pledge", "amount"] }, 10] } }] + """) + + let sum = PropertyConstraintTotalRead( + kind: .sumOf, + documentType: "pledge", + property: "amount", + filter: [] + ) + XCTAssertEqual( + rules.first?.readsTotals, + [ + sum, + PropertyConstraintTotalRead( + kind: .other("averageOf"), + documentType: "pledge", + property: "amount", + filter: ["campaignId", "$ownerId"] + ), + sum + ] + ) + XCTAssertEqual(rules.first?.readsTotals[1].kind.name, "averageOf") + } + + func testEveryTotalKindNameRoundTrips() { + let names = ["countOf", "sumOf"] + let kinds: [PropertyConstraintTotalRead.Kind] = [.countOf, .sumOf] + XCTAssertEqual(names.map(PropertyConstraintTotalRead.Kind.init(name:)), kinds) + XCTAssertEqual(kinds.map(\.name), names) + XCTAssertEqual(PropertyConstraintTotalRead.Kind(name: "CountOf"), .other("CountOf")) + XCTAssertEqual(PropertyConstraintTotalRead.Kind(name: "averageOf").name, "averageOf") + } + + /// A library built before `readsTotals` leaves the key out. + func testMissingTotalReadsDecodeAsEmpty() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: """ + [{ "name": "r", "rule": { "present": "a" }, "readsOwner": false, + "readsSystem": [], "reads": [{ "path": "a", "kind": "presence" }] }] + """) + + XCTAssertEqual(rules.first?.readsTotals, []) + } + + func testMalformedTotalReadsAreRefused() { + let rule = #""name": "r", "rule": {"present": "a"}, "reads": [], "readsOwner": false"# + let count = #""kind": "countOf", "documentType": "listing""# + let malformed = [ + // Not an array + #"[{\#(rule), "readsTotals": "countOf"}]"#, + #"[{\#(rule), "readsTotals": {\#(count), "filter": []}}]"#, + // A null is no missing key + #"[{\#(rule), "readsTotals": null}]"#, + // An element that is not an object + #"[{\#(rule), "readsTotals": ["countOf"]}]"#, + #"[{\#(rule), "readsTotals": [[{\#(count), "filter": []}]]}]"#, + // A missing or mistyped kind + #"[{\#(rule), "readsTotals": [{"documentType": "listing", "filter": []}]}]"#, + #"[{\#(rule), "readsTotals": [{"kind": 1, "documentType": "listing", "filter": []}]}]"#, + // A missing or mistyped document type + #"[{\#(rule), "readsTotals": [{"kind": "countOf", "filter": []}]}]"#, + #"[{\#(rule), "readsTotals": [{"kind": "countOf", "documentType": null, "filter": []}]}]"#, + // A missing or mistyped filter + #"[{\#(rule), "readsTotals": [{\#(count)}]}]"#, + #"[{\#(rule), "readsTotals": [{\#(count), "filter": null}]}]"#, + #"[{\#(rule), "readsTotals": [{\#(count), "filter": "$ownerId"}]}]"#, + #"[{\#(rule), "readsTotals": [{\#(count), "filter": {"$ownerId": "$ownerId"}}]}]"#, + // A filter key that is not a string + #"[{\#(rule), "readsTotals": [{\#(count), "filter": ["$ownerId", 1]}]}]"#, + #"[{\#(rule), "readsTotals": [{\#(count), "filter": [null]}]}]"#, + // A property that is present but not a string + #"[{\#(rule), "readsTotals": [{"kind": "sumOf", "documentType": "listing", "property": 1, "filter": []}]}]"#, + #"[{\#(rule), "readsTotals": [{"kind": "sumOf", "documentType": "listing", "property": null, "filter": []}]}]"#, + #"[{\#(rule), "readsTotals": [{"kind": "sumOf", "documentType": "listing", "property": ["price"], "filter": []}]}]"# + ] + // The same rule with well-formed totals decodes + XCTAssertNoThrow(try DocumentPropertyConstraint.list(fromJSON: """ + [{\(rule), "readsTotals": [{\(count), "filter": ["$ownerId"]}, + {"kind": "sumOf", "documentType": "listing", "property": "price", "filter": []}]}] + """)) + for json in malformed { + // Each is JSON, so what is refused is its readsTotals + XCTAssertNoThrow(try JSONSerialization.jsonObject(with: Data(json.utf8)), json) + XCTAssertThrowsError(try DocumentPropertyConstraint.list(fromJSON: json), json) { error in + guard case SDKError.serializationError = error else { + return XCTFail("\(json): expected a serialization error, got \(error)") + } + } + } + } + + /// The initializer still builds a rule without naming `readsTotals`, as it + /// did before the field existed. + func testInitializerDefaultsToNoTotalReads() { + let rule = DocumentPropertyConstraint( + name: "r", + ruleJSON: #"{"lessThan":["price",1000]}"#, + reads: [PropertyConstraintRead(path: "price", kind: .value)], + readsOwner: false, + readsSystem: [] + ) + + XCTAssertEqual(rule.readsTotals, []) + XCTAssertNotEqual( + rule, + DocumentPropertyConstraint( + name: "r", + ruleJSON: #"{"lessThan":["price",1000]}"#, + reads: [PropertyConstraintRead(path: "price", kind: .value)], + readsOwner: false, + readsSystem: [], + readsTotals: [PropertyConstraintTotalRead(kind: .countOf, documentType: "listing", filter: [])] + ) + ) + } + // MARK: - Violations func testViolationDecodes() throws { diff --git a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs index 9df961849d3..fe6193e5838 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs @@ -19,7 +19,9 @@ use dpp::consensus::basic::document::PropertyConstraintViolation; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; -use dpp::data_contract::document_type::property_constraints::{DocumentSystemValues, PropertyRead}; +use dpp::data_contract::document_type::property_constraints::{ + AggregateKind, DocumentSystemValues, PropertyRead, +}; use dpp::document::{Document, DocumentV0Getters}; use dpp::platform_value::Value; use dpp::version::PlatformVersion; @@ -180,6 +182,22 @@ export type PropertyConstraintSystemProperty = | '$updatedAtCoreBlockHeight' | '$transferredAtCoreBlockHeight'; +/** + * A `countOf` or `sumOf` total a rule reads: how many documents of + * `documentType`, a type of the same contract, match the filter, or the total + * of their integer `property` (a `sumOf` only). `filter` lists the keys the + * documents are matched by, properties of that type or `$ownerId`; the values + * they must take are in the rule. The platform reads the total from state when + * the document is sent; `checkDocumentPropertyConstraints` cannot, and does + * not judge a rule reading one. + */ +export type PropertyConstraintTotalRead = { + kind: 'countOf' | 'sumOf'; + documentType: string; + property?: string; + filter: string[]; +}; + /** * A single `propertyConstraints` rule of a document type. */ @@ -190,7 +208,10 @@ export type DocumentPropertyConstraint = { rule: PropertyConstraintCondition; /** Every property the rule reads, in declared order; `$ownerId` is no property and is not listed. */ reads: Array<{ path: string; kind: PropertyConstraintReadKind }>; - /** Whether the rule reads `$ownerId`: then a transfer or a purchase is judged against it too. */ + /** + * Whether the rule reads `$ownerId`, or a total that depends on the owner: + * then a transfer or a purchase is judged against it too. + */ readsOwner: boolean; /** * The system times and heights the rule reads, in declared order. A price @@ -198,6 +219,11 @@ export type DocumentPropertyConstraint = { * purchase against one reading the transfer's. */ readsSystem: PropertyConstraintSystemProperty[]; + /** + * The `countOf` and `sumOf` totals the rule reads, in declared order, one + * read twice listed twice. The pre-check does not judge a rule reading one. + */ + readsTotals: PropertyConstraintTotalRead[]; }; /** @@ -389,6 +415,27 @@ pub(crate) fn property_constraints_for_document_type( reads_system.push(&JsValue::from_str(property.name())); } set_field(&object, "readsSystem", &reads_system, name)?; + let reads_totals = Array::new(); + for read in constraint.aggregate_reads() { + let entry = Object::new(); + set_field(&entry, "kind", &JsValue::from_str(read.wire_name()), name)?; + set_field( + &entry, + "documentType", + &JsValue::from_str(&read.document_type), + name, + )?; + if let AggregateKind::Sum { property } = &read.kind { + set_field(&entry, "property", &JsValue::from_str(property), name)?; + } + let filter = Array::new(); + for key in read.filter.keys() { + filter.push(&JsValue::from_str(key)); + } + set_field(&entry, "filter", &filter, name)?; + reads_totals.push(&entry); + } + set_field(&object, "readsTotals", &reads_totals, name)?; rules.push(&object); } diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index 0eb399a16c0..cd907fa000c 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -880,7 +880,8 @@ impl DataContractWasm { /// document's stored creation and transfer times when it has them). A /// rule reading a block height the write records is not judged, since the /// height is unknown until the block, and neither is a rule reading a - /// `countOf` or `sumOf` total, which only the platform reads from state. + /// `countOf` or `sumOf` total, which only the platform reads from state + /// (a rule's `readsTotals` lists them). /// `undefined` when it meets every rule of its document type. /// /// A pre-check, so an app can refuse a document before paying for a diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts index 3e4ff490b3d..90215339ac7 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts @@ -111,6 +111,7 @@ describe('DataContract: propertyConstraints (v14)', () => { ], readsOwner: false, readsSystem: [], + readsTotals: [], }, { name: 'discountBelowPrice', @@ -121,6 +122,7 @@ describe('DataContract: propertyConstraints (v14)', () => { ], readsOwner: false, readsSystem: [], + readsTotals: [], }, { name: 'perUnitFee', @@ -131,6 +133,7 @@ describe('DataContract: propertyConstraints (v14)', () => { ], readsOwner: false, readsSystem: [], + readsTotals: [], }, { name: 'sellerIsOwner', @@ -141,6 +144,7 @@ describe('DataContract: propertyConstraints (v14)', () => { ], readsOwner: true, readsSystem: [], + readsTotals: [], }, { name: 'tieredFee', @@ -148,6 +152,7 @@ describe('DataContract: propertyConstraints (v14)', () => { reads: [{ path: 'fee', kind: 'value' }], readsOwner: false, readsSystem: [], + readsTotals: [], }, ]); }); @@ -202,6 +207,7 @@ describe('DataContract: propertyConstraints (v14)', () => { reads: [{ path: 'tags', kind: 'count' }, { path: 'maxTags', kind: 'value' }], readsOwner: false, readsSystem: [], + readsTotals: [], }, { name: 'titleBytes', @@ -209,6 +215,7 @@ describe('DataContract: propertyConstraints (v14)', () => { reads: [{ path: 'title', kind: 'length' }], readsOwner: false, readsSystem: [], + readsTotals: [], }, ]); @@ -255,6 +262,7 @@ describe('DataContract: propertyConstraints (v14)', () => { reads: [{ path: 'endsAt', kind: 'value' }], readsOwner: false, readsSystem: ['$createdAt'], + readsTotals: [], }, { name: 'listedAfterHeight10', @@ -262,6 +270,7 @@ describe('DataContract: propertyConstraints (v14)', () => { reads: [], readsOwner: false, readsSystem: ['$createdAtBlockHeight'], + readsTotals: [], }, ]); @@ -308,6 +317,7 @@ describe('DataContract: propertyConstraints (v14)', () => { reads: [{ path: 'labels', kind: 'elements' }], readsOwner: false, readsSystem: [], + readsTotals: [], }, ]); @@ -419,6 +429,7 @@ describe('DataContract: propertyConstraints (v14)', () => { it('should report what a countOf or sumOf reads, and leave it to consensus', () => { const rules = { + allListings: { lessThan: [{ countOf: ['listing'] }, 1000] }, atMostTwoPerOwner: { lessThanOrEqual: [{ countOf: ['listing', { $ownerId: '$ownerId' }] }, 2], }, @@ -438,6 +449,7 @@ describe('DataContract: propertyConstraints (v14)', () => { }, }, required: ['price', 'category'], + documentsCountable: true, indices: [ { name: 'byOwner', properties: [{ $ownerId: 'asc' }], countable: 'countable' }, { name: 'byCategory', properties: [{ category: 'asc' }], summable: 'price' }, @@ -448,14 +460,23 @@ describe('DataContract: propertyConstraints (v14)', () => { }); // The count by owner depends on the owner, the category total on a - // property of the document written + // property of the document written; a whole-type count has no filter expect(contract.documentTypePropertyConstraints('listing')).to.deep.equal([ + { + name: 'allListings', + rule: rules.allListings, + reads: [], + readsOwner: false, + readsSystem: [], + readsTotals: [{ kind: 'countOf', documentType: 'listing', filter: [] }], + }, { name: 'atMostTwoPerOwner', rule: rules.atMostTwoPerOwner, reads: [], readsOwner: true, readsSystem: [], + readsTotals: [{ kind: 'countOf', documentType: 'listing', filter: ['$ownerId'] }], }, { name: 'categoryBudget', @@ -463,6 +484,9 @@ describe('DataContract: propertyConstraints (v14)', () => { reads: [{ path: 'category', kind: 'value' }], readsOwner: false, readsSystem: [], + readsTotals: [{ + kind: 'sumOf', documentType: 'listing', property: 'price', filter: ['category'], + }], }, ]); @@ -502,6 +526,7 @@ describe('DataContract: propertyConstraints (v14)', () => { reads: [{ path: 'balance', kind: 'value' }], readsOwner: false, readsSystem: [], + readsTotals: [], }, { name: 'knownTier', @@ -509,6 +534,7 @@ describe('DataContract: propertyConstraints (v14)', () => { reads: [{ path: 'tier', kind: 'value' }], readsOwner: false, readsSystem: [], + readsTotals: [], }, ]; From 812d630ccfd58f99d36a4859223f37735617a01a Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 19:07:14 +0700 Subject: [PATCH 082/113] fix(platform)!: a preallocated agreement source must fit a tree key (PV14) (#5123) Co-authored-by: Claude Opus 5.5 --- book/src/contract-keywords/index-only.md | 3 + book/src/contract-keywords/refers-to.md | 4 +- .../batch/tests/document/mod.rs | 1 + .../document/preallocated_agreement_source.rs | 182 ++++++++++++++++++ .../v0/mod.rs | 75 +++++++- .../data_contract_create/mod.rs | 42 ++++ .../data_contract_update/mod.rs | 25 +++ ...-contract-agreement-preallocated-fits.json | 87 +++++++++ ...ement-preallocated-too-wide-update-v1.json | 27 +++ ...tract-agreement-preallocated-too-wide.json | 87 +++++++++ .../v0/tests/preallocated_index_e2e_tests.rs | 86 ++++++++- .../mod.rs | 134 +++++++++---- .../tests.rs | 64 +++++- .../rs-platform-version/src/version/v14.rs | 13 ++ 14 files changed, 786 insertions(+), 44 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/preallocated_agreement_source.rs create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-fits.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-too-wide-update-v1.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-too-wide.json diff --git a/book/src/contract-keywords/index-only.md b/book/src/contract-keywords/index-only.md index c2a6b14cc07..4171e0eee29 100644 --- a/book/src/contract-keywords/index-only.md +++ b/book/src/contract-keywords/index-only.md @@ -154,8 +154,11 @@ Rules at registration: - Only on an `indexOnly` type. - Every index property is either a property with a `permanentDocument` reference to a type of the same contract, or a key of that reference's `propertyAgreement`. A `deletableDocument` reference does not qualify, since the trees would outlive a deleted target. `$ownerId` may only be the terminal. +- The referenced property of each such agreement key holds at most 255 bytes, since creating a referenced document makes its value an index key (40126 when the contract is created or updated). - Not with `timeRange`. +A referenced document whose agreed value takes more bytes than the referring property can hold preallocates nothing for that index, since no entry could agree with it. + ## `skipIfAbsent` | | | diff --git a/book/src/contract-keywords/refers-to.md b/book/src/contract-keywords/refers-to.md index 6390eaee15d..b20fb5fe96f 100644 --- a/book/src/contract-keywords/refers-to.md +++ b/book/src/contract-keywords/refers-to.md @@ -171,7 +171,7 @@ Binds the referring document to the document it references: each pair `{ ">( + platform: &TempPlatform, + platform_state: &PlatformState, + contract: &DataContract, + post_hashtag: &str, + like_hashtag: &str, + owner: Identifier, + key: &IdentityPublicKey, + nonce: u64, + signer: &S, + rng: &mut StdRng, + platform_version: &PlatformVersion, + ) -> StateTransitionsProcessingResult { + let (post, result) = create_document( + platform, + platform_state, + contract, + "post", + &[("hashtag", Value::Text(post_hashtag.to_string()))], + owner, + key, + nonce, + signer, + rng, + platform_version, + ) + .await; + assert_successful(&result, "the post must be created"); + let (_, result) = create_document( + platform, + platform_state, + contract, + "like", + &[ + ("postId", Value::Identifier(post.id().to_buffer())), + ("hashtag", Value::Text(like_hashtag.to_string())), + ], + owner, + key, + nonce + 1, + signer, + rng, + platform_version, + ) + .await; + result + } + + /// A post type whose hashtag fits a tree key keys the preallocated + /// `byHashtagPost` index through the agreement: its posts, at the widest + /// hashtag too, and the likes agreeing with them are created. + #[tokio::test] + async fn should_create_posts_and_likes_when_the_agreement_source_fits_a_tree_key() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let mut rng = StdRng::seed_from_u64(5312); + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + let contract = register_contract_at( + &platform, + PREALLOCATED_FITS_CONTRACT_PATH, + identity.id(), + true, + platform_version, + ); + + // 63 characters of four bytes each: 252 bytes, the widest hashtag + let widest = "\u{1F600}".repeat(63); + for (nonce, hashtag) in [(2, "dash"), (4, widest.as_str())] { + let result = like_a_post( + &platform, + &platform_state, + &contract, + hashtag, + hashtag, + identity.id(), + &key, + nonce, + &signer, + &mut rng, + platform_version, + ) + .await; + assert_successful( + &result, + &format!("a like on a post under a {}-byte hashtag", hashtag.len()), + ); + } + } + + /// A contract applied before registration bounded the source of a + /// preallocated index's agreement: its posts are created, those under a + /// hashtag no like can carry included, and so are the likes on the + /// others. + #[tokio::test] + async fn should_create_posts_under_an_agreement_source_wider_than_a_tree_key() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let mut rng = StdRng::seed_from_u64(5313); + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + let contract = register_contract_at( + &platform, + PREALLOCATED_TOO_WIDE_CONTRACT_PATH, + identity.id(), + true, + platform_version, + ); + + // 70 characters of four bytes each: past any like's 63 characters + // and past the 255 bytes of a tree key + let (_, result) = create_document( + &platform, + &platform_state, + &contract, + "post", + &[("hashtag", Value::Text("\u{1F600}".repeat(70)))], + identity.id(), + &key, + 2, + &signer, + &mut rng, + platform_version, + ) + .await; + assert_successful(&result, "a post under a hashtag no like can carry"); + + let result = like_a_post( + &platform, + &platform_state, + &contract, + "dash", + "dash", + identity.id(), + &key, + 3, + &signer, + &mut rng, + platform_version, + ) + .await; + assert_successful(&result, "a like on a post under a short hashtag"); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs index f4394d88cc3..a17c7bda926 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs @@ -4,8 +4,8 @@ use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, Docume use dpp::data_contract::document_type::{ is_referenced_system_agreement_property, is_referring_system_agreement_property, is_transient, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, - DocumentReferenceDeclaration, DocumentTypeRef, KeyReferenceIdentityProperty, PropertyReference, - ReferenceHolder, + DocumentReferenceDeclaration, DocumentTypeRef, KeyReferenceIdentityProperty, + PreallocatedKeySource, PropertyReference, ReferenceHolder, MAX_INDEX_SIZE, }; use dpp::data_contract::DataContract; use dpp::document::property_names::CREATOR_ID; @@ -53,6 +53,43 @@ fn stores_key_id_without_identity( is_transient(document_type, identity_path) && !is_transient(document_type, key_id_path) } +/// The name of a preallocated index of `document_type` whose trees are keyed +/// by the referenced document's `referenced_property` through the agreement +/// pair naming it from `referring_property`, on the reference carried by +/// `reference_property`: creating a referenced document then writes that +/// property's value as a tree key of the index. `None` when no preallocated +/// index is keyed through the pair. +fn preallocated_index_keyed_by( + contract_id: Identifier, + document_type: DocumentTypeRef, + reference_property: &str, + referring_property: &str, + referenced_property: &str, +) -> Option { + document_type + .indexes() + .values() + .filter(|index| index.preallocated) + .find(|index| { + index + .preallocation_bindings(document_type.flattened_properties(), contract_id) + .iter() + .any(|binding| { + binding.referring_property == reference_property + && index.properties.iter().zip(&binding.key_sources).any( + |(index_property, key_source)| { + index_property.name == referring_property + && *key_source + == PreallocatedKeySource::ReferencedDocumentProperty( + referenced_property, + ) + }, + ) + }) + }) + .map(|index| index.name.clone()) +} + /// Checks every reference declaration of the given contract that carries /// declaration content. /// @@ -75,6 +112,10 @@ fn stores_key_id_without_identity( /// is written. One in the declaring contract was checked by the contract /// parse. /// +/// A pair through which a preallocated index of the declaring type is keyed +/// needs a referenced property whose every value fits a tree key, at most +/// 255 bytes: creating a referenced document writes the value as one. +/// /// `identityPublicKey`: the declared key id property must exist in the same /// document type and be an integer. On either side of a key reference, a /// stored key id may not pair with a transient identity, which would leave it @@ -651,6 +692,36 @@ fn validate_reference_target_declaration_v0( equality could never be satisfied", )); } + // A preallocated index keyed through this pair writes the referenced + // document's value as a tree key when that document is created, so + // every value the referenced property can hold must fit one; the + // referring side is an index property, bounded by the index rules. In + // place: inert before protocol version 14, which alone reaches this + // module (contract create and update state validation 1). + let keyed_index = reference_property.and_then(|reference_property| { + preallocated_index_keyed_by( + contract.id(), + document_type, + reference_property, + referring_property, + referenced_property, + ) + }); + if let Some(index_name) = keyed_index { + let max_width = referenced + .property_type + .saturating_max_byte_size(platform_version) + .ok() + .flatten(); + if max_width.is_none_or(|width| usize::from(width) > MAX_INDEX_SIZE) { + return Ok(invalid(&format!( + "the preallocated index {index_name} keys its trees by the referenced \ + property's value, and a tree key holds at most {MAX_INDEX_SIZE} bytes, but \ + the referenced property can hold values of up to {} bytes", + max_width.unwrap_or(u16::MAX) + ))); + } + } } Ok(SimpleConsensusValidationResult::new()) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs index ac5840d1f16..b4f7f9a6616 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs @@ -6041,6 +6041,48 @@ mod tests { ); } + /// A preallocated index keyed through an agreement pair makes the + /// referenced property's value a tree key when a referenced document is + /// created. A `post.hashtag` of up to 280 characters can take 1,120 + /// bytes, past the 255 a tree key holds, so every post carrying a long + /// one could never be created: the contract is refused instead. + #[tokio::test] + async fn should_reject_an_agreement_keying_a_preallocated_index_by_a_property_wider_than_a_tree_key( + ) { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-too-wide.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(error) + ), + .. + } if error.referring_property() == "hashtag" + && error.reason().contains("preallocated index byHashtagPost") + && error.reason().contains("up to 1120 bytes") + ); + } + + /// At most 63 characters, 252 bytes: every `post.hashtag` fits a tree + /// key. + #[tokio::test] + async fn should_register_an_agreement_keying_a_preallocated_index_by_a_property_that_fits_a_tree_key( + ) { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-fits.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + /// `refersTo` on the items of a typed array registers with every target /// the fixture uses: permanent and deletable document elements, an /// agreement keyed by the writer and one on a schema property. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs index c636dc271b9..7866558edea 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs @@ -4552,6 +4552,31 @@ mod tests { ); } + /// An update adding a `like` type whose preallocated index is keyed by + /// the existing `post.hashtag`, up to 280 characters, through an + /// agreement is refused as a registration is: accepted, it would stop + /// every post carrying a long hashtag from being created. + #[tokio::test] + async fn should_reject_contract_update_keying_a_preallocated_index_by_a_property_wider_than_a_tree_key( + ) { + let result = run_contract_update_from( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-too-wide-update-v1.json", + "tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-too-wide.json", + true, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(error) + ), + .. + } if error.reason().contains("preallocated index byHashtagPost") + ); + } + /// The contract whose immutable `electedCharter` holds the `members` /// list, updated below with an `appeal` type reading it. const LIST_ELEMENT_V1_PATH: &str = diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-fits.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-fits.json new file mode 100644 index 00000000000..87b85e2eb96 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-fits.json @@ -0,0 +1,87 @@ +{ + "$formatVersion": "1", + "id": "DhEXtoZkTta7ttck76sRgzgaC2fbiQ3P5McBRxRb9zmV", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "like": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byHashtagPost", + "properties": [ + { + "hashtag": "asc" + }, + { + "postId": "asc" + } + ], + "terminal": "$ownerId", + "countable": "countable", + "preallocated": true, + "skipIfAbsent": true + }, + { + "name": "byPost", + "properties": [ + { + "postId": "asc" + } + ], + "countable": "countable", + "preallocated": true + } + ], + "properties": { + "hashtag": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "position": 0 + }, + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "permanentDocument", + "documentType": "post", + "propertyAgreement": { + "hashtag": "hashtag" + } + } + } + }, + "required": [ + "postId" + ], + "additionalProperties": false + }, + "post": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "hashtag": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "position": 0 + }, + "message": { + "type": "string", + "maxLength": 280, + "position": 1 + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-too-wide-update-v1.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-too-wide-update-v1.json new file mode 100644 index 00000000000..b131c363ffe --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-too-wide-update-v1.json @@ -0,0 +1,27 @@ +{ + "$formatVersion": "1", + "id": "DQnATUA21cBmq6yVmxj1PjQgo5G4Ee2YdfeUsmVcn8EU", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "post": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "hashtag": { + "type": "string", + "minLength": 1, + "maxLength": 280, + "position": 0 + }, + "message": { + "type": "string", + "maxLength": 280, + "position": 1 + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-too-wide.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-too-wide.json new file mode 100644 index 00000000000..e46371c922e --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-preallocated-too-wide.json @@ -0,0 +1,87 @@ +{ + "$formatVersion": "1", + "id": "DQnATUA21cBmq6yVmxj1PjQgo5G4Ee2YdfeUsmVcn8EU", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "like": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byHashtagPost", + "properties": [ + { + "hashtag": "asc" + }, + { + "postId": "asc" + } + ], + "terminal": "$ownerId", + "countable": "countable", + "preallocated": true, + "skipIfAbsent": true + }, + { + "name": "byPost", + "properties": [ + { + "postId": "asc" + } + ], + "countable": "countable", + "preallocated": true + } + ], + "properties": { + "hashtag": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "position": 0 + }, + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "permanentDocument", + "documentType": "post", + "propertyAgreement": { + "hashtag": "hashtag" + } + } + } + }, + "required": [ + "postId" + ], + "additionalProperties": false + }, + "post": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "hashtag": { + "type": "string", + "minLength": 1, + "maxLength": 280, + "position": 0 + }, + "message": { + "type": "string", + "maxLength": 280, + "position": 1 + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/preallocated_index_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/preallocated_index_e2e_tests.rs index b6c6798ebec..c2a9938c58e 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/preallocated_index_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/preallocated_index_e2e_tests.rs @@ -41,7 +41,8 @@ use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; use dpp::fee::fee_result::FeeResult; use dpp::platform_value::{Identifier, Value}; use dpp::prelude::DataContract; -use dpp::tests::json_document::json_document_to_contract; +use dpp::tests::json_document::{json_document_to_contract, json_document_to_json_value}; +use serde_json::json; const OWNER_POSTER: [u8; 32] = [0x0F; 32]; const OWNER_1: [u8; 32] = [0x11; 32]; @@ -926,3 +927,86 @@ fn should_preallocate_only_reference_bound_trees_for_an_untagged_post() { assert_grovedb_is_consistent(&drive); } + +/// The preallocated fixture with `post.hashtag` widened to 280 characters and +/// unindexed, while `like.hashtag`, which `byHashtagPost` keys, stays at 63: +/// the shape registration now refuses, as a contract applied before it did. +fn setup_likes_with_a_wide_post_hashtag() -> (Drive, DataContract) { + let pv = platform_version(); + let drive = setup_drive_with_initial_state_structure(None); + let mut schema = json_document_to_json_value( + "tests/supporting_files/contract/yappr-likes/yappr-likes-preallocated-contract.json", + ) + .expect("read contract fixture"); + let post = &mut schema["documentSchemas"]["post"]; + post["properties"]["hashtag"]["maxLength"] = json!(280); + post.as_object_mut().expect("post schema").remove("indices"); + let contract = DataContract::try_from_platform_versioned( + serde_json::from_value(schema).expect("contract serialization format"), + true, + &mut vec![], + pv, + ) + .expect("parse the wide-hashtag contract"); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + pv, + ) + .expect("expected to apply the contract"); + (drive, contract) +} + +/// A post whose agreement-bound hashtag is wider than any like's can hold is +/// estimated and inserted, and preallocates nothing under `byHashtagPost`, +/// which no like could ever reach, while the id-bound `byPost` still +/// preallocates. A post whose hashtag a like can carry preallocates both. +#[test] +fn should_skip_preallocation_for_a_bound_value_wider_than_the_referring_property() { + let (drive, contract) = setup_likes_with_a_wide_post_hashtag(); + let base = doctype_path(&contract); + + // 70 characters of four bytes each: within the post's 280 characters, + // past the like's 63 and past the 255 bytes of any tree key. + let wide_post = build_post(&contract, &"\u{1F600}".repeat(70), 1); + let wide_post_id = wide_post.id().to_buffer(); + let estimated = insert_post(&drive, &contract, &wide_post, false) + .expect("the dry-run of a post with a wide hashtag must work"); + let actual = insert_post(&drive, &contract, &wide_post, true) + .expect("a post with a wide hashtag must be inserted"); + assert!( + estimated.storage_fee >= actual.storage_fee, + "estimated post storage fee {} must upper-bound actual {}", + estimated.storage_fee, + actual.storage_fee + ); + assert!( + matches!( + read_grove_element(&drive, &base, b"hashtag"), + Some(grovedb::Element::Tree(None, _)) + ), + "no hashtag trees may be preallocated for a hashtag no like can carry" + ); + let mut by_post_level = base.clone(); + by_post_level.push(b"postId".to_vec()); + assert!( + read_grove_element(&drive, &by_post_level, &wide_post_id).is_some(), + "the id-bound byPost trees must still be preallocated" + ); + + let post = build_post(&contract, "dash", 2); + insert_post(&drive, &contract, &post, false).expect("estimate a post with a short hashtag"); + insert_post(&drive, &contract, &post, true).expect("insert a post with a short hashtag"); + let mut hashtag_level = base.clone(); + hashtag_level.push(b"hashtag".to_vec()); + assert!( + read_grove_element(&drive, &hashtag_level, b"dash").is_some(), + "a hashtag a like can carry is preallocated as before" + ); + + assert_grovedb_is_consistent(&drive); +} diff --git a/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs b/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs index 50e75f259e7..ebc53e937f5 100644 --- a/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs @@ -58,7 +58,11 @@ use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::config::v0::DataContractConfigGettersV0; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; -use dpp::data_contract::document_type::{Index, IndexLevel, PreallocatedKeySource}; +use dpp::data_contract::document_type::{ + DocumentPropertyType, DocumentTypeRef, Index, PreallocatedKeySource, +}; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper; use dpp::version::PlatformVersion; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; @@ -92,7 +96,7 @@ impl Drive { let contract = document_and_contract_info.contract; let target_name = document_and_contract_info.document_type.name().as_str(); - for (referring_name, referring_document_type) in contract.document_types() { + for referring_document_type in contract.document_types().values() { let referring_type = referring_document_type.as_ref(); // `preallocated` is only valid on indexOnly document types, so // this filter also keeps the per-insert scan trivially cheap for @@ -133,8 +137,7 @@ impl Drive { ) { self.add_preallocated_index_tree_operations_for_binding( document_and_contract_info, - referring_name, - referring_type.index_structure(), + referring_type, index, &binding.key_sources, storage_flags, @@ -152,17 +155,17 @@ impl Drive { /// Adds the operations preallocating ONE index's trees for entries /// referencing the inserted document: walks the referring type's - /// [`IndexLevel`] along the index's properties, resolving each path key - /// from the inserted document per the binding's key sources, and creates - /// each property-name tree, value tree and the terminal `0` member - /// bucket with exactly the tree types and estimation layers the - /// entry-insert walkers use. + /// [`IndexLevel`](dpp::data_contract::document_type::IndexLevel) along + /// the index's properties, resolving each path key from the inserted + /// document per the binding's key sources, and creates each + /// property-name tree, value tree and the terminal `0` member bucket + /// with exactly the tree types and estimation layers the entry-insert + /// walkers use. #[allow(clippy::too_many_arguments)] fn add_preallocated_index_tree_operations_for_binding( &self, document_and_contract_info: &DocumentAndContractInfo, - referring_type_name: &str, - referring_index_structure: &IndexLevel, + referring_type: DocumentTypeRef, index: &Index, key_sources: &[PreallocatedKeySource], storage_flags: Option<&StorageFlags>, @@ -180,8 +183,9 @@ impl Drive { let document_info = &document_and_contract_info.owned_document_info.document_info; let event_id = unique_event_id(); + let referring_index_structure = referring_type.index_structure(); let contract_document_type_path = - contract_document_type_path_vec(contract.id_ref().as_bytes(), referring_type_name); + contract_document_type_path_vec(contract.id_ref().as_bytes(), referring_type.name()); if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { // The referring doctype tree layer — mirror of the entry-insert @@ -223,13 +227,43 @@ impl Drive { // property otherwise — validated at contract registration to // share one value kind with the referring side, so the encoded // bytes match what an entry insert would write. - let source_property = match *key_source { - PreallocatedKeySource::ReferencedDocumentId => "$id", - PreallocatedKeySource::ReferencedDocumentProperty(referenced) => referenced, + let (source_property, source_type) = match *key_source { + PreallocatedKeySource::ReferencedDocumentId => ("$id", target_document_type), + PreallocatedKeySource::ReferencedDocumentProperty(referenced) => { + match document_info.get_borrowed_document() { + // Without a document the key is sized as the + // referring property, the bound every key an entry + // writes at this level has + None => (property_name, referring_type), + Some(document) => { + let referring_property_type = &referring_type + .flattened_properties() + .get(property_name) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "a preallocated index's property must be a property of \ + its document type", + )))? + .property_type; + if !bound_value_fits_referring_property( + document, + referenced, + referring_property_type, + platform_version, + )? { + // No entry can ever agree with a value wider + // than the referring property holds, and it + // may not fit a tree key at all. Nothing to + // preallocate. + return Ok(()); + } + (referenced, target_document_type) + } + } + } }; let Some(value_key) = document_info.get_raw_for_document_type( source_property, - target_document_type, + source_type, document_and_contract_info.owned_document_info.owner_id, Some((sub_level, event_id)), platform_version, @@ -247,13 +281,7 @@ impl Drive { // the fallback. return Ok(()); } - resolved_levels.push(( - property_name, - sub_level, - source_property, - *key_source, - value_key, - )); + resolved_levels.push((property_name, sub_level, value_key)); current_level = sub_level; } let terminal_level = current_level; @@ -270,7 +298,7 @@ impl Drive { // inversion the entry-insert walkers apply. let mut parent_counts_continuations = false; - for (property_name, sub_level, source_property, key_source, value_key) in resolved_levels { + for (property_name, sub_level, value_key) in resolved_levels { let tree_types = index_level_tree_types_with_continuation_demotion(sub_level)?; let property_name_tree_type = tree_types.property_name_tree_type; let ranked_axes = tree_types.ranked_axes.as_slice(); @@ -348,19 +376,19 @@ impl Drive { if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { // The property-name layer: children are value trees keyed by - // the source property's values (32 bytes for `$id`). - let value_key_estimated_size = match key_source { - PreallocatedKeySource::ReferencedDocumentId => DEFAULT_HASH_SIZE_U8 as u16, - PreallocatedKeySource::ReferencedDocumentProperty(_) => document_info - .get_estimated_size_for_document_type( - source_property, - target_document_type, - platform_version, - )?, - }; + // the referring property's values, sized exactly as an entry + // insert sizes this layer (32 bytes for the reference + // property). A referenced document only keys it with a value + // that fits that property, so a wider referenced property + // does not widen the estimate. + let value_key_estimated_size = document_info.get_estimated_size_for_document_type( + property_name, + referring_type, + platform_version, + )?; if value_key_estimated_size > u8::MAX as u16 { return Err(Error::Fee(FeeError::Overflow( - "referenced document field is too big for being an index", + "document field is too big for being an index", ))); } estimated_costs_only_with_layer_info.insert( @@ -471,9 +499,6 @@ impl Drive { // Same per-entry padding (and sum-item worst case) the // entry-insert terminal claims for this layer — see // `add_index_only_terminal_item_operations`. - let referring_type = contract - .document_type_for_name(referring_type_name) - .map_err(|e| Error::Protocol(Box::new(dpp::ProtocolError::DataContractError(e))))?; let estimated_item_value_size = index_only_item_estimated_value_size(referring_type, platform_version)?; let member_key_max_size = match level_info.terminal.as_deref() { @@ -504,3 +529,36 @@ impl Drive { Ok(()) } } + +/// Whether `document`'s value of `referenced_property`, which a +/// `propertyAgreement` binds to a referring index property of +/// `referring_property_type`, is no wider as a tree key than a value of that +/// property can be. A wider value equals no referring document's value, so no +/// entry would ever sit under trees keyed by it, and past 255 bytes it is no +/// tree key at all. An absent value fits (the caller skips it on its own), as +/// do the referenced document's `$ownerId` and `$creatorId`, 32-byte +/// identifiers that registration pairs with an identifier. +fn bound_value_fits_referring_property( + document: &Document, + referenced_property: &str, + referring_property_type: &DocumentPropertyType, + platform_version: &PlatformVersion, +) -> Result { + if referenced_property.starts_with('$') { + return Ok(true); + } + let Some(value) = document + .properties() + .get_optional_at_path(referenced_property)? + else { + return Ok(true); + }; + let Some(max_width) = referring_property_type.saturating_max_byte_size(platform_version)? + else { + return Ok(true); + }; + let width = referring_property_type + .encode_value_for_tree_keys(value)? + .len(); + Ok(width <= usize::from(max_width)) +} diff --git a/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/tests.rs b/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/tests.rs index 5b8cfd589f6..130165609e3 100644 --- a/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/tests.rs +++ b/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/tests.rs @@ -3,7 +3,8 @@ use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; use crate::util::object_size_info::OwnedDocumentInfo; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use dpp::data_contract::document_type::random_document::CreateRandomDocument; -use dpp::document::DocumentV0Getters; +use dpp::document::{DocumentV0Getters, DocumentV0Setters}; +use dpp::platform_value::Value; use dpp::prelude::DataContract; use dpp::tests::json_document::json_document_to_json_value; use serde_json::json; @@ -71,3 +72,64 @@ fn should_estimate_composite_terminal_width_in_preallocated_member_layers() { "the estimate must cover every terminal component: {member_layer:?}", ); } + +/// The layer an agreement key opens holds the referring property's values, +/// so the dry-run sizes it as an entry insert does: from `like.hashtag` (1 to +/// 63 characters, 128 bytes midway), not from a `post.hashtag` of up to 280 +/// characters, which no tree key could hold. +#[test] +fn should_size_an_agreement_bound_layer_as_the_referring_property() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + let mut schema = json_document_to_json_value( + "tests/supporting_files/contract/yappr-likes/yappr-likes-preallocated-contract.json", + ) + .expect("read contract fixture"); + let post_schema = &mut schema["documentSchemas"]["post"]; + post_schema["properties"]["hashtag"]["maxLength"] = json!(280); + post_schema + .as_object_mut() + .expect("post schema") + .remove("indices"); + let contract = DataContract::try_from_platform_versioned( + serde_json::from_value(schema).expect("contract serialization format"), + false, + &mut vec![], + platform_version, + ) + .expect("parse the wide-hashtag contract"); + let post_type = contract.document_type_for_name("post").expect("post type"); + let mut post = post_type + .random_document(Some(1), platform_version) + .expect("post"); + post.set_properties([("hashtag".to_string(), Value::Text("dash".to_string()))].into()); + let mut layers = Some(HashMap::new()); + drive + .add_preallocated_index_tree_operations_for_referring_types( + &DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&post, None)), + owner_id: None, + }, + contract: &contract, + document_type: post_type, + }, + &mut None, + &mut layers, + None, + &mut vec![], + platform_version, + ) + .expect("estimate preallocation"); + + let mut hashtag_path = contract_document_type_path_vec(contract.id_ref().as_bytes(), "like"); + hashtag_path.push(b"hashtag".to_vec()); + let layers = layers.expect("estimated layers"); + let hashtag_layer = layers + .get(&KeyInfoPath::from_known_owned_path(hashtag_path)) + .expect("the byHashtagPost hashtag layer must be estimated"); + assert!( + matches!(hashtag_layer.estimated_layer_sizes, AllSubtrees(128, _, _)), + "the layer must be sized as like.hashtag: {hashtag_layer:?}", + ); +} diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 2e86b04dc0c..5aee58db46e 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1426,6 +1426,19 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// extended in place and is inert before this version, where the three /// slots are `None` and the meta-schemas refuse the keyword. /// +/// 55. **A preallocated index's agreement source fits a tree key**: contract +/// create and update state validation 1 refuse, paid, a +/// `propertyAgreement` pair through which a preallocated index is keyed +/// when its referenced property can hold a value over 255 bytes +/// (`ReferencedDocumentPropertyAgreementInvalidError`, 40126): creating a +/// referenced document writes that value as a tree key, which failed with +/// an internal error for a value over 255 bytes, and for any value once +/// the property's midway size, which sized the estimate, passed 255 +/// bytes. `add_document_for_contract_operations` 1 now estimates that +/// layer from the referring property, as an entry insert does, and +/// preallocates nothing for a referenced value wider than the referring +/// property can hold, which no referring document can agree with. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's From 4a8998f25efedfea4529e38bf0659d83d82c0835 Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:21:55 +0000 Subject: [PATCH 083/113] ci: use pinned ARM64 Rust images on Mac-backed Linux runners --- .github/SELF_HOSTED_RUNNER.md | 55 +++++++- .github/actions/rust/action.yaml | 10 +- .github/runner-requirements.arm64.json | 142 +++++++++++++++++++++ .github/scripts/runner-image.py | 71 +++++++++-- .github/scripts/tests/test_runner_image.py | 75 ++++++++++- .github/workflows/tests-rs-wallet.yml | 68 +++++----- .github/workflows/tests-rs-workspace.yml | 22 +++- .github/workflows/tests.yml | 23 +++- 8 files changed, 403 insertions(+), 63 deletions(-) create mode 100644 .github/runner-requirements.arm64.json diff --git a/.github/SELF_HOSTED_RUNNER.md b/.github/SELF_HOSTED_RUNNER.md index cb46745c9b0..c27e26202d1 100644 --- a/.github/SELF_HOSTED_RUNNER.md +++ b/.github/SELF_HOSTED_RUNNER.md @@ -5,7 +5,8 @@ Source, locked dependencies, deployment examples and publishing workflow: **[dashpay/dash-selfhosted-image](https://github.com/dashpay/dash-selfhosted-image)**. Use its `linux/amd64` contract-1 image for persistent Linux `kotlin-ci` / `rust-ci` -runners. The shared Rust action requires `/opt/ci/contract-version` to be `1` on +runners, or its native `linux/arm64` Rust-only image on Apple Silicon Linux VMs. +The shared Rust action requires `/opt/ci/contract-version` to be `1` on self-hosted Linux and fails early if an old/native runner picks up the job. Platform's desired versions and checksums now live in @@ -15,6 +16,12 @@ revision; a contract-1 marker by itself is not sufficient. The earlier published bootstrap image must be replaced by a matching candidate before these changes can be merged. +ARM64 Rust jobs select [runner-requirements.arm64.json](runner-requirements.arm64.json) +using the **actual** job's `RUNNER_OS=Linux` / `RUNNER_ARCH=ARM64`. They verify its +exact recipe and ARM64 lock, not the AMD64 lock and not just tool version strings. +The shared Rust action defaults to the native compilation target. Kotlin/Android +remains AMD64-only; ARM64 does not export or pretend to provide Android tooling. + The image locks Ubuntu 24.04 by digest, apt to a signed archive snapshot, and downloaded toolchains to exact URLs and SHA-256 hashes. It includes: @@ -108,6 +115,45 @@ Run the routing checks with: python3 -m unittest discover -s .github/scripts/tests -v ~~~ +## Apple Silicon rollout and ARM64 requirements changes + +The Rust workspace and wallet jobs select `[self-hosted, Linux, rust-ci]`. This +includes Linux containers on Macs; it does **not** remove Mac hardware from CI. +Keep native macOS registrations for Swift, Xcode and simulator jobs. Never reuse +their registration, HOME or workspaces inside a container. Rust and Swift may run +concurrently, so reserve host resources rather than assigning both the whole Mac. +Use Linux-owned named volumes for build/cache data, not macOS bind mounts. + +Initial ARM64 recipe: `772673c94f2c0b39e7e796198a6ea407c087dd72`, published by +[image run 36419475060](https://github.com/dashpay/dash-selfhosted-image/actions/runs/36419475060). +Its tested immutable reference is +`dashpay/dash-selfhosted-image@sha256:2ef7934f6877b4b78bdc3d4b81c07ee260d1648c0338c86145cec02760390a24`. +The existing AMD64 requirements and deployed images are unchanged. + +Before merging/routing ordinary CI, provision each Mac's ARM64 VM and validate +the digest with the image's smoke test. Register it separately in the existing +selected-repository group with `rust-ci-validation`, prove a real Platform +workspace job on that exact runner, and verify unattended restart. Only validated +instances get `rust-ci`; keep the validation label for future image qualification. +Do not count image-build CI, local unit tests or skipped fork jobs as this proof. + +The existing automatic PR-candidate publisher/controller is **AMD64-only**. +ARM64 requirements currently use explicit operator deployment, not that publisher: + +1. Build/publish the ARM64 recipe and pin its exact lock and recipe here. +2. Deploy the tested digest to an idle validation runner, preserving rollback. +3. `ARM64 runner image validation` runs the **full** Rust workspace on ARM64 when + this manifest changes. Exact lock/recipe mismatch fails before compilation. + Its selector compares the PR-head manifest with the checked-out merge tree; + an AMD64 candidate status cannot satisfy ARM64 validation. +4. Require successful real ARM64 validation before merging the requirements and + rolling out other Mac-backed capacity. If AMD64 requirements also change, + their separate Rust/Kotlin candidate gates still apply. + +Keep shared Rust/helper versions aligned across both manifests. Automatic ARM64 +candidate creation/promotion is not implemented; never infer ARM64 validation or +deployment from an AMD64 publisher result. + ## Hosted Linux and native macOS remain distinct The shared Rust action branches on `runner.environment`: persistent Linux verifies @@ -115,10 +161,9 @@ the image's native libraries and protoc, while GitHub-hosted consumers retain ap provisioning and the user-local protoc cache. Both select clang through `CC`/`CXX`, without mutating system alternatives. -The Linux image does not provision macOS. Native macOS `rust-ci` runners still -need the existing Homebrew dependencies plus llvm-cov 0.9.1, nextest 0.9.144 and -machete 0.9.2; the wallet fast path needs machete 0.9.2. Provision and verify these -separately before rollout. Do not silently install tools or swallow failures in +The Linux image does not provision macOS. Native macOS runners retain their +existing Swift/Homebrew dependencies and registrations; generic Rust jobs now +use the Linux image pool. Do not silently install tools or swallow failures in persistent jobs. Kotlin release builds use persistent `kotlin-ci` capacity and retain their separate diff --git a/.github/actions/rust/action.yaml b/.github/actions/rust/action.yaml index 945806752e2..7dfed6fc848 100644 --- a/.github/actions/rust/action.yaml +++ b/.github/actions/rust/action.yaml @@ -6,9 +6,9 @@ inputs: description: Rust toolchain to use, stable / nightly / beta, or exact version; uses rust-toolchain.toml if not specified default: "" target: - description: Target Rust platform + description: Additional Rust target to install; defaults to the runner's native target required: false - default: x86_64-unknown-linux-gnu + default: "" components: description: List of additional Rust toolchain components to install required: false @@ -149,10 +149,10 @@ runs: ${{ steps.resolved_home.outputs.home }}/.cargo/registry/index ${{ steps.resolved_home.outputs.home }}/.cargo/registry/cache ${{ steps.resolved_home.outputs.home }}/.cargo/git - key: ${{ runner.os }}/cargo/registry/${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}/${{ runner.arch }}/cargo/registry/${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}/cargo/registry/${{ hashFiles('**/Cargo.lock') }} - ${{ runner.os }}/cargo/registry/ + ${{ runner.os }}/${{ runner.arch }}/cargo/registry/${{ hashFiles('**/Cargo.lock') }} + ${{ runner.os }}/${{ runner.arch }}/cargo/registry/ # This composite is also used by hosted release, nightly and book jobs. # Keep their bootstrap path; only persistent runners require a prebaked image. diff --git a/.github/runner-requirements.arm64.json b/.github/runner-requirements.arm64.json new file mode 100644 index 00000000000..3c787c338e3 --- /dev/null +++ b/.github/runner-requirements.arm64.json @@ -0,0 +1,142 @@ +{ + "schema": 1, + "recipe_revision": "772673c94f2c0b39e7e796198a6ea407c087dd72", + "requirements": { + "schema": 2, + "contract_version": "1", + "platform": "linux/arm64", + "ubuntu_image": "ubuntu:24.04@sha256:11dc1ccb427f0464a2369e645454c272bb0baece7357c892ba69d313b3a332cf", + "apt_snapshot": "20260920T000000Z", + "rust_version": "1.98.1", + "rust_manifest_sha256": "a7c8774a5fd8441c997d94c029776cbc5eb111e9d72ab5d256fa69866644347e", + "artifacts": [ + { + "name": "runner", + "url": "https://github.com/actions/runner/releases/download/v2.337.0/actions-runner-linux-arm64-2.337.0.tar.gz", + "sha256": "9b1dc70626422526e3c94767cf024896beb15da5342a3f4819bf2feac13e0393", + "format": "tar", + "destination": "/opt/actions-runner" + }, + { + "name": "cargo-llvm-cov", + "url": "https://github.com/taiki-e/cargo-llvm-cov/releases/download/v0.9.1/cargo-llvm-cov-aarch64-unknown-linux-gnu.tar.gz", + "sha256": "abf5f13c1520f8756d2192bfaaeadb0208f5b7eaa8cadc15bf847befa42b7360", + "format": "tar", + "destination": "/opt/ci/bin/cargo-llvm-cov", + "binary": "cargo-llvm-cov" + }, + { + "name": "cargo-nextest", + "url": "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-0.9.144/cargo-nextest-0.9.144-aarch64-unknown-linux-gnu.tar.gz", + "sha256": "7fecfd431b810c05c589d800524286b8f80ce2fe9fb1fef05caf16d095cea407", + "format": "tar", + "destination": "/opt/ci/bin/cargo-nextest", + "binary": "cargo-nextest" + }, + { + "name": "cargo-machete", + "url": "https://github.com/bnjbvr/cargo-machete/releases/download/v0.9.2/cargo-machete-v0.9.2-aarch64-unknown-linux-gnu.tar.gz", + "sha256": "6f96c3e6026a5bdd241b6ae600c6fb86c9197c6e189a894f91371baa01fd10f5", + "format": "tar", + "destination": "/opt/ci/bin/cargo-machete", + "binary": "cargo-machete" + }, + { + "name": "protoc", + "url": "https://github.com/protocolbuffers/protobuf/releases/download/v32.0/protoc-32.0-linux-aarch_64.zip", + "sha256": "56af3fc2e43a0230802e6fadb621d890ba506c5c17a1ae1070f685fe79ba12d0", + "format": "zip", + "destination": "/opt/protoc" + }, + { + "name": "rustup-init", + "url": "https://static.rust-lang.org/rustup/archive/1.28.2/aarch64-unknown-linux-gnu/rustup-init", + "sha256": "e3853c5a252fca15252d07cb23a1bdd9377a8c6f3efa01531109281ae47f841c", + "format": "file", + "destination": "/opt/ci/rustup-init", + "build_only": true + } + ], + "bootstrap_ca": { + "url": "https://snapshot.ubuntu.com/ubuntu/20260920T000000Z/pool/main/c/ca-certificates/ca-certificates_20240203_all.deb", + "sha256": "641de77d8f142cfd62a1a6f964ba67b20754d3337c480efb529d086075a06c9a", + "version": "20240203" + }, + "apt_packages": [ + "ca-certificates", + "curl", + "git", + "gh", + "jq", + "python3", + "unzip", + "zip", + "xz-utils", + "bzip2", + "gnupg", + "build-essential", + "clang", + "llvm", + "libsnappy-dev", + "cmake", + "libgmp-dev", + "libssl-dev", + "pkg-config", + "openjdk-17-jdk-headless", + "libicu74", + "libkrb5-3", + "zlib1g", + "libgcc-s1", + "libstdc++6", + "libcurl4t64", + "liblttng-ust1t64", + "libunwind8", + "libpulse0", + "libx11-xcb1", + "libnss3", + "libxcomposite1", + "libxcursor1", + "libxi6", + "libxrandr2", + "libxtst6", + "libasound2t64", + "libgl1", + "libegl1", + "libdbus-1-3", + "libxdamage1", + "libxfixes3" + ], + "java_major": 17, + "versions": { + "runner": "2.337.0", + "llvm_cov": "0.9.1", + "nextest": "0.9.144", + "machete": "0.9.2", + "protoc": "32.0", + "rustup": "1.28.2" + }, + "client_codegen": { + "schema": 1, + "versions": { + "protobuf": "3.18.1", + "grpc": "1.46.3", + "grpc_java": "1.42.1" + }, + "sources": [ + { + "url": "https://github.com/protocolbuffers/protobuf/releases/download/v3.18.1/protobuf-cpp-3.18.1.tar.gz", + "sha256": "6ee35eda3f79e49608d2ace8d866313fdec539d8bb14c6c54e8d2a16fa4e6780" + }, + { + "url": "https://github.com/grpc/grpc/archive/refs/tags/v1.46.3.tar.gz", + "sha256": "d6cbf22cb5007af71b61c6be316a79397469c58c82a942552a62e708bce60964" + }, + { + "url": "https://github.com/grpc/grpc-java/archive/refs/tags/v1.42.1.tar.gz", + "sha256": "33775a1ad05974bbba6ff97801cd9b326485b21fab91b08a4e1bc53e500e6326" + } + ] + }, + "profile": "rust" + } +} diff --git a/.github/scripts/runner-image.py b/.github/scripts/runner-image.py index 60d9638b505..d11cff8d14b 100644 --- a/.github/scripts/runner-image.py +++ b/.github/scripts/runner-image.py @@ -13,6 +13,7 @@ import urllib.request MANIFEST = ".github/runner-requirements.json" +ARM64_MANIFEST = ".github/runner-requirements.arm64.json" REPO = "dashpay/platform" @@ -28,10 +29,20 @@ def read_manifest(path): require(set(manifest) == {"schema", "recipe_revision", "requirements"} and manifest["schema"] == 1, "Unsupported requirements manifest") require(re.fullmatch(r"[0-9a-f]{40}", manifest["recipe_revision"]), "Pin the image recipe to a full SHA") - require(manifest["requirements"]["platform"] == "linux/amd64", "Unsupported image platform") + require(manifest["requirements"]["platform"] in ("linux/amd64", "linux/arm64"), + "Unsupported image platform") + if manifest["requirements"]["platform"] == "linux/arm64": + require(manifest["requirements"].get("profile") == "rust", "ARM64 requires the Rust-only profile") return manifest +def runtime_manifest(): + # Hosted selector jobs must not select a manifest for the eventual runner. + # Only env/verify use the actual job runner's OS and architecture. + return ARM64_MANIFEST if (os.environ.get("RUNNER_OS") == "Linux" + and os.environ.get("RUNNER_ARCH") == "ARM64") else MANIFEST + + def fingerprint(value): return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() @@ -46,11 +57,11 @@ def api(path): return json.load(response) -def changed_requirements(pr): +def changed_requirements(pr, manifest_path=MANIFEST): require(pr.get("changed_files", 0) <= 3000, "PR exceeds GitHub's file-list limit; requirements need explicit review") for page in range(1, 31): files = api(f"pulls/{pr['number']}/files?per_page=100&page={page}") - if any(f["filename"] == MANIFEST or f.get("previous_filename") == MANIFEST for f in files): + if any(f["filename"] == manifest_path or f.get("previous_filename") == manifest_path for f in files): return True if len(files) < 100: return False @@ -59,16 +70,20 @@ def changed_requirements(pr): def export_environment(manifest, output): lock = manifest["requirements"] - versions, android = lock["versions"], lock["android"] + versions = lock["versions"] values = { "CI_CARGO_LLVM_COV_VERSION": versions["llvm_cov"], "CI_CARGO_NEXTEST_VERSION": versions["nextest"], "CI_CARGO_MACHETE_VERSION": versions["machete"], - "CI_CARGO_NDK_VERSION": versions["cargo_ndk"], "CI_PROTOC_VERSION": versions["protoc"], "CI_JAVA_MAJOR": str(lock["java_major"]), - "CI_ANDROID_API": str(android["api"]), "CI_ANDROID_NDK": android["ndk"], - "CI_ANDROID_BUILD_TOOLS": android["build_tools"], } + if lock.get("profile", "full") == "full": + android = lock["android"] + values.update({ + "CI_CARGO_NDK_VERSION": versions["cargo_ndk"], + "CI_ANDROID_API": str(android["api"]), "CI_ANDROID_NDK": android["ndk"], + "CI_ANDROID_BUILD_TOOLS": android["build_tools"], + }) require(all(isinstance(value, str) and re.fullmatch(r"[0-9]+(?:[.][0-9]+){0,3}(?:[-+][A-Za-z0-9.-]+)?", value) for value in values.values()), "Versions must be version-pinned, newline-free values") with open(output, "a") as handle: @@ -76,8 +91,18 @@ def export_environment(manifest, output): handle.write(f"{key}={value}\n") -def select(manifest, kind, output, wait_seconds): - fallback = ["self-hosted", {"rust": "rust-ci", "kotlin": "kotlin-ci", "npm": "npm-pr"}[kind]] +def select(manifest, kind, output, wait_seconds, arch=None, validation=False): + require(arch in (None, "", "X64", "ARM64"), "Unsupported runner architecture") + require(not arch or kind == "rust", "Architecture selection is only supported for Rust") + require(not validation or (kind == "rust" and arch == "ARM64"), + "The validation-only pool is for explicitly selected ARM64 Rust jobs") + # Linux describes the runner process, not the physical host: ARM64 Linux + # containers on Macs remain in this pool; native macOS stays for Swift. + fallback = ["self-hosted"] + (["Linux"] if kind == "rust" else []) + if arch: + fallback.append(arch) + fallback.append("rust-ci-validation" if validation else + {"rust": "rust-ci", "kotlin": "kotlin-ci", "npm": "npm-pr"}[kind]) event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) requested = event.get("pull_request") labels, changed = fallback, False @@ -85,7 +110,22 @@ def select(manifest, kind, output, wait_seconds): pr = api(f"pulls/{requested['number']}") head = requested["head"]["sha"] require(pr["state"] == "open" and pr["head"]["sha"] == head, "This PR run has been superseded") - changed = changed_requirements(pr) + changed = changed_requirements(pr, ARM64_MANIFEST if arch == "ARM64" else MANIFEST) + if arch == "ARM64": + # ARM64 is explicitly provisioned from a published immutable image. + # The AMD64/KVM candidate publisher is not ARM64 validation. The + # separate ARM64 job verifies its exact lock/recipe on real capacity. + if changed: + import base64 + remote = api("contents/" + ARM64_MANIFEST + "?" + urllib.parse.urlencode({"ref": head})) + expected = json.loads(base64.b64decode(remote["content"])) + require(fingerprint(expected) == fingerprint(read_manifest(ARM64_MANIFEST)), + "Merge-tree ARM64 requirements differ from PR head; rebase before validation") + with open(output, "a") as handle: + handle.write("labels=" + json.dumps(fallback, separators=(",", ":")) + "\n") + handle.write("image_changed=" + str(changed).lower() + "\n") + print("Using explicitly provisioned ARM64 image capacity; exact runtime verification is required") + return if changed: # Use the exact PR requirement, not an accidental merge-tree mix # after both branches edited this file. Rebase such a PR first. @@ -127,19 +167,22 @@ def select(manifest, kind, output, wait_seconds): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("command", choices=["env", "verify", "select"]) - parser.add_argument("--manifest", default=MANIFEST) + parser.add_argument("--manifest") parser.add_argument("--env", default=os.environ.get("GITHUB_ENV")) parser.add_argument("--output", default=os.environ.get("GITHUB_OUTPUT")) parser.add_argument("--kind", choices=["rust", "kotlin", "npm"]) + parser.add_argument("--arch", choices=["", "X64", "ARM64"]) + parser.add_argument("--validation", action="store_true") parser.add_argument("--wait-seconds", type=int, default=7200) args = parser.parse_args() - manifest = read_manifest(args.manifest) + manifest_path = args.manifest or (MANIFEST if args.command == "select" else runtime_manifest()) + manifest = read_manifest(manifest_path) if args.command == "select": require(args.kind and args.output, "Runner selection needs kind and output") - select(manifest, args.kind, args.output, args.wait_seconds) + select(manifest, args.kind, args.output, args.wait_seconds, args.arch, args.validation) return if args.command == "verify": - subprocess.run(["ci-image-contract", "verify", args.manifest], check=True) + subprocess.run(["ci-image-contract", "verify", manifest_path], check=True) require(args.env, "Environment output file is required") export_environment(manifest, args.env) diff --git a/.github/scripts/tests/test_runner_image.py b/.github/scripts/tests/test_runner_image.py index 7e74e4a56c0..c690d3f33f2 100644 --- a/.github/scripts/tests/test_runner_image.py +++ b/.github/scripts/tests/test_runner_image.py @@ -4,6 +4,7 @@ import importlib.util import json import os +import sys from pathlib import Path import tempfile import unittest @@ -40,15 +41,15 @@ def setUp(self): "event": "pull_request_target", "conclusion": "success"}, } - def select(self, event=None, kind="rust"): + def select(self, event=None, kind="rust", arch=None, validation=False): self.event.write_text(json.dumps(event if event is not None else {"pull_request": self.pr})) with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(self.event)}), \ patch.object(runner, "api", side_effect=lambda path: self.responses[path]): - runner.select(self.manifest, kind, self.output, 0) + runner.select(self.manifest, kind, self.output, 0, arch, validation) return dict(line.split("=", 1) for line in self.output.read_text().splitlines()) def test_non_pr_and_unchanged_pr_use_existing_pool(self): - self.assertEqual(json.loads(self.select({})["labels"]), ["self-hosted", "rust-ci"]) + self.assertEqual(json.loads(self.select({})["labels"]), ["self-hosted", "Linux", "rust-ci"]) self.responses["pulls/4702/files?per_page=100&page=1"] = [{"filename": "Cargo.lock"}] self.assertEqual(self.select()["image_changed"], "false") @@ -97,6 +98,74 @@ def test_environment_export_rejects_multiline_values_before_writing(self): runner.export_environment(self.manifest, self.output) self.assertFalse(self.output.exists()) + def test_arm64_validation_never_consumes_amd64_candidate_status(self): + self.responses[f"commits/{HEAD}/status"]["statuses"] = [] + output = self.select(arch="ARM64") + self.assertEqual(json.loads(output["labels"]), ["self-hosted", "Linux", "ARM64", "rust-ci"]) + + def test_arm64_validation_rejects_merge_tree_drift(self): + arm = runner.read_manifest(ROOT / runner.ARM64_MANIFEST) + self.responses["pulls/4702/files?per_page=100&page=1"] = [{"filename": runner.ARM64_MANIFEST}] + remote = copy.deepcopy(arm) + remote["recipe_revision"] = "e" * 40 + self.responses[f"contents/{runner.ARM64_MANIFEST}?ref={HEAD}"] = { + "content": base64.b64encode(json.dumps(remote).encode()).decode()} + with patch.object(runner, "read_manifest", return_value=arm): + with self.assertRaisesRegex(ValueError, "rebase"): + self.select(arch="ARM64") + self.responses[f"contents/{runner.ARM64_MANIFEST}?ref={HEAD}"] = { + "content": base64.b64encode(json.dumps(arm).encode()).decode()} + with patch.object(runner, "read_manifest", return_value=arm): + self.assertEqual(self.select(arch="ARM64")["image_changed"], "true") + + def test_arm64_cannot_be_requested_for_android(self): + with self.assertRaisesRegex(ValueError, "only supported for Rust"): + self.select(kind="kotlin", arch="ARM64") + + def test_arm64_validation_pool_is_not_ordinary_capacity(self): + labels = json.loads(self.select({}, arch="ARM64", validation=True)["labels"]) + self.assertEqual(labels, ["self-hosted", "Linux", "ARM64", "rust-ci-validation"]) + self.assertNotIn("rust-ci", labels) + with self.assertRaisesRegex(ValueError, "validation-only pool"): + self.select({}, validation=True) + + def test_runtime_manifest_keeps_native_macos_and_amd64_separate(self): + for os_name, arch, expected in [ + ("Linux", "ARM64", runner.ARM64_MANIFEST), + ("Linux", "X64", runner.MANIFEST), + ("macOS", "ARM64", runner.MANIFEST), + ]: + with self.subTest(os=os_name, arch=arch), \ + patch.dict(os.environ, {"RUNNER_OS": os_name, "RUNNER_ARCH": arch}): + self.assertEqual(runner.runtime_manifest(), expected) + + def test_arm64_verify_uses_exact_contract_without_android_exports(self): + with patch.dict(os.environ, {"RUNNER_OS": "Linux", "RUNNER_ARCH": "ARM64", + "GITHUB_ENV": str(self.output)}), \ + patch.object(sys, "argv", ["runner-image.py", "verify"]), \ + patch.object(runner.subprocess, "run") as verify: + runner.main() + verify.assert_called_once_with(["ci-image-contract", "verify", runner.ARM64_MANIFEST], check=True) + values = dict(line.split("=", 1) for line in self.output.read_text().splitlines()) + self.assertEqual(values["CI_CARGO_NEXTEST_VERSION"], "0.9.144") + self.assertFalse(any("ANDROID" in name or "NDK" in name for name in values)) + + def test_shared_rust_toolchain_does_not_drift_between_architectures(self): + amd = self.manifest["requirements"] + arm = runner.read_manifest(ROOT / runner.ARM64_MANIFEST)["requirements"] + self.assertEqual(arm["versions"], {k: v for k, v in amd["versions"].items() if k != "cargo_ndk"}) + for key in ("rust_version", "rust_manifest_sha256", "apt_snapshot", "java_major", "client_codegen"): + self.assertEqual(amd[key], arm[key], key) + + def test_rejected_image_contract_stops_before_environment_export(self): + with patch.dict(os.environ, {"RUNNER_OS": "Linux", "RUNNER_ARCH": "ARM64", + "GITHUB_ENV": str(self.output)}), \ + patch.object(sys, "argv", ["runner-image.py", "verify"]), \ + patch.object(runner.subprocess, "run", side_effect=runner.subprocess.CalledProcessError(1, "ci-image-contract")): + with self.assertRaises(runner.subprocess.CalledProcessError): + runner.main() + self.assertFalse(self.output.exists()) + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/tests-rs-wallet.yml b/.github/workflows/tests-rs-wallet.yml index a63e96c4fe5..b18700d9ba3 100644 --- a/.github/workflows/tests-rs-wallet.yml +++ b/.github/workflows/tests-rs-wallet.yml @@ -20,10 +20,8 @@ # # No coverage here: code coverage is collected only by the nightly run of the # full workspace workflow (tests-rs-workspace.yml, `coverage` input), which -# also measures the wallet crates. Known trade-offs: this fast path stays -# pinned to the macOS runners (unlike the full workspace job, which schedules -# onto any `rust-ci` self-hosted runner), so wallet PRs depend on a mac -# runner being online; and the scoped `-p` builds feature-unify shared deps +# also measures the wallet crates. Both workflows use the same Linux image +# pool, including ARM64 Linux VMs on Macs. The scoped `-p` builds feature-unify shared deps # differently than `--workspace` builds, so the shared target/ carries an # extra artifact flavor. on: @@ -35,9 +33,35 @@ on: default: false jobs: + select-runner: + name: Select compatible runner image + if: >- + github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name == github.repository + || github.event.pull_request.head.repo.owner.login == 'thepastaclaw' + runs-on: ubuntu-24.04 + timeout-minutes: 135 + permissions: + contents: read + pull-requests: read + statuses: read + actions: read + outputs: + labels: ${{ steps.select.outputs.labels }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Select provisioned pool or exact PR candidate + id: select + env: + GH_TOKEN: ${{ github.token }} + run: python3 .github/scripts/runner-image.py select --kind rust + test-mac: - name: Wallet tests (macOS) - runs-on: [self-hosted, macOS, ARM64] + name: Wallet tests (Linux image) + needs: select-runner + runs-on: ${{ fromJSON(needs.select-runner.outputs.labels) }} if: >- github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository @@ -58,7 +82,7 @@ jobs: # shares the same runner workspace — wiping it on a wallet-only PR # would force the next nightly into a ~2.5 min cold rebuild. The size # guard below still removes the whole target/ if it outgrows the caps. - - name: Prune macOS runner disk before tests + - name: Prune runner disk before tests run: | for path in ../target-backup-before-*-clean-*; do if [ -e "$path" ]; then @@ -77,6 +101,11 @@ jobs: TARGET_MAX_MB=120000 MIN_FREE_MB=60000 + TOTAL=$(df -m . | awk 'NR == 2 {print $2}') + if [ "${TOTAL:-0}" -gt 0 ]; then + [ $((TOTAL / 3)) -lt "$TARGET_MAX_MB" ] && TARGET_MAX_MB=$((TOTAL / 3)) + [ $((TOTAL / 5)) -lt "$MIN_FREE_MB" ] && MIN_FREE_MB=$((TOTAL / 5)) + fi SIZE=$(du -sm target 2>/dev/null | awk '{print $1}' || echo 0) FREE=$(df -m . | awk 'NR == 2 {print $4}') SIZE=${SIZE:-0} @@ -89,34 +118,15 @@ jobs: rm -rf target fi - - name: Verify build dependencies (macOS) - run: | - # Persistent runners must be provisioned before accepting CI jobs. - echo "/opt/homebrew/bin" >> "$GITHUB_PATH" - echo "/opt/homebrew/opt/llvm/bin" >> "$GITHUB_PATH" - export PATH="/opt/homebrew/opt/llvm/bin:/opt/homebrew/bin:$PATH" - - missing=0 - for tool in cmake llvm-config; do - if ! "$tool" --version >/dev/null 2>&1; then - echo "::error::Missing or unusable $tool. Provision this dependency on the runner before running CI." - missing=1 - fi - done - for library in /opt/homebrew/opt/gmp/include/gmp.h /opt/homebrew/opt/gmp/lib/libgmp.dylib /opt/homebrew/opt/llvm/lib/libclang.dylib; do - if [ ! -r "$library" ]; then - echo "::error::Missing or unreadable $library. Provision this dependency on the runner before running CI." - missing=1 - fi - done - exit "$missing" - - name: Setup Rust uses: ./.github/actions/rust with: cache: false components: rustfmt, clippy + - name: Verify repository-pinned cargo-nextest + run: test "$(cargo nextest --version | awk 'NR == 1 {print $2}')" = "$CI_CARGO_NEXTEST_VERSION" + # Enforce the invariant the scoped --package lists below rely on: the # only workspace crate depending (transitively) on the wallet crates is # rs-unified-sdk-ffi. The same check runs on the full workspace path, diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index e9cb35fed0f..583055bd168 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -1,6 +1,14 @@ on: workflow_call: inputs: + runner-architecture: + description: Optional native architecture for image validation (X64 or ARM64) + type: string + default: '' + validate-arm64-image: + description: Use validation-only ARM64 capacity before admission to the ordinary Rust pool + type: boolean + default: false doctests-changed: description: Whether doc comments with code examples have changed type: boolean @@ -50,15 +58,17 @@ jobs: id: select env: GH_TOKEN: ${{ github.token }} - run: python3 .github/scripts/runner-image.py select --kind rust + RUNNER_ARCHITECTURE: ${{ inputs.runner-architecture }} + VALIDATE_ARM64_IMAGE: ${{ inputs.validate-arm64-image }} + run: | + extra=() + if [ "$VALIDATE_ARM64_IMAGE" = true ]; then extra+=(--validation); fi + python3 .github/scripts/runner-image.py select --kind rust --arch "$RUNNER_ARCHITECTURE" "${extra[@]}" test: name: Tests - # Scheduled onto whichever self-hosted runner is free — the macOS boxes or - # the Linux one. `rust-ci` is a custom label applied to exactly those - # runners; pairing it with `self-hosted` keeps the job off GitHub-hosted - # runners entirely, so untrusted code never reaches a hosted Linux VM by - # way of a label collision. + # Shared image pool: physical Linux hosts and ARM64 Linux VMs on Macs. + # Native macOS registrations remain available for Swift/Xcode. needs: select-runner runs-on: ${{ fromJSON(needs.select-runner.outputs.labels) }} # Fork PRs must not execute on any persistent runner, macOS or Linux. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b8016259dcc..0508af6eafb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -61,6 +61,7 @@ jobs: js-packages-direct: ${{ steps.override.outputs.js-packages-direct || steps.filter-js-direct.outputs.changes }} rs-packages: ${{ steps.override.outputs.rs-packages || steps.filter-rs.outputs.changes }} rs-workflows-changed: ${{ steps.filter-rs-workflows.outputs.rs-workflows }} + arm64-image-changed: ${{ steps.filter-rs-workflows.outputs.arm64-image }} rs-scope: ${{ steps.rs-scope.outputs.scope }} shielded-changed: ${{ steps.override.outputs.shielded-changed || steps.filter-shielded.outputs.shielded-changed }} doctests-changed: ${{ steps.override.outputs.doctests-changed || steps.filter-doctests.outputs.doctests-changed }} @@ -110,8 +111,11 @@ jobs: - .github/workflows/tests.yml - .github/scripts/check-wallet-closure.py - .github/runner-requirements.json + - .github/runner-requirements.arm64.json - .github/scripts/runner-image.py - .github/actions/rust/** + arm64-image: + - .github/runner-requirements.arm64.json - uses: dorny/paths-filter@v4 id: filter-e2e @@ -294,7 +298,7 @@ jobs: exit 0 fi - if echo "$CHANGED" | grep -qE '(^|/)Cargo\.(toml|lock)$|^rust-toolchain\.toml$|^\.github/runner-requirements\.json$|^\.github/scripts/runner-image\.py$|^\.github/actions/rust/|^\.github/workflows/tests\.yml$|^\.github/workflows/tests-rs-workspace\.yml$'; then + if echo "$CHANGED" | grep -qE '(^|/)Cargo\.(toml|lock)$|^rust-toolchain\.toml$|^\.github/runner-requirements(\.arm64)?\.json$|^\.github/scripts/runner-image\.py$|^\.github/actions/rust/|^\.github/workflows/tests\.yml$|^\.github/workflows/tests-rs-workspace\.yml$'; then echo "shielded-changed=true" >> "$GITHUB_OUTPUT" echo "Build configuration changed — shielded tests will run" exit 0 @@ -477,6 +481,23 @@ jobs: # test phases without instrumentation and upload nothing to Codecov. coverage: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.coverage == true) }} + # The AMD64 candidate publisher cannot prove an ARM64 image. Changes to + # its separate manifest need a real full workspace job on that architecture, + # in addition to any ordinary/AMD64 candidate job above. Provision the exact + # ARM64 image before merging; a hosted build or skipped fork job is not proof. + rs-arm64-image-tests: + name: ARM64 runner image validation + needs: changes + if: ${{ needs.changes.outputs.arm64-image-changed == 'true' }} + secrets: inherit + uses: ./.github/workflows/tests-rs-workspace.yml + with: + runner-architecture: ARM64 + validate-arm64-image: true + doctests-changed: true + shielded-changed: true + coverage: false + # Fast path: only wallet crates changed, so run the scoped wallet suite # instead of the full workspace job above (the two are mutually exclusive # via `rs-scope`). From b281e39f9879f65b71f2627017547823358c8476 Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:33:26 +0000 Subject: [PATCH 084/113] ci: isolate ARM64 image updates from the ordinary runner pool --- .github/SELF_HOSTED_RUNNER.md | 3 +++ .github/scripts/runner-image.py | 5 +++++ .github/scripts/tests/test_runner_image.py | 17 +++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/.github/SELF_HOSTED_RUNNER.md b/.github/SELF_HOSTED_RUNNER.md index c27e26202d1..6978bff1889 100644 --- a/.github/SELF_HOSTED_RUNNER.md +++ b/.github/SELF_HOSTED_RUNNER.md @@ -146,6 +146,9 @@ ARM64 requirements currently use explicit operator deployment, not that publishe this manifest changes. Exact lock/recipe mismatch fails before compilation. Its selector compares the PR-head manifest with the checked-out merge tree; an AMD64 candidate status cannot satisfy ARM64 validation. + That PR's ordinary Rust job stays on AMD64 so it cannot land on ARM64 + production capacity still running the old image; unrelated PRs use both + architectures as usual. 4. Require successful real ARM64 validation before merging the requirements and rolling out other Mac-backed capacity. If AMD64 requirements also change, their separate Rust/Kotlin candidate gates still apply. diff --git a/.github/scripts/runner-image.py b/.github/scripts/runner-image.py index d11cff8d14b..34bffbdf8bb 100644 --- a/.github/scripts/runner-image.py +++ b/.github/scripts/runner-image.py @@ -111,6 +111,11 @@ def select(manifest, kind, output, wait_seconds, arch=None, validation=False): head = requested["head"]["sha"] require(pr["state"] == "open" and pr["head"]["sha"] == head, "This PR run has been superseded") changed = changed_requirements(pr, ARM64_MANIFEST if arch == "ARM64" else MANIFEST) + if not arch and kind == "rust" and changed_requirements(pr, ARM64_MANIFEST): + # Only validation capacity has the new ARM64 image before rollout. + # Keep this PR's ordinary job on unchanged AMD64 capacity while its + # separate ARM64 job proves the new manifest on the validation pool. + labels = fallback = ["self-hosted", "Linux", "X64", "rust-ci"] if arch == "ARM64": # ARM64 is explicitly provisioned from a published immutable image. # The AMD64/KVM candidate publisher is not ARM64 validation. The diff --git a/.github/scripts/tests/test_runner_image.py b/.github/scripts/tests/test_runner_image.py index c690d3f33f2..f0f9773654f 100644 --- a/.github/scripts/tests/test_runner_image.py +++ b/.github/scripts/tests/test_runner_image.py @@ -103,6 +103,23 @@ def test_arm64_validation_never_consumes_amd64_candidate_status(self): output = self.select(arch="ARM64") self.assertEqual(json.loads(output["labels"]), ["self-hosted", "Linux", "ARM64", "rust-ci"]) + def test_arm64_manifest_change_does_not_use_stale_ordinary_arm64_runners(self): + self.responses["pulls/4702/files?per_page=100&page=1"] = [{"filename": runner.ARM64_MANIFEST}] + output = self.select() + self.assertEqual(json.loads(output["labels"]), ["self-hosted", "Linux", "X64", "rust-ci"]) + self.assertEqual(output["image_changed"], "false") + # Mac-backed ARM64 capacity remains available to ordinary, unrelated PRs. + self.responses["pulls/4702/files?per_page=100&page=1"] = [{"filename": "Cargo.lock"}] + self.assertEqual(json.loads(self.select()["labels"]), ["self-hosted", "Linux", "rust-ci"]) + + def test_both_manifest_changes_still_require_exact_amd64_candidate(self): + self.responses["pulls/4702/files?per_page=100&page=1"] = [ + {"filename": runner.MANIFEST}, {"filename": runner.ARM64_MANIFEST}] + output = self.select() + self.assertEqual(json.loads(output["labels"]), ["self-hosted", "Linux", "X64", + f"platform-image-pr-4702-{HEAD}-{DIGEST[7:]}-rust"]) + self.assertEqual(output["image_changed"], "true") + def test_arm64_validation_rejects_merge_tree_drift(self): arm = runner.read_manifest(ROOT / runner.ARM64_MANIFEST) self.responses["pulls/4702/files?per_page=100&page=1"] = [{"filename": runner.ARM64_MANIFEST}] From 16afde9ac89e88f3f7a5d86a65942404e81f13aa Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:42:21 +0000 Subject: [PATCH 085/113] fix(ci): keep wallet FFI test pointer cast portable on ARM64 --- .../rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs b/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs index 2afc08840fc..144a8961aa8 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs @@ -628,7 +628,7 @@ mod resolve_classification_tests { ) -> i32 { let phrase = b"not a bip39 phrase at all"; assert!(cap >= phrase.len()); - std::ptr::copy_nonoverlapping(phrase.as_ptr(), out as *mut u8, phrase.len()); + std::ptr::copy_nonoverlapping(phrase.as_ptr(), out.cast::(), phrase.len()); *out_len = phrase.len(); mnemonic_resolver_result::SUCCESS } From e776f957a1ba66ac57717a653871bf89382087a6 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 28 Sep 2026 16:13:49 +0300 Subject: [PATCH 086/113] fix(sdk): bound each DAPI request attempt, including the response body (#4973) Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 3 + packages/rs-dapi-client/Cargo.toml | 6 +- .../rs-dapi-client/src/connection_pool.rs | 244 ++++++++++-- packages/rs-dapi-client/src/dapi_client.rs | 356 +++++++++++++++++- .../rs-dapi-client/src/request_settings.rs | 64 +++- packages/rs-dapi-client/src/transport.rs | 18 + packages/rs-dapi-client/src/transport/grpc.rs | 62 ++- .../src/transport/tonic_channel.rs | 15 + .../rs-dapi-client/tests/request_deadline.rs | 200 ++++++++++ .../tests/stalled_response_body.rs | 216 +++++++++++ 10 files changed, 1136 insertions(+), 48 deletions(-) create mode 100644 packages/rs-dapi-client/tests/request_deadline.rs create mode 100644 packages/rs-dapi-client/tests/stalled_response_body.rs diff --git a/Cargo.lock b/Cargo.lock index 1f56ac26e70..a28e354c7e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6417,9 +6417,11 @@ dependencies = [ "futures", "getrandom 0.2.17", "gloo-timers", + "h2", "hex", "http", "http-serde", + "hyper-util", "lru", "rand 0.8.6", "serde", @@ -6428,6 +6430,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tonic-web-wasm-client", + "tower 0.5.3", "tracing", "wasm-bindgen-futures", ] diff --git a/packages/rs-dapi-client/Cargo.toml b/packages/rs-dapi-client/Cargo.toml index da97e5b4f98..36043a61304 100644 --- a/packages/rs-dapi-client/Cargo.toml +++ b/packages/rs-dapi-client/Cargo.toml @@ -68,7 +68,11 @@ serde_json = { version = "1.0.140", optional = true } chrono = { version = "0.4.38", features = ["serde"] } [dev-dependencies] -tokio = { version = "1.40", features = ["macros"] } +tokio = { version = "1.40", features = ["macros", "rt", "test-util", "io-util"] } +# In-memory HTTP/2 server and connector for the stalled-response-body tests. +h2 = "0.4" +hyper-util = { version = "0.1", features = ["tokio"] } +tower = { version = "0.5", features = ["util"] } [package.metadata.cargo-machete] diff --git a/packages/rs-dapi-client/src/connection_pool.rs b/packages/rs-dapi-client/src/connection_pool.rs index bf51838d64f..bd826cc363f 100644 --- a/packages/rs-dapi-client/src/connection_pool.rs +++ b/packages/rs-dapi-client/src/connection_pool.rs @@ -20,7 +20,30 @@ pub(crate) const DEFAULT_POOL_CAPACITY: usize = 50; /// Cloning the pool will create a new reference to the same pool. #[derive(Debug, Clone)] pub struct ConnectionPool { - inner: Arc>>, + inner: Arc>, +} + +#[derive(Debug)] +struct PoolState { + connections: LruCache, + /// Generation of the connection pooled most recently. + generation: u64, +} + +/// A pooled connection and the generation it was pooled at. +#[derive(Debug)] +struct Pooled { + generation: u64, + item: PoolItem, +} + +/// Identity of a pooled connection: the client type, the node, and the +/// connection-affecting settings (`None` when none were given). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct PoolKey { + prefix: PoolPrefix, + uri: String, + connection: Option, } impl ConnectionPool { @@ -32,9 +55,10 @@ impl ConnectionPool { /// Panics if the capacity is zero. pub fn new(capacity: usize) -> Self { Self { - inner: Arc::new(Mutex::new(LruCache::new( - capacity.try_into().expect("must be non-zero"), - ))), + inner: Arc::new(Mutex::new(PoolState { + connections: LruCache::new(capacity.try_into().expect("must be non-zero")), + generation: 0, + })), } } } @@ -59,7 +83,12 @@ impl ConnectionPool { settings: Option<&AppliedRequestSettings>, ) -> Option { let key = Self::key(prefix, uri, settings); - self.inner.lock().expect("must lock").get(&key).cloned() + self.inner + .lock() + .expect("must lock") + .connections + .get(&key) + .map(|pooled| pooled.item.clone()) } /// Get value from cache or create it using provided closure. @@ -77,38 +106,110 @@ impl ConnectionPool { settings: Option<&AppliedRequestSettings>, create: impl FnOnce() -> Result, ) -> Result { - if let Some(cli) = self.get(prefix, uri, settings) { - return Ok(cli); - } + self.get_or_create_with_generation(prefix, uri, settings, create) + .map(|(item, _)| item) + } - let cli = create(); - if let Ok(cli) = &cli { - self.put(uri, settings, cli.clone()); + /// Like [ConnectionPool::get_or_create], and also returns the generation + /// the returned connection was pooled at (see [ConnectionPool::generation]). + /// + /// The generation is read under the same lock that finds or stores the + /// connection, so it is the returned connection's own even when other + /// threads replace it right afterwards. + pub fn get_or_create_with_generation( + &self, + prefix: PoolPrefix, + uri: &Uri, + settings: Option<&AppliedRequestSettings>, + create: impl FnOnce() -> Result, + ) -> Result<(PoolItem, u64), E> { + let key = Self::key(prefix, uri, settings); + let cached = self + .inner + .lock() + .expect("must lock") + .connections + .get(&key) + .map(|pooled| (pooled.item.clone(), pooled.generation)); + if let Some(cached) = cached { + return Ok(cached); } - cli + + let item = create()?; + let generation = self.put_with_generation(uri, settings, item.clone()); + Ok((item, generation)) } /// Put item into the pool for the given uri and settings. pub fn put(&self, uri: &Uri, settings: Option<&AppliedRequestSettings>, value: PoolItem) { + self.put_with_generation(uri, settings, value); + } + + /// Put item into the pool and return the generation it was pooled at. + fn put_with_generation( + &self, + uri: &Uri, + settings: Option<&AppliedRequestSettings>, + value: PoolItem, + ) -> u64 { let key = Self::key(&value, uri, settings); - self.inner.lock().expect("must lock").put(key, value); + let mut state = self.inner.lock().expect("must lock"); + state.generation += 1; + let generation = state.generation; + state.connections.put( + key, + Pooled { + generation, + item: value, + }, + ); + generation + } + + /// Generation of the connection pooled most recently. Every connection + /// put into the pool afterwards gets a higher generation. + pub fn generation(&self) -> u64 { + self.inner.lock().expect("must lock").generation + } + + /// Drop every connection to `uri` pooled at or before `generation`, + /// whatever its prefix and connection settings. + /// + /// A request that misses its deadline may have been sent over a half-open + /// connection: the network path died after the request left, and nothing + /// on the idle channel would ever notice. Keeping it pooled would stall + /// the next request sent to the same node, so the executor evicts it and + /// the next request dials a fresh connection. + /// + /// Connections pooled after `generation` stay. They were dialed after the + /// timed-out attempt took its connection, for example by a concurrent + /// request that already timed out on the same node and reconnected. + pub fn remove_uri(&self, uri: &Uri, generation: u64) { + let uri = uri.to_string(); + let mut state = self.inner.lock().expect("must lock"); + let stale: Vec = state + .connections + .iter() + .filter(|(key, pooled)| key.uri == uri && pooled.generation <= generation) + .map(|(key, _)| key.clone()) + .collect(); + for key in stale { + state.connections.pop(&key); + } } fn key>( class: C, uri: &Uri, settings: Option<&AppliedRequestSettings>, - ) -> String { - let prefix: PoolPrefix = class.into(); + ) -> PoolKey { // Only connection-affecting settings participate in the key (see // `AppliedRequestSettings::connection_key`), so requests differing only // in per-request knobs (timeout, retries, banning) share a connection. - // The settings segment is always present (and contains no `:`), so the - // two branches cannot produce colliding shapes even for a URI whose - // path mimics a key fragment. - match settings { - Some(settings) => format!("{}:{}:{}", prefix, uri, settings.connection_key()), - None => format!("{}:{}:none", prefix, uri), + PoolKey { + prefix: class.into(), + uri: uri.to_string(), + connection: settings.map(AppliedRequestSettings::connection_key), } } } @@ -164,6 +265,7 @@ impl From for CoreGrpcClient { } /// Prefix for the item in the pool. Used to distinguish between Core and Platform clients. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PoolPrefix { Core, Platform, @@ -402,6 +504,106 @@ mod tests { assert!(result.is_some()); } + #[tokio::test] + async fn should_remove_every_pooled_connection_to_an_uri() { + let pool = ConnectionPool::new(10); + let uri = test_uri(); + let longer_port = Uri::from_str("http://127.0.0.1:30001").unwrap(); + let connect_timeout = RequestSettings { + connect_timeout: Some(Duration::from_secs(3)), + ..RequestSettings::default() + } + .finalize(); + + pool.put(&uri, None, make_platform_pool_item()); + pool.put(&uri, None, make_core_pool_item()); + pool.put(&uri, Some(&connect_timeout), make_platform_pool_item()); + pool.put(&longer_port, None, make_platform_pool_item()); + + pool.remove_uri(&uri, pool.generation()); + + assert!(pool.get(PoolPrefix::Platform, &uri, None).is_none()); + assert!(pool.get(PoolPrefix::Core, &uri, None).is_none()); + assert!(pool + .get(PoolPrefix::Platform, &uri, Some(&connect_timeout)) + .is_none()); + assert!( + pool.get(PoolPrefix::Platform, &longer_port, None).is_some(), + "a URI that merely starts with the evicted one must stay pooled" + ); + } + + #[tokio::test] + async fn should_remove_only_the_exact_uri_when_another_extends_it_past_a_colon() { + let cases = [ + ("http://node", "http://node:443"), + ("http://node/grpc", "http://node/grpc:8080"), + ]; + for (evicted, kept) in cases { + let pool = ConnectionPool::new(10); + let evicted = Uri::from_str(evicted).unwrap(); + let kept = Uri::from_str(kept).unwrap(); + pool.put(&evicted, None, make_platform_pool_item()); + pool.put(&kept, None, make_platform_pool_item()); + + pool.remove_uri(&evicted, pool.generation()); + + assert!( + pool.get(PoolPrefix::Platform, &evicted, None).is_none(), + "{evicted} must be evicted" + ); + assert!( + pool.get(PoolPrefix::Platform, &kept, None).is_some(), + "{kept} must stay pooled when {evicted} is evicted" + ); + } + } + + #[tokio::test] + async fn should_keep_connections_pooled_after_the_given_generation() { + let pool = ConnectionPool::new(10); + let uri = test_uri(); + pool.put(&uri, None, make_platform_pool_item()); + let used_by_attempt = pool.generation(); + // Replaced after the attempt took its connection. + pool.put(&uri, None, make_platform_pool_item()); + + pool.remove_uri(&uri, used_by_attempt); + assert!( + pool.get(PoolPrefix::Platform, &uri, None).is_some(), + "a connection pooled after the given generation must stay" + ); + + pool.remove_uri(&uri, pool.generation()); + assert!(pool.get(PoolPrefix::Platform, &uri, None).is_none()); + } + + #[tokio::test] + async fn should_return_the_generation_of_the_connection_it_takes() { + let pool = ConnectionPool::new(10); + let uri = test_uri(); + + let (_, created) = pool + .get_or_create_with_generation(PoolPrefix::Platform, &uri, None, || { + Ok::<_, String>(make_platform_pool_item()) + }) + .unwrap(); + assert_eq!(created, pool.generation()); + + let (_, taken) = pool + .get_or_create_with_generation(PoolPrefix::Platform, &uri, None, || { + Err("the pooled connection must be reused".to_string()) + }) + .unwrap(); + pool.put(&uri, None, make_platform_pool_item()); + + assert_eq!(taken, created); + assert!( + taken < pool.generation(), + "a replacement pooled afterwards must get a higher generation" + ); + } + #[tokio::test] async fn test_connection_pool_clone_shares_data() { let pool = ConnectionPool::new(10); diff --git a/packages/rs-dapi-client/src/dapi_client.rs b/packages/rs-dapi-client/src/dapi_client.rs index e37111ed0be..5557af91361 100644 --- a/packages/rs-dapi-client/src/dapi_client.rs +++ b/packages/rs-dapi-client/src/dapi_client.rs @@ -4,6 +4,8 @@ use dapi_grpc::mock::Mockable; use dapi_grpc::tonic::async_trait; #[cfg(not(target_arch = "wasm32"))] use dapi_grpc::tonic::transport::Certificate; +#[cfg(not(target_arch = "wasm32"))] +use dapi_grpc::tonic::Status; use std::fmt::{Debug, Display}; use std::time::Duration; use tracing::Instrument; @@ -732,6 +734,325 @@ mod tests { let display = format!("{}", err); assert!(display.contains("address list error")); } + + /// Executor-level coverage for evicting the pooled connection of a node + /// whose attempt missed its deadline. + #[cfg(not(target_arch = "wasm32"))] + mod deadline_pool_eviction { + use super::*; + use crate::connection_pool::{PoolItem, PoolPrefix}; + use crate::transport::{BoxFuture, PlatformGrpcClient}; + use crate::Uri; + use dapi_grpc::tonic::transport::Channel; + use dapi_grpc::tonic::Code; + use std::sync::{Arc, Mutex}; + + /// Takes its connection from the executor's pool, as the real gRPC + /// clients do, so the pool holds an entry for every node dialed. + struct PooledClient { + uri: Uri, + } + + impl PooledClient { + fn pooled( + uri: Uri, + settings: Option<&AppliedRequestSettings>, + pool: &ConnectionPool, + ) -> Result { + pool.get_or_create(PoolPrefix::Platform, &uri, settings, || { + Ok::<_, TransportError>(PoolItem::Platform(PlatformGrpcClient::new( + Channel::builder(uri.clone()).connect_lazy(), + ))) + })?; + Ok(Self { uri }) + } + } + + impl TransportClient for PooledClient { + fn with_uri(uri: Uri, pool: &ConnectionPool) -> Result { + Self::pooled(uri, None, pool) + } + + fn with_uri_and_settings( + uri: Uri, + settings: &AppliedRequestSettings, + pool: &ConnectionPool, + ) -> Result { + Self::pooled(uri, Some(settings), pool) + } + } + + #[derive(Debug)] + struct Pong; + + impl Mockable for Pong {} + + /// Never answers on the first node it is sent to; answers at once + /// everywhere else. + #[derive(Clone, Debug, Default)] + struct StallFirstRequest { + stalled: Arc>>, + } + + impl Mockable for StallFirstRequest {} + + impl TransportRequest for StallFirstRequest { + type Client = PooledClient; + type Response = Pong; + + const SETTINGS_OVERRIDES: RequestSettings = RequestSettings::default(); + + fn method_name(&self) -> &'static str { + "stall_first" + } + + fn execute_transport<'c>( + self, + client: &'c mut Self::Client, + _settings: &AppliedRequestSettings, + ) -> BoxFuture<'c, Result> { + let mut stalled = self.stalled.lock().expect("stall lock"); + let target = stalled.get_or_insert_with(|| client.uri.clone()); + if *target == client.uri { + Box::pin(futures::future::pending()) + } else { + Box::pin(async { Ok(Pong) }) + } + } + } + + #[tokio::test(start_paused = true)] + async fn should_evict_the_pooled_connection_of_a_node_that_missed_its_deadline() { + let request = StallFirstRequest::default(); + let client = DapiClient::new( + "http://127.0.0.1:10001,http://127.0.0.1:10002" + .parse() + .expect("valid address list"), + RequestSettings::default(), + ); + + let response = client + .execute(request.clone(), RequestSettings::default()) + .await + .expect("the other node must answer"); + + let stalled = request + .stalled + .lock() + .expect("stall lock") + .clone() + .expect("a node stalled"); + // The executor's applied settings for these defaults (no CA + // certificate) produce the same pool key. + let settings = RequestSettings::default().finalize(); + assert!( + client + .pool + .get(PoolPrefix::Platform, &stalled, Some(&settings)) + .is_none(), + "the stalled node's connection must be evicted" + ); + assert!( + client + .pool + .get( + PoolPrefix::Platform, + response.address.uri(), + Some(&settings) + ) + .is_some(), + "the healthy node's connection must stay pooled" + ); + } + + /// Replaces its node's pooled connection, as a concurrent request + /// that timed out on the node and reconnected would, then never + /// answers. + #[derive(Clone, Debug)] + struct ReplaceConnectionThenStall { + pool: ConnectionPool, + } + + impl Mockable for ReplaceConnectionThenStall {} + + impl TransportRequest for ReplaceConnectionThenStall { + type Client = PooledClient; + type Response = Pong; + + const SETTINGS_OVERRIDES: RequestSettings = RequestSettings::default(); + + fn method_name(&self) -> &'static str { + "replace_connection_then_stall" + } + + fn execute_transport<'c>( + self, + client: &'c mut Self::Client, + settings: &AppliedRequestSettings, + ) -> BoxFuture<'c, Result> { + self.pool.put( + &client.uri, + Some(settings), + PoolItem::Platform(PlatformGrpcClient::new( + Channel::builder(client.uri.clone()).connect_lazy(), + )), + ); + Box::pin(futures::future::pending()) + } + } + + #[tokio::test(start_paused = true)] + async fn should_keep_a_connection_pooled_after_the_attempt_took_its_own() { + let client = DapiClient::new( + "http://127.0.0.1:10001" + .parse() + .expect("valid address list"), + RequestSettings { + retries: Some(0), + ..RequestSettings::default() + }, + ); + let request = ReplaceConnectionThenStall { + pool: client.pool.clone(), + }; + + let error = client + .execute(request, RequestSettings::default()) + .await + .expect_err("the only node never answers"); + + assert!( + matches!( + &error.inner, + DapiClientError::Transport(TransportError::Grpc(status)) + if status.code() == Code::DeadlineExceeded + ), + "expected DeadlineExceeded, got {:?}", + error.inner + ); + let uri = error.address.expect("the attempted node").uri().clone(); + let settings = RequestSettings::default().finalize(); + assert!( + client + .pool + .get(PoolPrefix::Platform, &uri, Some(&settings)) + .is_some(), + "a connection pooled after the attempt took its own must stay pooled" + ); + } + + /// Takes its node's pooled connection, which another worker replaces + /// before the constructor returns. + struct ReplacedWhileBuildingClient; + + impl ReplacedWhileBuildingClient { + fn build( + uri: Uri, + settings: Option<&AppliedRequestSettings>, + pool: &ConnectionPool, + ) -> Result<(Self, u64), TransportError> { + let connect = || { + Ok::<_, TransportError>(PoolItem::Platform(PlatformGrpcClient::new( + Channel::builder(uri.clone()).connect_lazy(), + ))) + }; + let (_, generation) = pool.get_or_create_with_generation( + PoolPrefix::Platform, + &uri, + settings, + connect, + )?; + // What a concurrent request that timed out and reconnected + // would pool. + pool.put(&uri, settings, connect()?); + Ok((Self, generation)) + } + } + + impl TransportClient for ReplacedWhileBuildingClient { + fn with_uri(uri: Uri, pool: &ConnectionPool) -> Result { + Self::build(uri, None, pool).map(|(client, _)| client) + } + + fn with_uri_and_settings( + uri: Uri, + settings: &AppliedRequestSettings, + pool: &ConnectionPool, + ) -> Result { + Self::build(uri, Some(settings), pool).map(|(client, _)| client) + } + + fn with_uri_and_settings_and_generation( + uri: Uri, + settings: &AppliedRequestSettings, + pool: &ConnectionPool, + ) -> Result<(Self, u64), TransportError> { + Self::build(uri, Some(settings), pool) + } + } + + /// Never answers. + #[derive(Clone, Debug)] + struct StallOnReplacedConnection; + + impl Mockable for StallOnReplacedConnection {} + + impl TransportRequest for StallOnReplacedConnection { + type Client = ReplacedWhileBuildingClient; + type Response = Pong; + + const SETTINGS_OVERRIDES: RequestSettings = RequestSettings::default(); + + fn method_name(&self) -> &'static str { + "stall_on_replaced_connection" + } + + fn execute_transport<'c>( + self, + _client: &'c mut Self::Client, + _settings: &AppliedRequestSettings, + ) -> BoxFuture<'c, Result> { + Box::pin(futures::future::pending()) + } + } + + #[tokio::test(start_paused = true)] + async fn should_keep_a_connection_pooled_while_the_client_was_being_built() { + let client = DapiClient::new( + "http://127.0.0.1:10001" + .parse() + .expect("valid address list"), + RequestSettings { + retries: Some(0), + ..RequestSettings::default() + }, + ); + + let error = client + .execute(StallOnReplacedConnection, RequestSettings::default()) + .await + .expect_err("the only node never answers"); + + assert!( + matches!( + &error.inner, + DapiClientError::Transport(TransportError::Grpc(status)) + if status.code() == Code::DeadlineExceeded + ), + "expected DeadlineExceeded, got {:?}", + error.inner + ); + let uri = error.address.expect("the attempted node").uri().clone(); + let settings = RequestSettings::default().finalize(); + assert!( + client + .pool + .get(PoolPrefix::Platform, &uri, Some(&settings)) + .is_some(), + "a connection pooled while the attempt's client was being built must stay pooled" + ); + } + } } #[async_trait] @@ -812,13 +1133,17 @@ impl DapiRequestExecutor for DapiClient { let response_name = request.response_name(); // Try to create transport client - let transport_client_result = R::Client::with_uri_and_settings( + let transport_client_result = R::Client::with_uri_and_settings_and_generation( address.uri().clone(), &applied_settings, &self.pool, ); - let mut transport_client = match transport_client_result { + // `pool_generation` is the pool generation of the connection + // this attempt uses. A deadline eviction below keeps + // connections pooled later: a concurrent request may already + // have evicted this one and reconnected. + let (mut transport_client, pool_generation) = match transport_client_result { Ok(client) => client, Err(transport_error) => { let can_retry_error = transport_error.can_retry(); @@ -857,15 +1182,36 @@ impl DapiRequestExecutor for DapiClient { }; // Execute the transport request - let result = transport_request + let attempt = transport_request .execute_transport(&mut transport_client, &applied_settings) .instrument(tracing::trace_span!( "execute_request", ?address, settings = ?applied_settings, method = request.method_name(), - )) - .await; + )); + // tonic enforces the `grpc-timeout` header only until the + // response headers arrive; reading the body has no limit, so an + // attempt over a half-open connection would never return. Bound + // the whole attempt, and drop the pooled connection it used. + #[cfg(not(target_arch = "wasm32"))] + let result = match applied_settings.attempt_deadline() { + Some(deadline) => match tokio::time::timeout(deadline, attempt).await { + Ok(result) => result, + Err(_) => { + self.pool.remove_uri(address.uri(), pool_generation); + Err(TransportError::Grpc(Status::deadline_exceeded(format!( + "no complete response within {deadline:?}" + )))) + } + }, + None => attempt.await, + }; + #[cfg(target_arch = "wasm32")] + let result = { + let _ = pool_generation; + attempt.await + }; let execution_result = match result { Ok(response) => { diff --git a/packages/rs-dapi-client/src/request_settings.rs b/packages/rs-dapi-client/src/request_settings.rs index 17f45d13b65..b503d21e9e5 100644 --- a/packages/rs-dapi-client/src/request_settings.rs +++ b/packages/rs-dapi-client/src/request_settings.rs @@ -21,7 +21,15 @@ const DEFAULT_BAN_FAILED_ADDRESS: bool = true; pub struct RequestSettings { /// Timeout for establishing a connection. pub connect_timeout: Option, - /// Timeout for single request (soft limit). + /// Timeout for a single request attempt. + /// + /// It is sent to the server as the `grpc-timeout` header. On native targets + /// it also bounds the whole attempt on the client: an attempt still running + /// after `timeout + connect_timeout` fails with `DeadlineExceeded` and is + /// retried like any other retryable error. For unary RPCs the attempt runs + /// from dispatch through the response body and trailers; for streaming + /// RPCs it ends when the response headers arrive, so consuming the + /// returned stream is not bounded by it. Zero disables both limits. /// /// Note that the total maximum time of execution can exceed `(timeout + connect_timeout) * retries` /// as it accounts for internal processing time between retries. @@ -88,7 +96,7 @@ impl RequestSettings { pub struct AppliedRequestSettings { /// Timeout for establishing a connection. pub connect_timeout: Option, - /// Timeout for a request. + /// Timeout for a single request attempt; see [RequestSettings::timeout]. pub timeout: Duration, /// Number of retries until returning the last error. pub retries: usize, @@ -110,6 +118,22 @@ impl AppliedRequestSettings { self } + /// Upper bound for one request attempt: `timeout` plus `connect_timeout`, + /// the bound documented on [RequestSettings::timeout]. A unary attempt + /// covers dispatch through the response body and trailers; a streaming + /// attempt ends when the response headers arrive. `None` when `timeout` + /// is zero, which means "no limit" (the transport then omits the + /// `grpc-timeout` header as well). + pub fn attempt_deadline(&self) -> Option { + if self.timeout.is_zero() { + return None; + } + Some( + self.timeout + .saturating_add(self.connect_timeout.unwrap_or_default()), + ) + } + /// Cache key fragment for the [ConnectionPool](crate::ConnectionPool), /// covering only the fields that affect the constructed transport client: /// connect timeout, response decoding limit and CA certificate. @@ -254,6 +278,42 @@ mod tests { assert!(result.ca_certificate.is_some()); } + #[test] + fn should_bound_attempt_by_timeout_plus_connect_timeout() { + let applied = RequestSettings { + timeout: Some(Duration::from_secs(10)), + connect_timeout: Some(Duration::from_secs(3)), + ..RequestSettings::default() + } + .finalize(); + assert_eq!(applied.attempt_deadline(), Some(Duration::from_secs(13))); + + let default = RequestSettings::default().finalize(); + assert_eq!(default.attempt_deadline(), Some(Duration::from_secs(10))); + } + + #[test] + fn should_not_bound_attempt_when_timeout_is_zero() { + let applied = RequestSettings { + timeout: Some(Duration::ZERO), + connect_timeout: Some(Duration::from_secs(3)), + ..RequestSettings::default() + } + .finalize(); + assert_eq!(applied.attempt_deadline(), None); + } + + #[test] + fn should_saturate_attempt_deadline_instead_of_overflowing() { + let applied = RequestSettings { + timeout: Some(Duration::MAX), + connect_timeout: Some(Duration::from_secs(1)), + ..RequestSettings::default() + } + .finalize(); + assert_eq!(applied.attempt_deadline(), Some(Duration::MAX)); + } + #[test] fn test_connection_key_ignores_per_request_settings() { let custom = RequestSettings { diff --git a/packages/rs-dapi-client/src/transport.rs b/packages/rs-dapi-client/src/transport.rs index bea0968fd2f..bad291c6b26 100644 --- a/packages/rs-dapi-client/src/transport.rs +++ b/packages/rs-dapi-client/src/transport.rs @@ -153,6 +153,24 @@ pub trait TransportClient: Send + Sized { settings: &AppliedRequestSettings, pool: &ConnectionPool, ) -> Result; + + /// Build client using node's url and [AppliedRequestSettings], together + /// with the pool generation of the connection it took (see + /// [ConnectionPool::generation]). When an attempt misses its deadline, + /// the executor evicts the node's connections up to that generation. + /// + /// The default reads the pool's generation after building the client, so + /// it can also cover a connection another thread pooled in between. + /// Clients that take their connection from `pool` override it with the + /// generation [ConnectionPool::get_or_create_with_generation] returns. + fn with_uri_and_settings_and_generation( + uri: Uri, + settings: &AppliedRequestSettings, + pool: &ConnectionPool, + ) -> Result<(Self, u64), TransportError> { + let client = Self::with_uri_and_settings(uri, settings, pool)?; + Ok((client, pool.generation())) + } } #[cfg(test)] diff --git a/packages/rs-dapi-client/src/transport/grpc.rs b/packages/rs-dapi-client/src/transport/grpc.rs index 7e419e9930b..1f919c9df2f 100644 --- a/packages/rs-dapi-client/src/transport/grpc.rs +++ b/packages/rs-dapi-client/src/transport/grpc.rs @@ -32,12 +32,17 @@ impl TransportClient for PlatformGrpcClient { settings: &AppliedRequestSettings, pool: &ConnectionPool, ) -> Result { - Ok(pool - .get_or_create( - PoolPrefix::Platform, - &uri, - Some(settings), - || match create_channel(uri.clone(), Some(settings)) { + Self::with_uri_and_settings_and_generation(uri, settings, pool).map(|(client, _)| client) + } + + fn with_uri_and_settings_and_generation( + uri: Uri, + settings: &AppliedRequestSettings, + pool: &ConnectionPool, + ) -> Result<(Self, u64), TransportError> { + let (item, generation) = + pool.get_or_create_with_generation(PoolPrefix::Platform, &uri, Some(settings), || { + match create_channel(uri.clone(), Some(settings)) { Ok(channel) => { let mut client = Self::new(channel); if let Some(max_size) = settings.max_decoding_message_size { @@ -49,9 +54,9 @@ impl TransportClient for PlatformGrpcClient { "Channel creation failed: {}", e ))), - }, - )? - .into()) + } + })?; + Ok((item.into(), generation)) } } @@ -75,12 +80,17 @@ impl TransportClient for CoreGrpcClient { settings: &AppliedRequestSettings, pool: &ConnectionPool, ) -> Result { - Ok(pool - .get_or_create( - PoolPrefix::Core, - &uri, - Some(settings), - || match create_channel(uri.clone(), Some(settings)) { + Self::with_uri_and_settings_and_generation(uri, settings, pool).map(|(client, _)| client) + } + + fn with_uri_and_settings_and_generation( + uri: Uri, + settings: &AppliedRequestSettings, + pool: &ConnectionPool, + ) -> Result<(Self, u64), TransportError> { + let (item, generation) = + pool.get_or_create_with_generation(PoolPrefix::Core, &uri, Some(settings), || { + match create_channel(uri.clone(), Some(settings)) { Ok(channel) => { let mut client = Self::new(channel); if let Some(max_size) = settings.max_decoding_message_size { @@ -92,9 +102,9 @@ impl TransportClient for CoreGrpcClient { "Channel creation failed: {}", e ))), - }, - )? - .into()) + } + })?; + Ok((item.into(), generation)) } } @@ -252,6 +262,14 @@ macro_rules! impl_transport_request_grpc { const STREAMING_TIMEOUT: Duration = Duration::from_secs(5 * 60); +/// Attempt timeout for unary requests whose responses run to megabytes. +/// +/// The timeout bounds the whole attempt including the response body (see +/// `RequestSettings::timeout`), so the 10 s default would fail these responses +/// on a slow link every time and ban the node that was sending them. Dead +/// connections are still caught early by the channel's HTTP/2 keepalive. +const LARGE_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5 * 60); + impl_transport_request_grpc!( platform_proto::GetIdentityRequest, platform_proto::GetIdentityResponse, @@ -617,7 +635,12 @@ impl_transport_request_grpc!( platform_proto::GetShieldedEncryptedNotesRequest, platform_proto::GetShieldedEncryptedNotesResponse, PlatformGrpcClient, - RequestSettings::default(), + RequestSettings { + // A full chunk carries thousands of encrypted notes (megabytes), and + // the notes sync fetches several chunks in parallel over one link. + timeout: Some(LARGE_RESPONSE_TIMEOUT), + ..RequestSettings::default() + }, get_shielded_encrypted_notes ); @@ -923,6 +946,7 @@ impl_transport_request_grpc!( // GetRecentCompactedAddressBalanceChangesResponse can have 100 values * 2048 addresses * ~44 bytes each = ~9MB // We set it to 16MB to be safe max_decoding_message_size: Some(16 * 1024 * 1024), + timeout: Some(LARGE_RESPONSE_TIMEOUT), ..RequestSettings::default() }, get_recent_compacted_address_balance_changes diff --git a/packages/rs-dapi-client/src/transport/tonic_channel.rs b/packages/rs-dapi-client/src/transport/tonic_channel.rs index c2df9d92b47..779bdb33f52 100644 --- a/packages/rs-dapi-client/src/transport/tonic_channel.rs +++ b/packages/rs-dapi-client/src/transport/tonic_channel.rs @@ -3,6 +3,7 @@ use crate::{request_settings::AppliedRequestSettings, Uri}; use dapi_grpc::core::v0::core_client::CoreClient; use dapi_grpc::platform::v0::platform_client::PlatformClient; use dapi_grpc::tonic::transport::{Certificate, Channel, ClientTlsConfig}; +use std::time::Duration; /// Platform Client using gRPC transport. pub type PlatformGrpcClient = PlatformClient; @@ -13,6 +14,11 @@ pub type CoreGrpcClient = CoreClient; // #[derive(Default, Clone, Debug)] pub type TokioBackonSleeper = backon::TokioSleeper; +/// HTTP/2 PING interval while a request is in flight on a connection. +const HTTP2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(15); +/// How long to wait for a PING acknowledgement before closing the connection. +const HTTP2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(10); + /// Create channel (connection) for gRPC transport. pub fn create_channel( uri: Uri, @@ -50,6 +56,15 @@ pub fn create_channel( }; } + // Ping only while a request is in flight: a connection whose network path + // died mid-response is then closed within interval + timeout, failing its + // streams instead of leaving them waiting for data that never comes. + // Idle pooled connections are not pinged. + builder = builder + .http2_keep_alive_interval(HTTP2_KEEP_ALIVE_INTERVAL) + .keep_alive_timeout(HTTP2_KEEP_ALIVE_TIMEOUT) + .keep_alive_while_idle(false); + builder = builder .tls_config(tls_config) .expect("Failed to set TLS config"); diff --git a/packages/rs-dapi-client/tests/request_deadline.rs b/packages/rs-dapi-client/tests/request_deadline.rs new file mode 100644 index 00000000000..05f50b6b573 --- /dev/null +++ b/packages/rs-dapi-client/tests/request_deadline.rs @@ -0,0 +1,200 @@ +//! Client-side attempt deadline: an attempt whose response never completes +//! (a node that stalls mid-response, or a half-open connection) must fail with +//! `DeadlineExceeded` after `timeout + connect_timeout` and be retried on +//! another node, instead of hanging the request forever. +//! +//! The tests run on tokio's paused clock, so the deadlines elapse instantly. + +#[allow(dead_code)] +mod common; + +use std::fmt::Debug; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use common::{FakeClient, FakeResponse}; +use dapi_grpc::mock::Mockable; +use dapi_grpc::tonic::Code; +use rs_dapi_client::transport::{ + AppliedRequestSettings, BoxFuture, TransportError, TransportRequest, +}; +use rs_dapi_client::{ + Address, AddressList, DapiClient, DapiClientError, DapiRequestExecutor, RequestSettings, Uri, +}; + +/// How a fake node answers one attempt. +#[derive(Clone, Copy)] +enum Answer { + /// Respond successfully after this much time. + After(Duration), + /// Never finish the response. + Never, +} + +/// Fake request whose `answer` closure decides, per node, how the attempt +/// behaves over time. `hit_uris` records every node the executor tried. +#[derive(Clone)] +struct DelayedRequest { + answer: Arc Answer + Send + Sync>, + hit_uris: Arc>>, +} + +impl DelayedRequest { + fn new(answer: impl Fn(&Uri) -> Answer + Send + Sync + 'static) -> Self { + Self { + answer: Arc::new(answer), + hit_uris: Default::default(), + } + } + + fn hits(&self) -> Vec { + self.hit_uris.lock().unwrap().clone() + } +} + +impl Debug for DelayedRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DelayedRequest") + .field("hit_uris", &self.hit_uris) + .finish() + } +} + +impl Mockable for DelayedRequest {} + +impl TransportRequest for DelayedRequest { + type Client = FakeClient; + type Response = FakeResponse; + + const SETTINGS_OVERRIDES: RequestSettings = RequestSettings::default(); + + fn method_name(&self) -> &'static str { + "delayed_fake_method" + } + + fn execute_transport<'c>( + self, + client: &'c mut Self::Client, + _settings: &AppliedRequestSettings, + ) -> BoxFuture<'c, Result> { + let uri = client.uri.clone(); + self.hit_uris.lock().unwrap().push(uri.clone()); + match (self.answer)(&uri) { + Answer::After(delay) => Box::pin(async move { + tokio::time::sleep(delay).await; + Ok(FakeResponse) + }), + Answer::Never => Box::pin(futures::future::pending()), + } + } +} + +fn two_nodes() -> AddressList { + "http://127.0.0.1:10001,http://127.0.0.1:10002" + .parse() + .expect("valid address list") +} + +#[tokio::test(start_paused = true)] +async fn should_retry_on_another_node_when_an_attempt_misses_its_deadline() { + // The first node the executor picks stalls forever; every other node + // answers at once. + let stalled: Arc>> = Default::default(); + let stalled_c = stalled.clone(); + let request = DelayedRequest::new(move |uri| { + let mut stalled = stalled_c.lock().unwrap(); + let target = stalled.get_or_insert_with(|| uri.clone()); + if *target == *uri { + Answer::Never + } else { + Answer::After(Duration::ZERO) + } + }); + let client = DapiClient::new(two_nodes(), RequestSettings::default()); + + let started = tokio::time::Instant::now(); + let response = client + .execute(request.clone(), RequestSettings::default()) + .await + .expect("the retry on the healthy node must succeed"); + + let stalled_uri = stalled.lock().unwrap().clone().expect("a node stalled"); + assert_eq!(response.retries, 1); + assert_ne!(response.address.uri(), &stalled_uri); + assert_eq!(request.hits().len(), 2); + assert!( + started.elapsed() >= Duration::from_secs(10), + "the stalled attempt must run until the default 10 s deadline, not be cut earlier" + ); + let stalled_node = Address::try_from(stalled_uri).expect("valid address"); + assert!( + client.address_list().is_banned(&stalled_node), + "the node that missed the deadline must be banned" + ); +} + +#[tokio::test(start_paused = true)] +async fn should_fail_with_deadline_exceeded_when_every_node_stalls() { + let request = DelayedRequest::new(|_| Answer::Never); + let client = DapiClient::new(two_nodes(), RequestSettings::default()); + + let error = client + .execute(request.clone(), RequestSettings::default()) + .await + .expect_err("no node ever completes a response"); + + // Both nodes were tried once and banned, then the address list ran dry. + assert_eq!(request.hits().len(), 2); + match error.inner { + DapiClientError::NoAvailableAddressesToRetry(last) => { + let TransportError::Grpc(status) = *last; + assert_eq!(status.code(), Code::DeadlineExceeded); + } + other => panic!("expected NoAvailableAddressesToRetry, got {other:?}"), + } +} + +#[tokio::test(start_paused = true)] +async fn should_cut_a_response_that_outlasts_timeout_plus_connect_timeout() { + // The response would complete, but only after the attempt deadline: + // headers-only timeouts let this through; the attempt deadline must not. + let request = DelayedRequest::new(|_| Answer::After(Duration::from_secs(14))); + let settings = RequestSettings { + timeout: Some(Duration::from_secs(10)), + connect_timeout: Some(Duration::from_secs(3)), + retries: Some(0), + ..RequestSettings::default() + }; + let client = DapiClient::new(two_nodes(), settings); + + let error = client + .execute(request.clone(), RequestSettings::default()) + .await + .expect_err("a 14 s response exceeds the 13 s attempt deadline"); + + match error.inner { + DapiClientError::Transport(TransportError::Grpc(status)) => { + assert_eq!(status.code(), Code::DeadlineExceeded) + } + other => panic!("expected a gRPC DeadlineExceeded, got {other:?}"), + } +} + +#[tokio::test(start_paused = true)] +async fn should_not_cut_an_attempt_when_timeout_is_zero() { + // Zero means "no limit", for the `grpc-timeout` header and the attempt alike. + let request = DelayedRequest::new(|_| Answer::After(Duration::from_secs(60))); + let settings = RequestSettings { + timeout: Some(Duration::ZERO), + ..RequestSettings::default() + }; + let client = DapiClient::new(two_nodes(), RequestSettings::default()); + + let response = client + .execute(request.clone(), settings) + .await + .expect("a slow response must complete when the timeout is disabled"); + + assert_eq!(response.retries, 0); + assert_eq!(request.hits().len(), 1); +} diff --git a/packages/rs-dapi-client/tests/stalled_response_body.rs b/packages/rs-dapi-client/tests/stalled_response_body.rs new file mode 100644 index 00000000000..39cdff7d3b3 --- /dev/null +++ b/packages/rs-dapi-client/tests/stalled_response_body.rs @@ -0,0 +1,216 @@ +//! Regression through tonic itself: a node that sends the response headers +//! of a unary call and then never sends its body. +//! +//! tonic enforces the `grpc-timeout` header only until the response headers +//! arrive, so such a call never completes on its own. The executor's attempt +//! deadline must cut it with `DeadlineExceeded` and fail over to another +//! node. The server runs in memory over a duplex stream, so the tests need no +//! network access. + +#[allow(dead_code)] +mod common; + +use std::fmt::Debug; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use common::FakeClient; +use dapi_grpc::mock::Mockable; +use dapi_grpc::platform::v0::{GetStatusRequest, GetStatusResponse}; +use dapi_grpc::tonic::transport::{Channel, Endpoint}; +use dapi_grpc::tonic::{Code, IntoRequest}; +use hyper_util::rt::TokioIo; +use rs_dapi_client::transport::{ + AppliedRequestSettings, BoxFuture, PlatformGrpcClient, TransportError, TransportRequest, +}; +use rs_dapi_client::{ + Address, AddressList, DapiClient, DapiClientError, DapiRequestExecutor, RequestSettings, Uri, +}; + +const ATTEMPT_TIMEOUT: Duration = Duration::from_millis(200); + +/// A gRPC client whose channel runs over an in-memory duplex stream to an +/// HTTP/2 server that answers every request with `200` response headers and +/// then never sends a body or trailers. +fn body_stalling_client() -> PlatformGrpcClient { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + tokio::spawn(async move { + let mut connection = h2::server::handshake(server_io) + .await + .expect("h2 handshake"); + // Keep every response stream open (and the connection polled) so the + // body stays pending instead of the stream being reset. + let mut open_streams = Vec::new(); + while let Some(accepted) = connection.accept().await { + let (_request, mut respond) = accepted.expect("accept request"); + let headers = http::Response::builder() + .status(200) + .header("content-type", "application/grpc") + .body(()) + .expect("response headers"); + open_streams.push( + respond + .send_response(headers, false) + .expect("send response headers"), + ); + } + }); + + let mut io = Some(client_io); + let channel: Channel = Endpoint::from_static("http://body-stalling.in-memory") + .connect_with_connector_lazy(tower::service_fn(move |_: Uri| { + let io = io.take(); + async move { + io.map(TokioIo::new) + .ok_or_else(|| std::io::Error::other("the in-memory stream is single-use")) + } + })); + PlatformGrpcClient::new(channel) +} + +/// `getStatus` request whose first attempted node is served by the +/// body-stalling tonic channel; every other node answers at once. +#[derive(Clone)] +struct StatusRequest { + stalling_client: PlatformGrpcClient, + stalled_node: Arc>>, +} + +impl StatusRequest { + fn new() -> Self { + Self { + stalling_client: body_stalling_client(), + stalled_node: Default::default(), + } + } + + fn stalled_node(&self) -> Option { + self.stalled_node.lock().expect("stalled node lock").clone() + } +} + +impl Debug for StatusRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StatusRequest") + .field("stalled_node", &self.stalled_node) + .finish() + } +} + +impl Mockable for StatusRequest {} + +impl TransportRequest for StatusRequest { + type Client = FakeClient; + type Response = GetStatusResponse; + + const SETTINGS_OVERRIDES: RequestSettings = RequestSettings::default(); + + fn method_name(&self) -> &'static str { + "get_status" + } + + fn execute_transport<'c>( + self, + client: &'c mut Self::Client, + settings: &AppliedRequestSettings, + ) -> BoxFuture<'c, Result> { + let is_stalled_node = { + let mut stalled = self.stalled_node.lock().expect("stalled node lock"); + *stalled.get_or_insert_with(|| client.uri.clone()) == client.uri + }; + if !is_stalled_node { + return Box::pin(async { Ok(GetStatusResponse::default()) }); + } + // The same shape as the production transport: the request timeout + // travels only as the `grpc-timeout` header. + let mut grpc = self.stalling_client; + let mut request = GetStatusRequest::default().into_request(); + request.set_timeout(settings.timeout); + Box::pin(async move { + grpc.get_status(request) + .await + .map(|response| response.into_inner()) + .map_err(TransportError::Grpc) + }) + } +} + +fn settings(retries: usize) -> RequestSettings { + RequestSettings { + timeout: Some(ATTEMPT_TIMEOUT), + retries: Some(retries), + ..RequestSettings::default() + } +} + +/// The premise: with only the `grpc-timeout` header, a unary call whose body +/// never arrives is still pending long after that timeout. +#[tokio::test] +async fn should_leave_a_body_stalled_call_pending_with_only_the_grpc_timeout_header() { + let mut client = body_stalling_client(); + let mut request = GetStatusRequest::default().into_request(); + request.set_timeout(ATTEMPT_TIMEOUT); + + let outcome = tokio::time::timeout(Duration::from_secs(2), client.get_status(request)).await; + + assert!( + outcome.is_err(), + "tonic must still be waiting for the body 2 s after a 200 ms grpc-timeout, got {outcome:?}" + ); +} + +#[tokio::test] +async fn should_cut_a_body_stalled_call_with_deadline_exceeded() { + let request = StatusRequest::new(); + let client = DapiClient::new( + "http://127.0.0.1:20001" + .parse() + .expect("valid address list"), + settings(0), + ); + + let error = client + .execute(request, RequestSettings::default()) + .await + .expect_err("the only node never completes its response"); + + match error.inner { + DapiClientError::Transport(TransportError::Grpc(status)) => { + assert_eq!( + status.code(), + Code::DeadlineExceeded, + "unexpected status: {status:?}" + ) + } + other => panic!("expected a gRPC DeadlineExceeded, got {other:?}"), + } +} + +#[tokio::test] +async fn should_fail_over_when_a_node_stalls_its_response_body() { + let request = StatusRequest::new(); + let address_list: AddressList = "http://127.0.0.1:20001,http://127.0.0.1:20002" + .parse() + .expect("valid address list"); + let client = DapiClient::new(address_list, settings(5)); + + let started = tokio::time::Instant::now(); + let response = client + .execute(request.clone(), RequestSettings::default()) + .await + .expect("the other node must answer"); + let elapsed = started.elapsed(); + + let stalled_uri = request.stalled_node().expect("a node was tried first"); + assert_eq!(response.retries, 1); + assert_ne!(response.address.uri(), &stalled_uri); + assert!( + elapsed >= ATTEMPT_TIMEOUT && elapsed < Duration::from_secs(2), + "the stalled attempt must end at its deadline, took {elapsed:?}" + ); + let stalled_node = Address::try_from(stalled_uri).expect("valid address"); + assert!( + client.address_list().is_banned(&stalled_node), + "the node that stalled its response body must be banned" + ); +} From 5298d20952fdd28aba426415e13c007e0b999fd9 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 20:23:52 +0700 Subject: [PATCH 087/113] chore(release): update changelog and bump version to 4.2.0-beta.6 (#5128) Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 120 ++++++++++++++++++ Cargo.lock | 96 +++++++------- Cargo.toml | 2 +- package.json | 2 +- packages/app-connect-contract/package.json | 2 +- packages/bench-suite/package.json | 2 +- packages/dapi-grpc/package.json | 2 +- packages/dapi/package.json | 2 +- packages/dash-spv/package.json | 2 +- packages/dashmate/package.json | 2 +- packages/dashpay-contract/package.json | 2 +- .../document-history-contract/package.json | 2 +- packages/dpns-contract/package.json | 2 +- packages/js-dapi-client/package.json | 2 +- packages/js-dash-sdk/package.json | 2 +- packages/js-evo-sdk/package.json | 2 +- packages/js-grpc-common/package.json | 2 +- packages/keyword-search-contract/package.json | 2 +- .../package.json | 2 +- .../moderation-charters-contract/package.json | 2 +- packages/platform-test-suite/package.json | 2 +- packages/token-history-contract/package.json | 2 +- packages/wallet-lib/package.json | 2 +- packages/wallet-utils-contract/package.json | 2 +- packages/wasm-dpp/package.json | 2 +- packages/wasm-dpp2/package.json | 2 +- packages/wasm-drive-verify/package.json | 2 +- packages/wasm-sdk/package.json | 2 +- packages/withdrawals-contract/package.json | 2 +- 29 files changed, 195 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f7960c9aea..72d965e81cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,123 @@ +## [4.2.0-beta.6](https://github.com/dashpay/platform/compare/v4.2.0-beta.5...v4.2.0-beta.6) (2026-09-28) + + +### ⚠ BREAKING CHANGES + +* **platform:** a preallocated agreement source must fit a tree key (PV14) (#5123) +* **sdk:** countOf and sumOf totals in the rule descriptors of the JS, Swift and Kotlin SDKs (#5121) +* **platform:** refuse own-type totals on contested types and fail loudly on unread totals (PV14) (#5115) +* **drive:** subscription filters match generated properties a transition leaves out (#5114) +* **platform:** generatedFrom, string properties the platform generates with a system function (PV14) (#5099) +* **platform:** countOf and sumOf totals from count and sum trees in propertyConstraints rules (PV14) (#5109) +* **dpp:** propertyConstraints read empty objects as absent and follow $defs refs (PV14) (#5101) +* **platform:** elected moderation windows may be 0 off mainnet, mainnet keeps one day (PV14) (#5108) +* **platform:** ifThen, ifThenElse, notIn, min, max and abs in propertyConstraints rules (PV14) (#5100) +* **swift-sdk:** new propertyConstraints read kinds and system reads in the Swift SDK and iOS example app (#5098) +* **kotlin-sdk:** new propertyConstraints read kinds and system reads in the Kotlin SDK and Android example app (#5097) +* **dpp:** size estimates of strings of 16384 or more characters no longer overflow (PV14) (#5086) +* **platform:** startsWith and endsWith in propertyConstraints rules (PV14) (#5085) +* **platform:** contains in propertyConstraints rules (PV14) (#5083) +* **dpp:** report contracts refused by parser generation 3 as consensus errors (PV14) (#5076) +* **platform:** creation, update and transfer times and heights in propertyConstraints rules (PV14) (#5078) +* **platform:** string length, byte length and array count operands in propertyConstraints rules (PV14) (#5071) +* **dpp:** propertyConstraints compare identifier properties that declare refersTo (PV14) (#5073) +* **dpp:** accept a reordered entryPayload and refuse unruled keywords as incompatible (PV14) (#5074) +* **dpp:** refuse token cost and unruled keyword changes on update with a consensus error (PV14) (#5069) +* **platform:** $ownerId comparisons in propertyConstraints rules (PV14) (#5048) +* **platform:** identifier comparisons in propertyConstraints rules (PV14) (#5047) +* **platform:** string ifAbsent defaults in propertyConstraints rules (PV14) (#5046) +* **platform:** compare two string properties in propertyConstraints rules (PV14) (#5045) + +### Features + +* **kotlin-sdk:** new propertyConstraints read kinds and system reads in the Kotlin SDK and Android example app ([#5097](https://github.com/dashpay/platform/issues/5097)) +* **platform:** $ownerId comparisons in propertyConstraints rules (PV14) ([#5048](https://github.com/dashpay/platform/issues/5048)) +* **platform:** compare two string properties in propertyConstraints rules (PV14) ([#5045](https://github.com/dashpay/platform/issues/5045)) +* **platform:** contains in propertyConstraints rules (PV14) ([#5083](https://github.com/dashpay/platform/issues/5083)) +* **platform:** countOf and sumOf totals from count and sum trees in propertyConstraints rules (PV14) ([#5109](https://github.com/dashpay/platform/issues/5109)) +* **platform:** creation, update and transfer times and heights in propertyConstraints rules (PV14) ([#5078](https://github.com/dashpay/platform/issues/5078)) +* **platform:** elected moderation windows may be 0 off mainnet, mainnet keeps one day (PV14) ([#5108](https://github.com/dashpay/platform/issues/5108)) +* **platform:** generatedFrom, string properties the platform generates with a system function (PV14) ([#5099](https://github.com/dashpay/platform/issues/5099)) +* **platform:** identifier comparisons in propertyConstraints rules (PV14) ([#5047](https://github.com/dashpay/platform/issues/5047)) +* **platform:** ifThen, ifThenElse, notIn, min, max and abs in propertyConstraints rules (PV14) ([#5100](https://github.com/dashpay/platform/issues/5100)) +* **platform:** startsWith and endsWith in propertyConstraints rules (PV14) ([#5085](https://github.com/dashpay/platform/issues/5085)) +* **platform:** string ifAbsent defaults in propertyConstraints rules (PV14) ([#5046](https://github.com/dashpay/platform/issues/5046)) +* **platform:** string length, byte length and array count operands in propertyConstraints rules (PV14) ([#5071](https://github.com/dashpay/platform/issues/5071)) +* **sdk:** countOf and sumOf totals in the rule descriptors of the JS, Swift and Kotlin SDKs ([#5121](https://github.com/dashpay/platform/issues/5121)) +* **sdk:** propertyConstraints discovery and pre-check in the JS SDK ([#5051](https://github.com/dashpay/platform/issues/5051)) +* **sdk:** propertyConstraints rules and pre-check in the Kotlin SDK and Android example app ([#5066](https://github.com/dashpay/platform/issues/5066)) +* **sdk:** propertyConstraints rules and pre-check in the Swift SDK and iOS example app ([#5064](https://github.com/dashpay/platform/issues/5064)) +* **swift-sdk:** new propertyConstraints read kinds and system reads in the Swift SDK and iOS example app ([#5098](https://github.com/dashpay/platform/issues/5098)) + + +### Bug Fixes + +* **ci:** build release clients natively on unprivileged runners +* **ci:** isolate release runners from PR build state +* **dpp:** accept a reordered entryPayload and refuse unruled keywords as incompatible (PV14) ([#5074](https://github.com/dashpay/platform/issues/5074)) +* **dpp:** parse nested required and transient entries by prefix ([#5050](https://github.com/dashpay/platform/issues/5050)) +* **dpp:** pass the contract's $defs to the countOf and sumOf key enum check ([#5110](https://github.com/dashpay/platform/issues/5110)) +* **dpp:** propertyConstraints compare identifier properties that declare refersTo (PV14) ([#5073](https://github.com/dashpay/platform/issues/5073)) +* **dpp:** propertyConstraints read empty objects as absent and follow $defs refs (PV14) ([#5101](https://github.com/dashpay/platform/issues/5101)) +* **dpp:** refuse token cost and unruled keyword changes on update with a consensus error (PV14) ([#5069](https://github.com/dashpay/platform/issues/5069)) +* **dpp:** report contracts refused by parser generation 3 as consensus errors (PV14) ([#5076](https://github.com/dashpay/platform/issues/5076)) +* **dpp:** restore the shipped order of basic consensus errors ([#5053](https://github.com/dashpay/platform/issues/5053)) +* **dpp:** size estimates of strings of 16384 or more characters no longer overflow (PV14) ([#5086](https://github.com/dashpay/platform/issues/5086)) +* **drive-abci:** finalize a block accepted in an earlier round after a later proposal was refused ([#5081](https://github.com/dashpay/platform/issues/5081)) +* **drive-abci:** sign a locked block when a later proposal left no execution context ([#5079](https://github.com/dashpay/platform/issues/5079)) +* **drive-abci:** sign vote extensions only for blocks this node accepted ([#5084](https://github.com/dashpay/platform/issues/5084)) +* **drive:** subscription filters match generated properties a transition leaves out ([#5114](https://github.com/dashpay/platform/issues/5114)) +* **platform:** a preallocated agreement source must fit a tree key (PV14) ([#5123](https://github.com/dashpay/platform/issues/5123)) +* **platform:** refuse own-type totals on contested types and fail loudly on unread totals (PV14) ([#5115](https://github.com/dashpay/platform/issues/5115)) +* **release:** require all generated clients in packed archives +* **sdk:** bound each DAPI request attempt, including the response body ([#4973](https://github.com/dashpay/platform/issues/4973)) +* **sdk:** consensus errors reach JS with their code ([#5112](https://github.com/dashpay/platform/issues/5112)) +* **sdk:** consensus errors reach Swift and Kotlin apps with their code ([#5116](https://github.com/dashpay/platform/issues/5116)) +* **swift-sdk:** documentTransfer handles a missing document and signs once ([#5120](https://github.com/dashpay/platform/issues/5120)) +* **swift-sdk:** free FFI errors in state-transition wrappers ([#5117](https://github.com/dashpay/platform/issues/5117)) +* **swift-sdk:** take migration copies out of WAL mode +* **wasm-sdk:** keep StateTransitionResult.ownerBalance exact in JSON ([#5059](https://github.com/dashpay/platform/issues/5059)) +* **wasm-sdk:** leave price tier validation to rs-dpp ([#5058](https://github.com/dashpay/platform/issues/5058)) + + +### Miscellaneous Chores + +* **swift-sdk:** freeze App Store schema 3.0.0 + + +### Continuous Integration + +* bootstrap PR-first runner images on v4.2-dev +* reconcile rootless runner workflows with v4.2-dev, closes [#4702](https://github.com/dashpay/platform/issues/4702) + + +### Code Refactoring + +* **dpp:** restore shipped registration_cost v1 index parsing ([#5055](https://github.com/dashpay/platform/issues/5055)) +* **drive:** create once-per-identity claim trees in insert_contract v2 ([#5056](https://github.com/dashpay/platform/issues/5056)) +* **platform:** fold DRIVE_ABCI_QUERY_VERSIONS_V3 into V2 ([#5057](https://github.com/dashpay/platform/issues/5057)) +* **platform:** import instead of inline crate paths in 4.1 and 4.2 code ([#5065](https://github.com/dashpay/platform/issues/5065)) + + +### Documentation + +* add a contract keywords reference page to the book ([#5067](https://github.com/dashpay/platform/issues/5067)) +* encrypt the 69-byte compact xpub in the contact-request guide ([#5087](https://github.com/dashpay/platform/issues/5087)) +* give every contract keyword its own chapter in the book ([#5075](https://github.com/dashpay/platform/issues/5075)) +* list the complete contract language in the keywords overview ([#5082](https://github.com/dashpay/platform/issues/5082)) +* **platform:** add Parameters and Returns sections to 4.1 and 4.2 dispatchers ([#5063](https://github.com/dashpay/platform/issues/5063)) +* **platform:** document the genesis protocol version exception and Swift/Kotlin indentation ([#5061](https://github.com/dashpay/platform/issues/5061)) +* **platform:** say why in-place edits to shipped generations are inert ([#5054](https://github.com/dashpay/platform/issues/5054)) +* remove committed working specs and plans ([#5060](https://github.com/dashpay/platform/issues/5060)) +* **swift-sdk:** say the migration copy is switched out of WAL mode + + +### Tests + +* **drive:** pin that a cached contract read after an in-block update bills like a cold read ([#5052](https://github.com/dashpay/platform/issues/5052)) +* follow the test conventions in tests added in 4.1 and 4.2 ([#5062](https://github.com/dashpay/platform/issues/5062)) +* **sdk:** ifThen and ifThenElse rules through rs-sdk-ffi and the Swift and Kotlin SDKs ([#5106](https://github.com/dashpay/platform/issues/5106)) + ## [4.2.0-beta.5](https://github.com/dashpay/platform/compare/v4.2.0-beta.4...v4.2.0-beta.5) (2026-09-27) diff --git a/Cargo.lock b/Cargo.lock index a28e354c7e4..e7d97484fc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,7 +159,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app-connect-contract" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "base58", "platform-value", @@ -1055,7 +1055,7 @@ dependencies = [ [[package]] name = "check-features" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "toml 0.8.23", ] @@ -1543,7 +1543,7 @@ dependencies = [ [[package]] name = "dapi-grpc" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "dash-platform-macros", "futures-core", @@ -1630,7 +1630,7 @@ dependencies = [ [[package]] name = "dash-async" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "futures", "thiserror 2.0.18", @@ -1641,7 +1641,7 @@ dependencies = [ [[package]] name = "dash-context-provider" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "dash-async", "dpp", @@ -1672,7 +1672,7 @@ dependencies = [ [[package]] name = "dash-platform-balance-checker" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "anyhow", "clap", @@ -1687,7 +1687,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "heck 0.5.0", "quote", @@ -1696,7 +1696,7 @@ dependencies = [ [[package]] name = "dash-platform-queries" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "dapi-grpc", "dash-context-provider", @@ -1713,7 +1713,7 @@ dependencies = [ [[package]] name = "dash-sdk" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "arc-swap", "assert_matches", @@ -1858,7 +1858,7 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "platform-value", "platform-version", @@ -1868,7 +1868,7 @@ dependencies = [ [[package]] name = "data-contracts" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "app-connect-contract", "base58", @@ -2073,7 +2073,7 @@ dependencies = [ [[package]] name = "document-history-contract" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "platform-value", "platform-version", @@ -2095,7 +2095,7 @@ checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" [[package]] name = "dpns-contract" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "platform-value", "platform-version", @@ -2105,7 +2105,7 @@ dependencies = [ [[package]] name = "dpp" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "anyhow", "assert_matches", @@ -2164,7 +2164,7 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "proc-macro2", "quote", @@ -2173,7 +2173,7 @@ dependencies = [ [[package]] name = "drive" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "arc-swap", "assert_matches", @@ -2216,7 +2216,7 @@ dependencies = [ [[package]] name = "drive-abci" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "arc-swap", "assert_matches", @@ -2278,7 +2278,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "dapi-grpc", "dash-context-provider", @@ -4080,7 +4080,7 @@ dependencies = [ [[package]] name = "json-schema-compatibility-validator" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "assert_matches", "json-patch", @@ -4223,7 +4223,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "base58", "platform-value", @@ -4414,7 +4414,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "platform-value", "platform-version", @@ -4615,7 +4615,7 @@ dependencies = [ [[package]] name = "moderation-charters-contract" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "base58", "platform-value", @@ -5198,7 +5198,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "platform-encryption" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "aes", "cbc", @@ -5210,7 +5210,7 @@ dependencies = [ [[package]] name = "platform-serialization" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "grovedb-bincode", "platform-version", @@ -5218,7 +5218,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "proc-macro2", "quote", @@ -5228,7 +5228,7 @@ dependencies = [ [[package]] name = "platform-value" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "base64 0.22.1", "bs58", @@ -5247,7 +5247,7 @@ dependencies = [ [[package]] name = "platform-value-convertible" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "quote", "syn 2.0.117", @@ -5255,7 +5255,7 @@ dependencies = [ [[package]] name = "platform-version" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "grovedb-bincode", "grovedb-version", @@ -5265,7 +5265,7 @@ dependencies = [ [[package]] name = "platform-versioning" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "proc-macro2", "quote", @@ -5274,7 +5274,7 @@ dependencies = [ [[package]] name = "platform-wallet" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "arc-swap", "async-trait", @@ -5314,7 +5314,7 @@ dependencies = [ [[package]] name = "platform-wallet-ffi" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "anyhow", "async-trait", @@ -5342,7 +5342,7 @@ dependencies = [ [[package]] name = "platform-wallet-storage" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "apple-native-keyring-store", "argon2", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "rs-dapi" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "async-trait", "axum 0.8.9", @@ -6409,7 +6409,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "backon", "chrono", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "rs-dash-event-bus" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "metrics", "tokio", @@ -6470,7 +6470,7 @@ dependencies = [ [[package]] name = "rs-sdk-ffi" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "async-trait", "bs58", @@ -6504,7 +6504,7 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "arc-swap", "dash-async", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "rs-unified-sdk-ffi" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "dash-network", "key-wallet-ffi", @@ -6534,7 +6534,7 @@ dependencies = [ [[package]] name = "rs-unified-sdk-jni" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "android_logger", "dash-network", @@ -7276,7 +7276,7 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "simple-signer" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "async-trait", "base64 0.22.1", @@ -7413,7 +7413,7 @@ dependencies = [ [[package]] name = "strategy-tests" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "dpp", "drive", @@ -7815,7 +7815,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "platform-value", "platform-version", @@ -8658,7 +8658,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "platform-value", "platform-version", @@ -8801,7 +8801,7 @@ checksum = "a8145dd1593bf0fb137dbfa85b8be79ec560a447298955877804640e40c2d6ea" [[package]] name = "wasm-dpp" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "anyhow", "async-trait", @@ -8825,7 +8825,7 @@ dependencies = [ [[package]] name = "wasm-dpp2" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "anyhow", "async-trait", @@ -8844,7 +8844,7 @@ dependencies = [ [[package]] name = "wasm-drive-verify" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "base64 0.22.1", "bs58", @@ -8899,7 +8899,7 @@ dependencies = [ [[package]] name = "wasm-sdk" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "base64 0.22.1", "bip39", @@ -9410,7 +9410,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index 9985ecfe4bc..030eaee0c49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,5 +149,5 @@ opt-level = 3 [workspace.package] -version = "4.2.0-beta.5" +version = "4.2.0-beta.6" rust-version = "1.98" diff --git a/package.json b/package.json index 9f83ca0040a..5ee76fe1334 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/platform", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "private": true, "scripts": { "setup": "yarn install && yarn run build && yarn run configure", diff --git a/packages/app-connect-contract/package.json b/packages/app-connect-contract/package.json index cc5459b8ded..b9dc62ae9ea 100644 --- a/packages/app-connect-contract/package.json +++ b/packages/app-connect-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/app-connect-contract", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "A system contract for encrypted wallet-to-app login responses", "scripts": { "lint": "eslint .", diff --git a/packages/bench-suite/package.json b/packages/bench-suite/package.json index 8f6cd58bde2..ec2e7f25e35 100644 --- a/packages/bench-suite/package.json +++ b/packages/bench-suite/package.json @@ -1,7 +1,7 @@ { "name": "@dashevo/bench-suite", "private": true, - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "Dash Platform benchmark tool", "scripts": { "bench": "node ./bin/bench.js", diff --git a/packages/dapi-grpc/package.json b/packages/dapi-grpc/package.json index 42708c51f5d..6bf80b1d39a 100644 --- a/packages/dapi-grpc/package.json +++ b/packages/dapi-grpc/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dapi-grpc", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "DAPI GRPC definition file and generated clients", "browser": "browser.js", "main": "node.js", diff --git a/packages/dapi/package.json b/packages/dapi/package.json index f2a706e3358..cb61fc42735 100644 --- a/packages/dapi/package.json +++ b/packages/dapi/package.json @@ -1,7 +1,7 @@ { "name": "@dashevo/dapi", "private": true, - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "A decentralized API for the Dash network", "scripts": { "api": "node scripts/api.js", diff --git a/packages/dash-spv/package.json b/packages/dash-spv/package.json index 3b5acd5f425..e84c5cf8e5e 100644 --- a/packages/dash-spv/package.json +++ b/packages/dash-spv/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dash-spv", - "version": "5.2.0-beta.5", + "version": "5.2.0-beta.6", "description": "Repository containing SPV functions used by @dashevo", "main": "index.js", "scripts": { diff --git a/packages/dashmate/package.json b/packages/dashmate/package.json index dd8a761f7b0..9c3942c3a75 100644 --- a/packages/dashmate/package.json +++ b/packages/dashmate/package.json @@ -1,6 +1,6 @@ { "name": "dashmate", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "Distribution package for Dash node installation", "scripts": { "lint": "eslint .", diff --git a/packages/dashpay-contract/package.json b/packages/dashpay-contract/package.json index 3a57460fbf4..28ecd64b10f 100644 --- a/packages/dashpay-contract/package.json +++ b/packages/dashpay-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dashpay-contract", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "Reference contract of the DashPay DPA on Dash Evolution", "scripts": { "lint": "eslint .", diff --git a/packages/document-history-contract/package.json b/packages/document-history-contract/package.json index 8798146583c..e1e1582d7b4 100644 --- a/packages/document-history-contract/package.json +++ b/packages/document-history-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/document-history-contract", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "The document history contract", "scripts": { "lint": "eslint .", diff --git a/packages/dpns-contract/package.json b/packages/dpns-contract/package.json index fc29a8a4c88..b0a72e1db95 100644 --- a/packages/dpns-contract/package.json +++ b/packages/dpns-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dpns-contract", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "A contract and helper scripts for DPNS DApp", "scripts": { "lint": "eslint .", diff --git a/packages/js-dapi-client/package.json b/packages/js-dapi-client/package.json index 09f0ebc7450..5e586c7a5b8 100644 --- a/packages/js-dapi-client/package.json +++ b/packages/js-dapi-client/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/dapi-client", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "Client library used to access Dash DAPI endpoints", "main": "lib/index.js", "contributors": [ diff --git a/packages/js-dash-sdk/package.json b/packages/js-dash-sdk/package.json index b490bc242d0..5488c3a8ad1 100644 --- a/packages/js-dash-sdk/package.json +++ b/packages/js-dash-sdk/package.json @@ -1,6 +1,6 @@ { "name": "dash", - "version": "7.2.0-beta.5", + "version": "7.2.0-beta.6", "description": "Dash library for JavaScript/TypeScript ecosystem (Wallet, DAPI, Primitives, BLS, ...)", "main": "build/index.js", "unpkg": "dist/dash.min.js", diff --git a/packages/js-evo-sdk/package.json b/packages/js-evo-sdk/package.json index 5e74a229639..b57c406fed3 100644 --- a/packages/js-evo-sdk/package.json +++ b/packages/js-evo-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/evo-sdk", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "type": "module", "main": "./dist/evo-sdk.module.js", "types": "./dist/sdk.d.ts", diff --git a/packages/js-grpc-common/package.json b/packages/js-grpc-common/package.json index 169782bc4ca..c85e7cdc7a9 100644 --- a/packages/js-grpc-common/package.json +++ b/packages/js-grpc-common/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/grpc-common", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "Common GRPC library", "main": "index.js", "scripts": { diff --git a/packages/keyword-search-contract/package.json b/packages/keyword-search-contract/package.json index f56a995d96f..b92a6c8bb61 100644 --- a/packages/keyword-search-contract/package.json +++ b/packages/keyword-search-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/keyword-search-contract", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "A contract that allows searching for contracts", "scripts": { "lint": "eslint .", diff --git a/packages/masternode-reward-shares-contract/package.json b/packages/masternode-reward-shares-contract/package.json index 07be9ed94d5..e343b7140c4 100644 --- a/packages/masternode-reward-shares-contract/package.json +++ b/packages/masternode-reward-shares-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/masternode-reward-shares-contract", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "A contract and helper scripts for reward sharing", "scripts": { "lint": "eslint .", diff --git a/packages/moderation-charters-contract/package.json b/packages/moderation-charters-contract/package.json index 5c5ca2d2f96..861ca22b07d 100644 --- a/packages/moderation-charters-contract/package.json +++ b/packages/moderation-charters-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/moderation-charters-contract", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "A system contract for the charters of elected moderation teams", "scripts": { "lint": "eslint .", diff --git a/packages/platform-test-suite/package.json b/packages/platform-test-suite/package.json index 77b1b2856b0..1456de275a2 100644 --- a/packages/platform-test-suite/package.json +++ b/packages/platform-test-suite/package.json @@ -1,7 +1,7 @@ { "name": "@dashevo/platform-test-suite", "private": true, - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "Dash Network end-to-end tests", "scripts": { "test": "yarn exec bin/test.sh", diff --git a/packages/token-history-contract/package.json b/packages/token-history-contract/package.json index 1cb6fcfa112..90386fef5c4 100644 --- a/packages/token-history-contract/package.json +++ b/packages/token-history-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/token-history-contract", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "The token history contract", "scripts": { "lint": "eslint .", diff --git a/packages/wallet-lib/package.json b/packages/wallet-lib/package.json index fff2260d787..673662884f0 100644 --- a/packages/wallet-lib/package.json +++ b/packages/wallet-lib/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wallet-lib", - "version": "11.2.0-beta.5", + "version": "11.2.0-beta.6", "description": "Light wallet library for Dash", "main": "src/index.js", "unpkg": "dist/wallet-lib.min.js", diff --git a/packages/wallet-utils-contract/package.json b/packages/wallet-utils-contract/package.json index 121ca97bfd8..9269495495f 100644 --- a/packages/wallet-utils-contract/package.json +++ b/packages/wallet-utils-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wallet-utils-contract", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "A contract and helper scripts for Wallet DApp", "scripts": { "lint": "eslint .", diff --git a/packages/wasm-dpp/package.json b/packages/wasm-dpp/package.json index 2c855e301a5..0984ed7cc74 100644 --- a/packages/wasm-dpp/package.json +++ b/packages/wasm-dpp/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wasm-dpp", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "The JavaScript implementation of the Dash Platform Protocol", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/wasm-dpp2/package.json b/packages/wasm-dpp2/package.json index 8a5b19d6aa6..cf720b560aa 100644 --- a/packages/wasm-dpp2/package.json +++ b/packages/wasm-dpp2/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wasm-dpp2", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "type": "module", "main": "./dist/dpp.js", "types": "./dist/dpp.d.ts", diff --git a/packages/wasm-drive-verify/package.json b/packages/wasm-drive-verify/package.json index 2419715720c..b37445539c3 100644 --- a/packages/wasm-drive-verify/package.json +++ b/packages/wasm-drive-verify/package.json @@ -3,7 +3,7 @@ "collaborators": [ "Dash Core Group " ], - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "license": "MIT", "description": "WASM bindings for Drive verify functions", "repository": { diff --git a/packages/wasm-sdk/package.json b/packages/wasm-sdk/package.json index 3dd732583a0..e921edac51e 100644 --- a/packages/wasm-sdk/package.json +++ b/packages/wasm-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/wasm-sdk", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "type": "module", "main": "./dist/sdk.js", "types": "./dist/sdk.d.ts", diff --git a/packages/withdrawals-contract/package.json b/packages/withdrawals-contract/package.json index ef8eed56d58..230cb0d8895 100644 --- a/packages/withdrawals-contract/package.json +++ b/packages/withdrawals-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dashevo/withdrawals-contract", - "version": "4.2.0-beta.5", + "version": "4.2.0-beta.6", "description": "Data Contract to manipulate and track withdrawals", "scripts": { "build": "", From 52e03df12f9aad6b632b658af0fa1c3b29966b1e Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:23:12 +0300 Subject: [PATCH 088/113] fix(ci): repair headless Swift keychains and PIC RocksDB --- .github/actions/librocksdb/action.yaml | 6 +- packages/swift-sdk/run_tests.sh | 32 ++- .../scripts/test_run_tests_keychain.py | 213 ++++++++++++++++++ 3 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 packages/swift-sdk/scripts/test_run_tests_keychain.py diff --git a/.github/actions/librocksdb/action.yaml b/.github/actions/librocksdb/action.yaml index 8bb32f9fae1..3edae56999e 100644 --- a/.github/actions/librocksdb/action.yaml +++ b/.github/actions/librocksdb/action.yaml @@ -21,7 +21,7 @@ runs: uses: actions/cache@v5 id: librocksdb-cache with: - key: librocksdb/${{ inputs.version }}/${{ runner.os }}/${{ runner.arch }} + key: librocksdb/pic-v1/${{ inputs.version }}/${{ runner.os }}/${{ runner.arch }} path: /opt/rocksdb - if: ${{ steps.librocksdb-cache.outputs.cache-hit != 'true' || inputs.force == 'true' }} @@ -29,6 +29,10 @@ runs: name: Build librocksdb env: PORTABLE: 1 + # This static archive is also linked into Rust cdylibs. In particular, + # RocksDB's thread-local objects must not use non-PIC relocations. + EXTRA_CFLAGS: -fPIC + EXTRA_CXXFLAGS: -fPIC run: | set -ex WORKDIR=/tmp/rocksdb-build diff --git a/packages/swift-sdk/run_tests.sh b/packages/swift-sdk/run_tests.sh index 47ca4b095d3..d60c91dfaf9 100755 --- a/packages/swift-sdk/run_tests.sh +++ b/packages/swift-sdk/run_tests.sh @@ -23,7 +23,17 @@ cd "$SCRIPT_DIR" || exit 1 # touches a developer's keychain configuration; the previous default and # search list are restored on exit. if [ -n "${CI:-}${GITHUB_ACTIONS:-}" ]; then - PREV_DEFAULT_KEYCHAIN="$(security default-keychain -d user | sed -E 's/^[[:space:]]*"?//;s/"?[[:space:]]*$//')" + # A headless service account need not have a login/default keychain yet. + # Only accept that specific absence; other inspection errors must fail + # before we change any keychain preferences. + if PREV_DEFAULT_KEYCHAIN_OUTPUT="$(LC_ALL=C security default-keychain -d user 2>&1)"; then + PREV_DEFAULT_KEYCHAIN="$(printf '%s\n' "$PREV_DEFAULT_KEYCHAIN_OUTPUT" | sed -E 's/^[[:space:]]*"?//;s/"?[[:space:]]*$//')" + elif [ "$PREV_DEFAULT_KEYCHAIN_OUTPUT" = 'security: SecKeychainCopyDomainDefault user: A default keychain could not be found.' ]; then + PREV_DEFAULT_KEYCHAIN="" + else + printf '%s\n' "$PREV_DEFAULT_KEYCHAIN_OUTPUT" >&2 + exit 1 + fi PREV_USER_KEYCHAINS_OUTPUT="$(security list-keychains -d user)" PREV_USER_KEYCHAINS=() while IFS= read -r keychain_path; do @@ -32,10 +42,6 @@ if [ -n "${CI:-}${GITHUB_ACTIONS:-}" ]; then PREV_USER_KEYCHAINS+=("$keychain_path") fi done <<< "$PREV_USER_KEYCHAINS_OUTPUT" - if [ "${#PREV_USER_KEYCHAINS[@]}" -eq 0 ]; then - echo "No user keychain search list is configured" >&2 - exit 1 - fi CI_KEYCHAIN_DIR="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dash-ci-keychain.XXXXXX")" CI_KEYCHAIN="$CI_KEYCHAIN_DIR/tests.keychain-db" @@ -49,12 +55,16 @@ if [ -n "${CI:-}${GITHUB_ACTIONS:-}" ]; then trap - EXIT if [ "${CI_DEFAULT_MAY_HAVE_CHANGED:-0}" -eq 1 ]; then - if ! security default-keychain -d user -s "$PREV_DEFAULT_KEYCHAIN"; then + # With no keychain argument, -s clears the default instead of trying + # to open an empty path. Restore the original absence when applicable. + if ! security default-keychain -d user -s ${PREV_DEFAULT_KEYCHAIN:+"$PREV_DEFAULT_KEYCHAIN"}; then cleanup_status=1 fi fi if [ "${CI_SEARCH_LIST_MAY_HAVE_CHANGED:-0}" -eq 1 ]; then - if ! security list-keychains -d user -s "${PREV_USER_KEYCHAINS[@]}"; then + # Guard the empty array for nounset on macOS's Bash 3.2. No arguments + # after -s restores an empty search list, which is valid on a new user. + if ! security list-keychains -d user -s ${PREV_USER_KEYCHAINS[@]+"${PREV_USER_KEYCHAINS[@]}"}; then cleanup_status=1 fi fi @@ -84,12 +94,14 @@ if [ -n "${CI:-}${GITHUB_ACTIONS:-}" ]; then CI_KEYCHAIN_PASSWORD="$(openssl rand -hex 32)" CI_KEYCHAIN_MAY_EXIST=1 + # Creation itself can register the first keychain as the user default and + # append it to the search list. Restore both even if unlock/setup fails. + CI_DEFAULT_MAY_HAVE_CHANGED=1 + CI_SEARCH_LIST_MAY_HAVE_CHANGED=1 security create-keychain -p "$CI_KEYCHAIN_PASSWORD" "$CI_KEYCHAIN" security unlock-keychain -p "$CI_KEYCHAIN_PASSWORD" "$CI_KEYCHAIN" security set-keychain-settings -u -t 7200 "$CI_KEYCHAIN" - CI_SEARCH_LIST_MAY_HAVE_CHANGED=1 - security list-keychains -d user -s "$CI_KEYCHAIN" "${PREV_USER_KEYCHAINS[@]}" - CI_DEFAULT_MAY_HAVE_CHANGED=1 + security list-keychains -d user -s "$CI_KEYCHAIN" ${PREV_USER_KEYCHAINS[@]+"${PREV_USER_KEYCHAINS[@]}"} security default-keychain -d user -s "$CI_KEYCHAIN" SELECTED_DEFAULT_KEYCHAIN="$(security default-keychain -d user | sed -E 's/^[[:space:]]*"?//;s/"?[[:space:]]*$//')" diff --git a/packages/swift-sdk/scripts/test_run_tests_keychain.py b/packages/swift-sdk/scripts/test_run_tests_keychain.py new file mode 100644 index 00000000000..432c1a7a967 --- /dev/null +++ b/packages/swift-sdk/scripts/test_run_tests_keychain.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Exercise run_tests.sh's keychain lifecycle without touching macOS keychains. + +Run with python3 -m unittest discover -s packages/swift-sdk/scripts \ + -p test_run_tests_keychain.py -v + +The real entrypoint runs against stateful command doubles, including creation's +preference side effects. Native CI must still prove Security.framework access +and run the unchanged Swift and simulator suites. +""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "run_tests.sh" + +FAKE_COMMAND = r'''#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import sys + +path = Path(os.environ["KEYCHAIN_TEST_STATE"]) +state = json.loads(path.read_text()) +command = Path(sys.argv[0]).name +args = sys.argv[1:] +operation = args[0] if command == "security" else command +if operation in ("default-keychain", "list-keychains"): + operation += "-set" if "-s" in args else "-query" + if "-s" in args and (len(args) == 4 or state["created"] not in args[4:]): + operation += "-restore" +state["calls"].append(operation) +status = 0 +output = "" +error = "" + +if operation == "default-keychain-query": + if state["default"] is None: + error = "security: SecKeychainCopyDomainDefault user: A default keychain could not be found." + status = 1 + else: + output = ' "' + state["default"] + '"' +elif operation == "list-keychains-query": + output = "\n".join(' "' + item + '"' for item in state["search"]) +elif operation == "create-keychain": + state["created"] = args[-1] + Path(args[-1]).touch() + # SecKeychainCreate can change preferences before later setup commands. + if state["default"] is None: + state["default"] = args[-1] + state["search"].append(args[-1]) +elif operation.startswith("default-keychain-set"): + assert args[1:4] == ["-d", "user", "-s"] + assert len(args) == 4 or (len(args) == 5 and args[4]) + state["default"] = args[4] if len(args) == 5 else None +elif operation.startswith("list-keychains-set"): + assert args[1:4] == ["-d", "user", "-s"] + assert all(args[4:]) # An empty argument is not an empty search list. + state["search"] = args[4:] +elif operation == "delete-keychain": + Path(args[-1]).unlink() +elif operation == "add-generic-password": + state["smoke"] = args[args.index("-w") + 1] +elif operation == "find-generic-password": + output = "wrong value" if state.get("bad_smoke") else state["smoke"] +elif operation == "delete-generic-password": + state["smoke"] = None +elif operation in ("build-step", "swift", "xcodebuild"): + state["build_commands"].append([command, *args]) + if os.environ.get("CI"): + assert state["default"] == state["created"] + assert state["search"][0] == state["created"] + assert state["smoke"] is None + assert "delete-generic-password" in state["calls"] +else: + assert operation in ("unlock-keychain", "set-keychain-settings"), operation + +# Inject failure after a partial mutation as well as on read-only operations. +if operation in state.get("fail", []): + status = 37 + error = "injected failure: " + operation + output = "" +path.write_text(json.dumps(state)) +if output: + print(output) +if error: + print(error, file=sys.stderr) +sys.exit(status) +''' + + +class KeychainLifecycleTests(unittest.TestCase): + def run_entrypoint(self, default=None, search=(), *, ci=True, **options): + with tempfile.TemporaryDirectory(prefix="swift keychain test ") as temp: + root = Path(temp) + bin_dir = root / "bin" + bin_dir.mkdir() + runner_temp = root / "runner temp" + runner_temp.mkdir() + shutil.copyfile(SCRIPT, root / "run_tests.sh") + (root / "build_ios.sh").write_text('exec build-step "$@"\n') + for name in ("security", "build-step", "swift", "xcodebuild"): + command = bin_dir / name + command.write_text(FAKE_COMMAND) + command.chmod(0o755) + state_path = root / "state.json" + state_path.write_text(json.dumps({ + "default": default, "search": list(search), "created": None, + "calls": [], "build_commands": [], "smoke": None, **options, + })) + env = dict(os.environ) + env.pop("CI", None) + env.pop("GITHUB_ACTIONS", None) + if ci: + env["CI"] = "true" + env.update({ + "PATH": str(bin_dir) + os.pathsep + env["PATH"], + "KEYCHAIN_TEST_STATE": str(state_path), + "RUNNER_TEMP": str(runner_temp), + "SIM_NAME": "iPhone Test", + }) + result = subprocess.run( + ["bash", str(root / "run_tests.sh")], env=env, + capture_output=True, text=True, timeout=30, + ) + state = json.loads(state_path.read_text()) + self.assertEqual(list(runner_temp.iterdir()), [], result.stderr) + return result, state + + def assert_restored(self, state, default=None, search=()): + self.assertEqual(state["default"], default) + self.assertEqual(state["search"], list(search)) + + def test_should_run_all_suites_and_restore_empty_or_existing_preferences(self): + login = "/Users/CI Runner/Library/Keychains/login.keychain-db" + extra = "/Volumes/Runner Data/extra.keychain-db" + for default, search in ((None, ()), (None, (extra,)), + (login, (login, extra)), (login, ())): + with self.subTest(default=default, search=search): + result, state = self.run_entrypoint(default, search) + self.assertEqual(result.returncode, 0, result.stderr) + self.assert_restored(state, default, search) + self.assertEqual(state["build_commands"], [ + ["build-step", "--target", "tests", "--profile", "dev"], + ["swift", "test"], + ["xcodebuild", "test", "-project", + "SwiftExampleApp/SwiftExampleApp.xcodeproj", "-scheme", + "SwiftExampleApp", "-skip-testing:SwiftExampleAppUITests", + "-destination", "platform=iOS Simulator,name=iPhone Test"], + ]) + + def test_should_abort_on_inspection_errors_without_mutating_preferences(self): + for operation in ("default-keychain-query", "list-keychains-query"): + with self.subTest(operation=operation): + result, state = self.run_entrypoint(fail=[operation]) + self.assertNotEqual(result.returncode, 0) + self.assertIn("injected failure: " + operation, result.stderr) + self.assertIsNone(state["created"]) + self.assert_restored(state) + self.assertEqual(state["build_commands"], []) + + def test_should_restore_preferences_after_partial_setup_or_smoke_failure(self): + for operation in ("create-keychain", "unlock-keychain", "set-keychain-settings", + "list-keychains-set", "default-keychain-set", + "add-generic-password", "find-generic-password", + "delete-generic-password"): + with self.subTest(operation=operation): + result, state = self.run_entrypoint(fail=[operation]) + self.assertEqual(result.returncode, 37, result.stderr) + self.assert_restored(state) + self.assertEqual(state["build_commands"], []) + self.assertEqual(state["calls"][-1], "delete-keychain") + + def test_should_fail_on_smoke_readback_mismatch_and_restore_preferences(self): + result, state = self.run_entrypoint(bad_smoke=True) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("read-back did not match", result.stderr) + self.assertEqual(state["build_commands"], []) + self.assert_restored(state) + + def test_should_preserve_build_and_test_failures_while_cleaning_up(self): + for index, operation in enumerate(("build-step", "swift", "xcodebuild"), 1): + with self.subTest(operation=operation): + result, state = self.run_entrypoint(fail=[operation]) + self.assertEqual(result.returncode, 37, result.stderr) + self.assertEqual(len(state["build_commands"]), index) + self.assert_restored(state) + + def test_should_report_cleanup_failure_without_masking_a_test_failure(self): + for fail, status in ((["default-keychain-set-restore"], 1), + (["swift", "default-keychain-set-restore"], 37)): + with self.subTest(fail=fail): + result, state = self.run_entrypoint(fail=fail) + self.assertEqual(result.returncode, status, result.stderr) + self.assertEqual(state["calls"][-2:], [ + "list-keychains-set-restore", "delete-keychain", + ]) + + def test_should_leave_developer_keychains_untouched_outside_ci(self): + result, state = self.run_entrypoint(ci=False) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(state["calls"], ["build-step", "swift", "xcodebuild"]) + self.assert_restored(state) + + +if __name__ == "__main__": + unittest.main() From 6d42b152f25d64f42e5f0d2af0ccea97e96b5b99 Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:27:39 +0300 Subject: [PATCH 089/113] fix(kotlin-sdk): prepare secure emulator state in nightly tests --- .github/scripts/kotlin-instrumented-tests.sh | 2 +- .github/workflows/kotlin-sdk-nightly.yml | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/scripts/kotlin-instrumented-tests.sh b/.github/scripts/kotlin-instrumented-tests.sh index 586d43ab8c5..d0b239a64bc 100644 --- a/.github/scripts/kotlin-instrumented-tests.sh +++ b/.github/scripts/kotlin-instrumented-tests.sh @@ -31,4 +31,4 @@ if [ "$unlocked" != true ]; then exit 1 fi -./gradlew :sdk:connectedDebugAndroidTest --stacktrace +./gradlew :sdk:connectedDebugAndroidTest --stacktrace "$@" diff --git a/.github/workflows/kotlin-sdk-nightly.yml b/.github/workflows/kotlin-sdk-nightly.yml index 14fab4520a1..9be76995c46 100644 --- a/.github/workflows/kotlin-sdk-nightly.yml +++ b/.github/workflows/kotlin-sdk-nightly.yml @@ -15,8 +15,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - with: - ref: v4.1-dev + # Scheduled runs test the default branch; dispatches test their selected + # ref. Do not silently compile an old release branch with newer CI. - name: Free disk space run: | @@ -90,7 +90,9 @@ jobs: working-directory: packages/kotlin-sdk # -Ptestnet=true lifts the TestnetGuard so the live-network # queries (identity fetch, DPNS resolve, contract fetch) run. - script: ./gradlew :sdk:connectedDebugAndroidTest -Ptestnet=true --stacktrace + # Use the same secure-lockscreen setup as daytime instrumented tests. + # Keystore authentication-required keys cannot be created otherwise. + script: bash ../../.github/scripts/kotlin-instrumented-tests.sh -Ptestnet=true - name: Upload reports on failure if: failure() From bf196720d2e1f2ac8b77ea2d9c7e76e6b41c23a7 Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:36:22 +0300 Subject: [PATCH 090/113] fix: bound doctest linker concurrency on CI runners --- .github/workflows/tests-rs-workspace.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index 583055bd168..19e21262574 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -637,11 +637,13 @@ jobs: - name: Run doctests if: ${{ inputs.doctests-changed }} run: | + # Rustdoc compiles/links examples concurrently, independently of + # Cargo's build-job limit. Bound it too for memory-limited runners. cargo test \ --workspace \ --all-features \ --locked \ - --doc + --doc -- --test-threads="${CARGO_BUILD_JOBS:-2}" env: CARGO_PROFILE_DEV_DEBUG: "0" CARGO_PROFILE_DEV_CODEGEN_UNITS: "256" From f33d18be5f84c4ca2ab2683f4ef600ef9fc094fe Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:46:02 +0300 Subject: [PATCH 091/113] fix: update trusted runner image controller pin --- .github/workflows/runner-image-candidate.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/runner-image-candidate.yml b/.github/workflows/runner-image-candidate.yml index 76354aaad5d..86ff3d2d677 100644 --- a/.github/workflows/runner-image-candidate.yml +++ b/.github/workflows/runner-image-candidate.yml @@ -23,10 +23,10 @@ jobs: if: >- (github.event.action != 'closed' && !github.event.pull_request.draft) || (github.event.action == 'closed' && github.event.pull_request.merged) - uses: dashpay/dash-selfhosted-image/.github/workflows/platform-candidate.yml@baf8849b900555d66714e0e1fcffdff669b9e404 + uses: dashpay/dash-selfhosted-image/.github/workflows/platform-candidate.yml@07811cd919f6956ba9c6d69a3a1bff4550eb3761 with: pull_request: ${{ github.event.pull_request.number }} - control_revision: baf8849b900555d66714e0e1fcffdff669b9e404 + control_revision: 07811cd919f6956ba9c6d69a3a1bff4550eb3761 mode: ${{ github.event.action == 'closed' && 'promote' || 'candidate' }} secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} From 815756877a6b8edc1cf6b9ef4f71b400aa4e35e8 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 21:46:44 +0700 Subject: [PATCH 092/113] fix(drive)!: refuse a duplicate value in a unique index on a nested property (PV14) (#5127) Co-authored-by: Claude Opus 5.5 --- .../validate_uniqueness_of_data/mod.rs | 37 +- .../validate_uniqueness_of_data/v2/mod.rs | 419 ++++++++++++++++++ .../drive/document/index_uniqueness/mod.rs | 6 +- .../v0/mod.rs | 13 +- .../drive_document_method_versions/mod.rs | 5 + .../drive_document_method_versions/v1.rs | 1 + .../drive_document_method_versions/v2.rs | 1 + .../drive_document_method_versions/v3.rs | 1 + .../drive_document_method_versions/v4.rs | 1 + 9 files changed, 475 insertions(+), 9 deletions(-) create mode 100644 packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v2/mod.rs diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/mod.rs index a5879c25f3b..7ee3cfcca60 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/mod.rs @@ -1,7 +1,9 @@ mod v0; mod v1; +mod v2; use crate::drive::Drive; +use crate::error::drive::DriveError; use crate::error::Error; use std::borrow::Cow; @@ -180,20 +182,47 @@ impl Drive { /// /// # Errors /// - /// This function will return an error if the version of the Drive is unknown. + /// This function will return an error if the version of the Drive is unknown, or if the + /// request is not the one the selected generation takes. pub(in crate::drive::document::index_uniqueness) fn validate_uniqueness_of_data( &self, request: UniquenessOfDataRequest, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { - match request { - UniquenessOfDataRequest::V0(v0) => { + // Each table pairs the generation with the request its callers build: + // tables selecting 0 select every caller at 0, which builds the V0 + // request, and tables selecting 1 or 2 select every caller at 1 (or, + // for the restore, at a version only protocol version 14 on reaches), + // which builds the V1 request. Before this generation was read from + // the table the request alone picked v0 or v1, and those pairings + // keep that choice at every protocol version up to 13. + match ( + platform_version + .drive + .methods + .document + .index_uniqueness + .validate_uniqueness_of_data, + request, + ) { + (0, UniquenessOfDataRequest::V0(v0)) => { self.validate_uniqueness_of_data_v0(v0, transaction, platform_version) } - UniquenessOfDataRequest::V1(v1) => { + (1, UniquenessOfDataRequest::V1(v1)) => { self.validate_uniqueness_of_data_v1(v1, transaction, platform_version) } + (2, UniquenessOfDataRequest::V1(v1)) => { + self.validate_uniqueness_of_data_v2(v1, transaction, platform_version) + } + (0..=2, _) => Err(Error::Drive(DriveError::CorruptedCodeExecution( + "the uniqueness request is not the one this uniqueness generation takes", + ))), + (version, _) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "validate_uniqueness_of_data".to_string(), + known_versions: vec![0, 1, 2], + received: version, + })), } } } diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v2/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v2/mod.rs new file mode 100644 index 00000000000..05b4edefca1 --- /dev/null +++ b/packages/rs-drive/src/drive/document/index_uniqueness/internal/validate_uniqueness_of_data/v2/mod.rs @@ -0,0 +1,419 @@ +use crate::drive::document::index_uniqueness::internal::validate_uniqueness_of_data::UniquenessOfDataRequestV1; +use crate::drive::Drive; +use crate::error::Error; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use grovedb::TransactionArg; +use std::collections::BTreeMap; + +impl Drive { + /// Validates the uniqueness of data for version 2. + /// + /// Version 1 with one change: an index property named with a dot, such + /// as `profile.handle`, is read as a path into nested objects, the way + /// the insert path reads it. Version 1 looked the whole name up as one + /// top-level field, found nothing, and skipped the index as incomplete. + /// + /// Version 1 reads the document data only to look up the properties of + /// unique indexes by name, so this resolves those values first, a dotted + /// name as a path, and hands version 1 a map keyed by the index property + /// names. A property name holds no dot, so a dotted key hides no field. + /// + /// A replace records its changed fields by top-level name, so version 1 + /// never counts a nested property as changed and lets the check find the + /// document itself. That keeps an edit of a sibling field in the same + /// object from colliding with the document's own value, and it is safe: + /// the query can only return the document itself when its indexed values + /// did not change. + /// + /// A path running through a value that is not an object is returned as + /// an error rather than read as an absent value, which would skip the + /// index. + #[inline(always)] + pub(super) fn validate_uniqueness_of_data_v2( + &self, + request: UniquenessOfDataRequestV1, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let mut index_values = BTreeMap::new(); + for index in request + .document_type + .indexes() + .values() + .filter(|index| index.unique) + { + for property in &index.properties { + let value = request + .data + .get_optional_at_path(&property.name) + .map_err(|error| Error::Protocol(Box::new(ProtocolError::ValueError(error))))?; + if let Some(value) = value { + index_values.insert(property.name.clone(), value.clone()); + } + } + } + self.validate_uniqueness_of_data_v1( + UniquenessOfDataRequestV1 { + data: &index_values, + ..request + }, + transaction, + platform_version, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::drive::document::index_uniqueness::internal::validate_uniqueness_of_data::{ + UniquenessOfDataRequest, UniquenessOfDataRequestUpdateType, UniquenessOfDataRequestV0, + }; + use crate::error::drive::DriveError; + use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; + use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; + use crate::util::storage_flags::StorageFlags; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::DataContractFactory; + use dpp::document::{Document, DocumentV0}; + use dpp::identifier::Identifier; + use dpp::platform_value::{platform_value, Value}; + use dpp::prelude::DataContract; + use std::borrow::Cow; + use std::collections::BTreeSet; + + const ALICE_CARD: [u8; 32] = [0xA1; 32]; + const BOB_CARD: [u8; 32] = [0xB0; 32]; + + /// A `card` type whose unique index is on the nested `profile.handle` + fn build_card_contract(platform_version: &PlatformVersion) -> DataContract { + DataContractFactory::new(platform_version.protocol_version) + .expect("expected a contract factory") + .create_with_value_config( + Identifier::from([0x0C; 32]), + 0, + platform_value!({"card": { + "type": "object", + "documentsMutable": true, + "properties": { + "profile": { + "type": "object", + "position": 0, + "properties": { + "handle": {"type": "string", "maxLength": 63, "position": 0}, + "bio": {"type": "string", "maxLength": 63, "position": 1} + }, + "required": ["handle"], + "additionalProperties": false + } + }, + "required": ["profile"], + "indices": [ + {"name": "byHandle", "properties": [{"profile.handle": "asc"}], "unique": true} + ], + "additionalProperties": false + }}), + None, + None, + ) + .expect("expected the contract to parse") + .data_contract_owned() + } + + fn card_data(handle: &str, bio: &str) -> BTreeMap { + BTreeMap::from([( + "profile".to_string(), + platform_value!({"handle": handle, "bio": bio}), + )]) + } + + /// A drive holding the card contract and Alice's card under `sam` + fn setup(platform_version: &'static PlatformVersion) -> (crate::drive::Drive, DataContract) { + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = build_card_contract(platform_version); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + insert_card(&drive, &contract, ALICE_CARD, "sam", platform_version); + (drive, contract) + } + + fn insert_card( + drive: &crate::drive::Drive, + contract: &DataContract, + id: [u8; 32], + handle: &str, + platform_version: &PlatformVersion, + ) { + let document = Document::V0(DocumentV0 { + id: Identifier::from(id), + owner_id: Identifier::from(id), + properties: card_data(handle, "first"), + revision: Some(1), + ..Default::default() + }); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + StorageFlags::optional_default_as_cow(), + )), + owner_id: Some(id), + }, + contract, + document_type: contract + .document_type_for_name("card") + .expect("expected the card type"), + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("expected to add the card"); + } + + fn check( + drive: &crate::drive::Drive, + contract: &DataContract, + document_id: [u8; 32], + data: &BTreeMap, + update_type: UniquenessOfDataRequestUpdateType, + platform_version: &PlatformVersion, + ) -> Result { + let request = UniquenessOfDataRequestV1 { + contract, + document_type: contract + .document_type_for_name("card") + .expect("expected the card type"), + owner_id: Identifier::from(document_id), + creator_id: None, + document_id: Identifier::from(document_id), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + data, + update_type, + }; + drive.validate_uniqueness_of_data( + UniquenessOfDataRequest::V1(request), + None, + platform_version, + ) + } + + /// A replace whose data changed inside `profile` + fn profile_changed() -> UniquenessOfDataRequestUpdateType<'static> { + UniquenessOfDataRequestUpdateType::ChangedDocument { + changed_owner_id: false, + changed_updated_at: false, + changed_transferred_at: false, + changed_updated_at_block_height: false, + changed_transferred_at_block_height: false, + changed_updated_at_core_block_height: false, + changed_transferred_at_core_block_height: false, + changed_data_values: Cow::Owned(BTreeSet::from(["profile".to_string()])), + } + } + + fn is_duplicate(result: &SimpleConsensusValidationResult) -> bool { + matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DuplicateUniqueIndexError(_) + )] + ) + } + + #[test] + fn should_refuse_a_new_document_taking_a_stored_nested_unique_value() { + let platform_version = PlatformVersion::latest(); + let (drive, contract) = setup(platform_version); + + let result = check( + &drive, + &contract, + BOB_CARD, + &card_data("sam", ""), + UniquenessOfDataRequestUpdateType::NewDocument, + platform_version, + ) + .expect("expected the check to run"); + assert!(is_duplicate(&result), "got {:?}", result.errors); + } + + #[test] + fn should_accept_a_new_document_with_a_free_nested_unique_value() { + let platform_version = PlatformVersion::latest(); + let (drive, contract) = setup(platform_version); + + let result = check( + &drive, + &contract, + BOB_CARD, + &card_data("bob", ""), + UniquenessOfDataRequestUpdateType::NewDocument, + platform_version, + ) + .expect("expected the check to run"); + assert!(result.is_valid(), "got {:?}", result.errors); + } + + /// Only `profile.bio` changed, but the replace records `profile` as + /// changed: the check must still find Alice's own card and accept it + #[test] + fn should_accept_a_changed_document_keeping_its_nested_value_when_a_sibling_changes() { + let platform_version = PlatformVersion::latest(); + let (drive, contract) = setup(platform_version); + + let result = check( + &drive, + &contract, + ALICE_CARD, + &card_data("sam", "edited"), + profile_changed(), + platform_version, + ) + .expect("expected the check to run"); + assert!(result.is_valid(), "got {:?}", result.errors); + } + + #[test] + fn should_refuse_a_changed_document_moving_into_a_taken_nested_value() { + let platform_version = PlatformVersion::latest(); + let (drive, contract) = setup(platform_version); + insert_card(&drive, &contract, BOB_CARD, "bob", platform_version); + + let result = check( + &drive, + &contract, + BOB_CARD, + &card_data("sam", ""), + profile_changed(), + platform_version, + ) + .expect("expected the check to run"); + assert!(is_duplicate(&result), "got {:?}", result.errors); + } + + /// A path running through a value that is not an object is an error, not + /// an absent value that would skip the index + #[test] + fn should_return_an_error_for_a_path_through_a_value_that_is_not_an_object() { + let platform_version = PlatformVersion::latest(); + let (drive, contract) = setup(platform_version); + let data = BTreeMap::from([("profile".to_string(), Value::Text("sam".to_string()))]); + + for update_type in [ + UniquenessOfDataRequestUpdateType::NewDocument, + profile_changed(), + ] { + assert!(matches!( + check( + &drive, + &contract, + BOB_CARD, + &data, + update_type, + platform_version + ), + Err(Error::Protocol(error)) if matches!(*error, ProtocolError::ValueError(_)) + )); + } + } + + /// Protocol version 13 selects generation 1, which skips the nested index; + /// the latest selects generation 2, which checks it + #[test] + fn should_check_a_nested_unique_index_from_generation_2_only() { + let platform_version_13 = PlatformVersion::get(13).expect("expected protocol version 13"); + let (drive, contract) = setup(platform_version_13); + let result = check( + &drive, + &contract, + BOB_CARD, + &card_data("sam", ""), + UniquenessOfDataRequestUpdateType::NewDocument, + platform_version_13, + ) + .expect("expected the check to run"); + assert!( + result.is_valid(), + "generation 1 skips the nested index: {:?}", + result.errors + ); + + let platform_version = PlatformVersion::latest(); + let (drive, contract) = setup(platform_version); + let result = check( + &drive, + &contract, + BOB_CARD, + &card_data("sam", ""), + UniquenessOfDataRequestUpdateType::NewDocument, + platform_version, + ) + .expect("expected the check to run"); + assert!(is_duplicate(&result), "got {:?}", result.errors); + } + + /// Generation 2 takes the V1 request only + #[test] + fn should_refuse_a_v0_request_at_generation_2() { + let platform_version = PlatformVersion::latest(); + let (drive, contract) = setup(platform_version); + let data = card_data("sam", ""); + let request = UniquenessOfDataRequestV0 { + contract: &contract, + document_type: contract + .document_type_for_name("card") + .expect("expected the card type"), + owner_id: Identifier::from(BOB_CARD), + document_id: Identifier::from(BOB_CARD), + allow_original: false, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + data: &data, + }; + assert!(matches!( + drive.validate_uniqueness_of_data( + UniquenessOfDataRequest::V0(request), + None, + platform_version + ), + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + )); + } +} diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs index 75f59056201..0dc60759124 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs @@ -450,7 +450,8 @@ mod tests { /// index (allow_original remains true). #[test] fn validate_uniqueness_of_data_v1_changed_document_allows_original() { - let platform_version = PlatformVersion::latest(); + // The last protocol version selecting uniqueness generation 1 + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); let (drive, dpns) = setup_drive_with_dpns(platform_version); let document_type = dpns @@ -590,7 +591,8 @@ mod tests { /// check is skipped, yielding a valid result. #[test] fn validate_uniqueness_of_data_v1_exits_early_when_required_timestamp_missing() { - let platform_version = PlatformVersion::latest(); + // The last protocol version selecting uniqueness generation 1 + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); let (drive, dpns) = setup_drive_with_dpns(platform_version); // Use the domain document type because it has required timestamps diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/validate_restored_document_uniqueness/v0/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/validate_restored_document_uniqueness/v0/mod.rs index 0a3e12f083e..938f52f72db 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/validate_restored_document_uniqueness/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/validate_restored_document_uniqueness/v0/mod.rs @@ -1,4 +1,6 @@ -use crate::drive::document::index_uniqueness::internal::validate_uniqueness_of_data::UniquenessOfDataRequestV0; +use crate::drive::document::index_uniqueness::internal::validate_uniqueness_of_data::{ + UniquenessOfDataRequestUpdateType, UniquenessOfDataRequestV1, +}; use crate::drive::Drive; use crate::error::Error; use dpp::data_contract::document_type::DocumentTypeRef; @@ -10,6 +12,10 @@ use grovedb::TransactionArg; impl Drive { /// Validate that a restored document would be unique in the state + /// + /// A restore reaches this only from protocol version 14, whose table + /// selects uniqueness generation 2, the one taking the V1 request. The + /// removed document holds no index entry, so it is checked as a new one. #[inline(always)] pub(super) fn validate_restored_document_uniqueness_v0( &self, @@ -19,12 +25,12 @@ impl Drive { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { - let request = UniquenessOfDataRequestV0 { + let request = UniquenessOfDataRequestV1 { contract, document_type, owner_id: document.owner_id(), + creator_id: document.creator_id(), document_id: document.id(), - allow_original: false, created_at: document.created_at(), updated_at: document.updated_at(), transferred_at: document.transferred_at(), @@ -35,6 +41,7 @@ impl Drive { updated_at_core_block_height: document.updated_at_core_block_height(), transferred_at_core_block_height: document.transferred_at_core_block_height(), data: document.properties(), + update_type: UniquenessOfDataRequestUpdateType::NewDocument, }; self.validate_uniqueness_of_data(request.into(), transaction, platform_version) } diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs index 71addd4e1f3..c30d9cc47b8 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs @@ -186,6 +186,11 @@ pub struct DriveDocumentIndexUniquenessMethodVersions { pub validate_document_purchase_transition_action_uniqueness: FeatureVersion, pub validate_document_update_price_transition_action_uniqueness: FeatureVersion, pub validate_restored_document_uniqueness: FeatureVersion, + /// The shared uniqueness check every method above reaches. 0 takes the + /// V0 request (protocol versions 1 to 9), 1 the V1 request (10 to 13), + /// 2 the V1 request and reads a dotted index property name as a path + /// into nested objects, the way the insert path reads it (14 on). + pub validate_uniqueness_of_data: FeatureVersion, } #[cfg(test)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs index 392d1c7eb21..488b0bd3b4e 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs @@ -85,6 +85,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V1: DriveDocumentMethodVersions = validate_document_purchase_transition_action_uniqueness: 0, validate_document_update_price_transition_action_uniqueness: 0, validate_restored_document_uniqueness: 0, + validate_uniqueness_of_data: 0, }, primary_key_tree_type: 0, fetch_property_constraint_aggregate: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs index 3353ed72101..940e0bbde0a 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs @@ -87,6 +87,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V2: DriveDocumentMethodVersions = validate_document_purchase_transition_action_uniqueness: 1, // Changed validate_document_update_price_transition_action_uniqueness: 1, // Changed validate_restored_document_uniqueness: 0, + validate_uniqueness_of_data: 1, }, // FROZEN AT 0 for platform versions 10 and 11. Both protocol // versions select this table (`DRIVE_DOCUMENT_METHOD_VERSIONS_V2`) diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs index e0a408f26e6..1faf09adb9d 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs @@ -106,6 +106,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V3: DriveDocumentMethodVersions = validate_document_purchase_transition_action_uniqueness: 1, validate_document_update_price_transition_action_uniqueness: 1, validate_restored_document_uniqueness: 0, + validate_uniqueness_of_data: 1, }, // Bumped to 1 vs V2's frozen 0: this is the v12-gated entry // point for the sum-tree feature. The v1 dispatch arm in diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs index 0b62e55fa94..0df76c21336 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs @@ -174,6 +174,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = validate_document_purchase_transition_action_uniqueness: 1, validate_document_update_price_transition_action_uniqueness: 1, validate_restored_document_uniqueness: 0, + validate_uniqueness_of_data: 2, // changed: dotted index property names are read as paths into nested objects }, // Unchanged from V3 — see V3's comment for the v12-gated // count/sum composition rationale. From 99207ac9d78119c740edaa8106d80dfb805e53a2 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 22:57:41 +0700 Subject: [PATCH 093/113] fix(drive-abci): check_tx refuses a masternode vote a block would refuse (#5137) Co-authored-by: Claude Opus 5.5 --- book/src/contributing/coding-conventions.md | 3 +- .../state-transitions/validation-pipeline.md | 5 ++ .../traits/advanced_structure_with_state.rs | 19 +++--- .../processor/traits/state.rs | 9 +++ .../no_locking_contest_tests.rs | 62 ++++++++++++++++++- .../state_transition/state_transitions/mod.rs | 39 ++++++++++++ 6 files changed, 126 insertions(+), 11 deletions(-) diff --git a/book/src/contributing/coding-conventions.md b/book/src/contributing/coding-conventions.md index ee0556e0325..d564bab8f3b 100644 --- a/book/src/contributing/coding-conventions.md +++ b/book/src/contributing/coding-conventions.md @@ -265,7 +265,8 @@ Rules that fall out of the table: - Preserve mempool coverage. `Batch` runs advanced structure with state during `check_tx`, while full state validation is skipped there (`validates_full_state_on_check_tx` defaults to `false`; masternode votes - are the one transition that opts in, because they are unpaid). Moving a + are the one transition that opts in, because a block refuses them unpaid, + and they run advanced structure with state there too). Moving a contract-dependent structural check into state validation would remove that rejection from mempool admission. - Validation outcomes are `ConsensusValidationResult`, returned as `Ok`. A diff --git a/book/src/state-transitions/validation-pipeline.md b/book/src/state-transitions/validation-pipeline.md index 44801a7de92..859e4a31317 100644 --- a/book/src/state-transitions/validation-pipeline.md +++ b/book/src/state-transitions/validation-pipeline.md @@ -16,6 +16,11 @@ and again when rechecking mempool contents. It performs a lighter validation: signature verification, basic structure, and balance checks. The goal is to filter out obvious garbage cheaply, without doing expensive state lookups. +A `MasternodeVote` is the exception. A block refuses a failed vote without charging anyone, +and the proposer drops it from the block without a trace, so check_tx runs the vote's advanced +structure and state validation as well. A vote that a block would refuse, such as a Lock vote +on a contest without locking, is refused when it is broadcast, with its error. + This is implemented in `packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/mod.rs`: diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/advanced_structure_with_state.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/advanced_structure_with_state.rs index b926d74d3be..9a198e5f715 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/advanced_structure_with_state.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/advanced_structure_with_state.rs @@ -138,10 +138,15 @@ impl StateTransitionStructureKnownInStateValidationV0 for StateTransition { /// possession fail never enters the mempool: the address witnesses do not sign those proofs, /// so their owners should not be charged for them. Admission is not consensus, so every /// protocol version gets this. + /// + /// A masternode vote is checked here for the same reason: a block refuses a vote with the + /// wrong voting key unpaid. Its state validation, which check_tx also runs, needs the action. fn requires_advanced_structure_validation_with_state_on_check_tx(&self) -> bool { matches!( self, - StateTransition::Batch(_) | StateTransition::IdentityCreateFromAddresses(_) + StateTransition::Batch(_) + | StateTransition::IdentityCreateFromAddresses(_) + | StateTransition::MasternodeVote(_) ) } } @@ -270,7 +275,7 @@ mod tests { use super::*; #[test] - fn should_return_true_only_for_batch_and_identity_create_from_addresses() { + fn should_return_true_only_for_batch_identity_create_from_addresses_and_masternode_vote() { let batch = StateTransition::Batch(BatchTransition::V0(BatchTransitionV0::default())); assert!(batch.requires_advanced_structure_validation_with_state_on_check_tx()); let identity_create_from_addresses = StateTransition::IdentityCreateFromAddresses( @@ -280,6 +285,10 @@ mod tests { ); assert!(identity_create_from_addresses .requires_advanced_structure_validation_with_state_on_check_tx()); + let masternode_vote = StateTransition::MasternodeVote(MasternodeVoteTransition::V0( + MasternodeVoteTransitionV0::default(), + )); + assert!(masternode_vote.requires_advanced_structure_validation_with_state_on_check_tx()); } #[test] @@ -291,12 +300,6 @@ mod tests { IdentityCreateTransitionV0::default(), )), ), - ( - "MasternodeVote", - StateTransition::MasternodeVote(MasternodeVoteTransition::V0( - MasternodeVoteTransitionV0::default(), - )), - ), ("DataContractCreate", make_data_contract_create_st()), ]; for (name, st) in transitions { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs index 771ad56e64a..4cdc27f5517 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs @@ -321,6 +321,15 @@ impl StateTransitionStateValidation for StateTransition { | StateTransition::ShieldedWithdrawal(_) => false, } } + + /// A block refuses a masternode vote that fails state validation without charging anyone, + /// and a proposer drops it silently, so only check_tx can tell the voter why. + fn validates_full_state_on_check_tx(&self) -> bool { + match self { + StateTransition::MasternodeVote(st) => st.validates_full_state_on_check_tx(), + _ => false, + } + } } #[cfg(test)] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs index cb76677c7f1..6380355cab6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs @@ -5,8 +5,9 @@ //! towards an identity must name a contender of the poll, on these contests and on DPNS ones. use crate::execution::validation::state_transition::state_transitions::tests::{ - create_dpns_identity_name_contest, dpns_name_vote_poll, get_vote_states, perform_vote, - perform_votes_multi, setup_identity, setup_masternode_voting_identity, + create_dpns_identity_name_contest, dpns_name_vote_poll, first_time_check_tx_errors, + get_vote_states, perform_vote, perform_votes_multi, serialized_dpns_name_vote, + setup_identity, setup_masternode_voting_identity, }; use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::rpc::core::MockCoreRPCLike; @@ -388,6 +389,63 @@ fn winner( ) } +/// A block refuses a Lock vote without charging anyone, and its proposer drops it, so check_tx +/// refuses it when it is broadcast: the voter gets the error instead of a vote that never lands. +#[tokio::test] +async fn should_refuse_a_lock_vote_when_it_is_broadcast() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let alice = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let bob = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + join( + &platform, + &contract, + &alice, + 1, + 10_000, + &mut rng, + platform_version, + ) + .await; + join( + &platform, + &contract, + &bob, + 2, + 20_000, + &mut rng, + platform_version, + ) + .await; + + let (pro_tx_hash, _, signer, voting_key) = + setup_masternode_voting_identity(&mut platform, 0x10c, platform_version); + let lock_vote = serialized_dpns_name_vote( + &contract, + ResourceVoteChoice::Lock, + NAME, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await; + + assert_eq!( + first_time_check_tx_errors( + &platform, + &platform.state.load(), + &lock_vote, + platform_version + ), + vec![VoteChoiceNotAllowedForVotePollError::new( + vote_poll(&contract), + ResourceVoteChoice::Lock + ) + .into()] + ); +} + #[tokio::test] async fn should_refuse_a_lock_vote_and_accept_the_other_choices() { let (mut platform, platform_version, contract, mut rng) = setup(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 75757fdc576..f0ea147d58c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -200,6 +200,9 @@ pub(in crate::execution) mod tests { use crate::execution::types::block_execution_context::BlockExecutionContext; use crate::execution::types::block_execution_context::v0::BlockExecutionContextV0; use crate::expect_match; + use crate::execution::check_tx::CheckTxLevel; + use dpp::consensus::ConsensusError; + use crate::platform_types::platform::PlatformRef; use crate::platform_types::platform_state::PlatformState; use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::platform_types::state_transitions_processing_result::{StateTransitionExecutionResult, StateTransitionsProcessingResult}; @@ -2383,6 +2386,29 @@ pub(in crate::execution) mod tests { .expect("expected to serialize the masternode vote") } + /// The errors check_tx refuses a serialized transition with when it is first broadcast + pub(in crate::execution) fn first_time_check_tx_errors( + platform: &TempPlatform, + platform_state: &PlatformState, + serialized_transition: &[u8], + platform_version: &PlatformVersion, + ) -> Vec { + platform + .check_tx( + serialized_transition, + CheckTxLevel::FirstTimeCheck, + &PlatformRef { + drive: &platform.drive, + state: platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }, + platform_version, + ) + .expect("expected check_tx to run") + .errors + } + #[allow(clippy::too_many_arguments)] pub(in crate::execution) async fn perform_vote( platform: &mut TempPlatform, @@ -2418,6 +2444,19 @@ pub(in crate::execution) mod tests { &masternode_vote_serialized_transition, "masternode vote", ); + } else { + // A block refuses a failed vote without charging anyone, and its proposer drops it + // silently, so check_tx must refuse it first or the voter never learns why. + assert!( + !first_time_check_tx_errors( + platform, + platform_state, + &masternode_vote_serialized_transition, + platform_version, + ) + .is_empty(), + "check_tx must refuse a vote that a block refuses" + ); } let transaction = platform.drive.grove.start_transaction(); From 91db70abce4d105121dc63f0b42bca41c9570dbb Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 22:58:07 +0700 Subject: [PATCH 094/113] fix(sdk): masternodeVote takes the ProTxHash as an Identifier and returns the vote (#5138) Co-authored-by: Claude Opus 5.5 --- packages/js-evo-sdk/src/voting/facade.ts | 2 +- .../tests/unit/facades/voting.spec.ts | 11 ++- .../src/state_transitions/identity.rs | 93 +++++++------------ .../tests/unit/masternode-vote.spec.ts | 50 ++++++++++ 4 files changed, 91 insertions(+), 65 deletions(-) create mode 100644 packages/wasm-sdk/tests/unit/masternode-vote.spec.ts diff --git a/packages/js-evo-sdk/src/voting/facade.ts b/packages/js-evo-sdk/src/voting/facade.ts index 1626578e90b..eb3f52ec4ca 100644 --- a/packages/js-evo-sdk/src/voting/facade.ts +++ b/packages/js-evo-sdk/src/voting/facade.ts @@ -53,7 +53,7 @@ export class VotingFacade { return w.getVotePollsByEndDateWithProofInfo(query); } - async masternodeVote(options: wasm.MasternodeVoteOptions): Promise { + async masternodeVote(options: wasm.MasternodeVoteOptions): Promise { const w = await this.sdk.getWasmSdkConnected(); return w.masternodeVote(options); } diff --git a/packages/js-evo-sdk/tests/unit/facades/voting.spec.ts b/packages/js-evo-sdk/tests/unit/facades/voting.spec.ts index d3fed61c549..6c458327185 100644 --- a/packages/js-evo-sdk/tests/unit/facades/voting.spec.ts +++ b/packages/js-evo-sdk/tests/unit/facades/voting.spec.ts @@ -21,6 +21,7 @@ describe('VotingFacade', () => { let getVotePollsByEndDateStub: SinonStub; let getVotePollsByEndDateWithProofInfoStub: SinonStub; let masternodeVoteStub: SinonStub; + let recordedVote: wasmSDKPackage.Vote; beforeEach(async function setup() { await init(); @@ -60,9 +61,8 @@ describe('VotingFacade', () => { }); // Stub transition method - masternodeVoteStub = this.sinon.stub(wasmSdk, 'masternodeVote').resolves({ - success: true, - }); + recordedVote = Object.create(wasmSDKPackage.Vote.prototype); + masternodeVoteStub = this.sinon.stub(wasmSdk, 'masternodeVote').resolves(recordedVote); }); describe('contestedResourceVoteState()', () => { @@ -167,7 +167,7 @@ describe('VotingFacade', () => { }); describe('masternodeVote()', () => { - it('should cast a vote on a contested resource', async () => { + it('should cast a vote on a contested resource and return the recorded vote', async () => { const options = { masternodeProTxHash, contractId: dataContractId, @@ -178,9 +178,10 @@ describe('VotingFacade', () => { signer, }; - await client.voting.masternodeVote(options); + const vote = await client.voting.masternodeVote(options); expect(masternodeVoteStub).to.be.calledOnceWithExactly(options); + expect(vote).to.equal(recordedVote); }); it('should support abstain vote choice', async () => { diff --git a/packages/wasm-sdk/src/state_transitions/identity.rs b/packages/wasm-sdk/src/state_transitions/identity.rs index f0354c9a9dd..0fd35bb11ab 100644 --- a/packages/wasm-sdk/src/state_transitions/identity.rs +++ b/packages/wasm-sdk/src/state_transitions/identity.rs @@ -12,12 +12,18 @@ use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicK use dash_sdk::dpp::identity::signer::Signer; use dash_sdk::dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::Identifier; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dash_sdk::dpp::voting::vote_polls::VotePoll; +use dash_sdk::dpp::voting::votes::resource_vote::v0::ResourceVoteV0; +use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; +use dash_sdk::dpp::voting::votes::Vote; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; use dash_sdk::platform::transition::update_identity_key_limits::{ raised_key_limits, UpdateIdentityKeyLimits, }; +use dash_sdk::platform::transition::vote::PutVote; use js_sys::BigInt; use serde::Deserialize; use wasm_bindgen::prelude::*; @@ -28,6 +34,9 @@ use wasm_dpp2::utils::{ try_from_options_optional, try_from_options_optional_with, try_from_options_with, try_to_array, try_to_u64, IntoWasm, }; +use wasm_dpp2::voting::resource_vote_choice::ResourceVoteChoiceWasm; +use wasm_dpp2::voting::vote::VoteWasm; +use wasm_dpp2::voting::vote_poll::VotePollWasm; use wasm_dpp2::PrivateKeyWasm; use wasm_dpp2::{IdentityPublicKeyInCreationWasm, IdentitySignerWasm, IdentityWasm}; @@ -714,9 +723,10 @@ const MASTERNODE_VOTE_OPTIONS_TS: &'static str = r#" */ export interface MasternodeVoteOptions { /** - * The ProTxHash of the masternode. + * The ProTxHash of the masternode: an Identifier, its 32 bytes, the hex Core's RPC shows, + * or base58. */ - masternodeProTxHash: Identifier; + masternodeProTxHash: IdentifierLike; /** * The vote poll to vote on. @@ -756,46 +766,26 @@ extern "C" { pub type MasternodeVoteOptionsJs; } -/// Main input struct for masternode vote options. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct MasternodeVoteOptionsInput { - masternode_pro_tx_hash: IdentifierWasm, -} - -fn deserialize_masternode_vote_options( - options: JsValue, -) -> Result { - deserialize_required_query( - options, - "Options object is required", - "masternode vote options", - ) -} - #[wasm_bindgen] impl WasmSdk { /// Submit a masternode vote for a contested resource. /// /// This method handles the complete voting flow: - /// 1. Creates the voting public key from the signer - /// 2. Builds and signs the vote transition - /// 3. Broadcasts and waits for confirmation + /// 1. Builds and signs the vote transition with the voting key + /// 2. Broadcasts it and waits for the block that records it + /// 3. Verifies the proof of the recorded vote /// - /// @param options - Vote options including masternode ID, vote poll, choice, and signer - /// @returns Promise that resolves when the vote is submitted + /// @param options - Vote options including masternode ProTxHash, vote poll, choice, and signer + /// @returns The vote as Platform recorded it #[wasm_bindgen(js_name = "masternodeVote")] pub async fn masternode_vote( &self, options: MasternodeVoteOptionsJs, - ) -> Result<(), WasmSdkError> { - use wasm_dpp2::voting::resource_vote_choice::ResourceVoteChoiceWasm; - use wasm_dpp2::voting::vote_poll::VotePollWasm; - - // Extract complex types first (borrows &options) - let vote_poll: dash_sdk::dpp::voting::vote_polls::VotePoll = - VotePollWasm::try_from_options(&options, "votePoll")?.into(); - let resource_vote_choice: dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice = + ) -> Result { + let pro_tx_hash: Identifier = + IdentifierWasm::try_from_options(&options, "masternodeProTxHash")?.into(); + let vote_poll: VotePoll = VotePollWasm::try_from_options(&options, "votePoll")?.into(); + let resource_vote_choice: ResourceVoteChoice = ResourceVoteChoiceWasm::try_from_options(&options, "voteChoice")?.into(); let voting_public_key: IdentityPublicKey = IdentityPublicKeyWasm::try_from_options(&options, "votingKey")?.into(); @@ -803,37 +793,22 @@ impl WasmSdk { let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); - // Deserialize simple fields last (consumes options) - let parsed = deserialize_masternode_vote_options(options.into())?; - - // Convert ProTxHash - let pro_tx_hash: Identifier = parsed.masternode_pro_tx_hash.into(); - - // Create the resource vote - use dash_sdk::dpp::voting::votes::resource_vote::v0::ResourceVoteV0; - use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; - let resource_vote = ResourceVote::V0(ResourceVoteV0 { + let vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { vote_poll, resource_vote_choice, - }); + })); - // Create the vote - use dash_sdk::dpp::voting::votes::Vote; - let vote = Vote::ResourceVote(resource_vote); - - // Submit the vote using PutVote trait - use dash_sdk::platform::transition::vote::PutVote; - - vote.put_to_platform( - pro_tx_hash, - &voting_public_key, - self.inner_sdk(), - &signer, - settings, - ) - .await?; + let recorded_vote = vote + .put_to_platform_and_wait_for_response( + pro_tx_hash, + &voting_public_key, + self.inner_sdk(), + &signer, + settings, + ) + .await?; - Ok(()) + Ok(recorded_vote.into()) } } diff --git a/packages/wasm-sdk/tests/unit/masternode-vote.spec.ts b/packages/wasm-sdk/tests/unit/masternode-vote.spec.ts new file mode 100644 index 00000000000..c37b52c523d --- /dev/null +++ b/packages/wasm-sdk/tests/unit/masternode-vote.spec.ts @@ -0,0 +1,50 @@ +/** + * `masternodeVote` takes the masternode's ProTxHash as an `IdentifierLike` and reads it before + * the other options, so a call whose only fault is its missing vote poll fails on the vote poll: + * its ProTxHash was accepted, and nothing reached the network. + */ +import { expect } from './helpers/chai.ts'; +import init, * as sdk from '../../dist/sdk.compressed.js'; + +const proTxHashHex = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2'; + +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (e) { + return e as Error; + } + throw new Error('expected the vote to be refused'); +} + +describe('masternodeVote()', () => { + let client: sdk.WasmSdk; + + before(async () => { + await init(); + client = await sdk.WasmSdkBuilder.testnet().build(); + }); + + const accepted: Array<[string, () => unknown]> = [ + ['an Identifier', () => new sdk.Identifier(proTxHashHex)], + ['its 32 bytes', () => new sdk.Identifier(proTxHashHex).toBytes()], + ['the hex Core shows', () => proTxHashHex], + ['base58', () => new sdk.Identifier(proTxHashHex).toBase58()], + ]; + + accepted.forEach(([form, proTxHash]) => { + it(`should accept the ProTxHash as ${form}`, async () => { + const error = await rejectionOf(client.masternodeVote({ + masternodeProTxHash: proTxHash(), + } as never)); + expect(error.message).to.match(/'votePoll' is required/); + }); + }); + + it('should refuse a ProTxHash that is not an identifier before the vote poll', async () => { + const error = await rejectionOf(client.masternodeVote({ + masternodeProTxHash: 42, + } as never)); + expect(error.message).to.not.match(/votePoll/); + }); +}); From b936d4c47157202de72cbaa985fc5f6918076002 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 23:01:15 +0700 Subject: [PATCH 095/113] fix(sdk): index-only document creates and deletes resolve once they land (#5136) Co-authored-by: Claude Opus 5.5 --- packages/js-evo-sdk/src/documents/facade.ts | 7 +++++ .../platform/documents/transitions/create.rs | 28 +++++++++++++++-- .../platform/documents/transitions/delete.rs | 31 ++++++++++++++++--- .../src/platform/transition/broadcast.rs | 6 ++-- .../src/platform/transition/put_document.rs | 15 +++++++-- .../src/state_transitions/broadcast.rs | 4 ++- .../src/state_transitions/document.rs | 10 ++++-- 7 files changed, 86 insertions(+), 15 deletions(-) diff --git a/packages/js-evo-sdk/src/documents/facade.ts b/packages/js-evo-sdk/src/documents/facade.ts index 353c46a75c6..f1ecb2039e5 100644 --- a/packages/js-evo-sdk/src/documents/facade.ts +++ b/packages/js-evo-sdk/src/documents/facade.ts @@ -106,6 +106,8 @@ export class DocumentsFacade { * instance when you later intend to delete an indexOnly document whose * type requires `$createdAt`. A document of a contested index joins a * contest: `options.contestFund` is the most, in credits, it pays into it. + * For an indexOnly type the proof shows the document's entry at the proof's + * block, not that this create wrote it: no stronger proof exists for one. */ async create(options: wasm.DocumentCreateOptions): Promise { const w = await this.sdk.getWasmSdkConnected(); @@ -117,6 +119,11 @@ export class DocumentsFacade { return w.documentReplace(options); } + /** + * Deletes a document and resolves once the proof shows it gone. For an + * indexOnly type the proof shows the document's entry gone at the proof's + * block, not that this delete removed it: no stronger proof exists for one. + */ async delete(options: wasm.DocumentDeleteOptions): Promise { const w = await this.sdk.getWasmSdkConnected(); return w.documentDelete(options); diff --git a/packages/rs-sdk/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index 90098883e51..ee424abee94 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -3,6 +3,7 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; use dpp::data_contract::document_type::action_fees::agreement::DocumentActionFeeAgreement; use dpp::data_contract::DataContract; use dpp::document::{Document, DocumentV0Getters}; @@ -252,6 +253,9 @@ impl Sdk { /// /// This method broadcasts a document creation transition to add a new document /// to the specified data contract. The result contains the created document. + /// For an indexOnly document type the proof shows the document's entry at the + /// proof's block, not that this create wrote it: no stronger proof exists for + /// such a document. /// /// # Arguments /// @@ -279,6 +283,11 @@ impl Sdk { let platform_version = self.version(); let put_settings = create_document_transition_builder.settings; + let index_only = create_document_transition_builder + .data_contract + .document_type_for_name(&create_document_transition_builder.document_type_name) + .map_err(|e| Error::Protocol(e.into()))? + .index_only(); let state_transition = create_document_transition_builder .sign(self, signing_key, signer, platform_version) @@ -289,9 +298,22 @@ impl Sdk { trace!(hex = %hex::encode(state_transition.serialize_to_bytes()?), "document_create: transition bytes"); trace!(transition = ?state_transition, "document_create: transition details"); - let proof_result = state_transition - .broadcast_and_wait::(self, put_settings) - .await?; + // An indexOnly document keeps no row: its proof shows the entry the create leaves, + // which an earlier create with the same values would show too, so it cannot prove that + // this create executed. That is the strongest proof such a document has, so it is + // accepted rather than failing a create that landed. + let proof_result = if index_only { + state_transition + .broadcast_and_wait_for_affected_state::( + self, + put_settings, + ) + .await? + } else { + state_transition + .broadcast_and_wait::(self, put_settings) + .await? + }; match proof_result { StateTransitionProofResult::VerifiedDocuments(documents) => { diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 51f7dcf6d20..1544d2bb6de 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -3,6 +3,7 @@ use crate::platform::transition::put_settings::PutSettings; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; use dpp::data_contract::document_type::action_fees::agreement::DocumentActionFeeAgreement; use dpp::data_contract::DataContract; use dpp::document::{Document, INITIAL_REVISION}; @@ -195,7 +196,6 @@ impl DocumentDeleteTransitionBuilder { ), Error, > { - use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; use dpp::document::DocumentV0Getters; let document_type = self @@ -320,7 +320,10 @@ impl Sdk { /// Deletes an existing document from the platform. /// /// This method broadcasts a document deletion transition to permanently remove - /// a document from the platform. The result confirms the deletion. + /// a document from the platform. The result confirms the deletion. For an + /// indexOnly document type the proof shows the document's entry gone at the + /// proof's block, not that this delete removed it: no stronger proof exists + /// for such a document. /// /// # Arguments /// @@ -349,14 +352,32 @@ impl Sdk { let platform_version = self.version(); let put_settings = delete_document_transition_builder.settings; + let index_only = delete_document_transition_builder + .data_contract + .document_type_for_name(&delete_document_transition_builder.document_type_name) + .map_err(|e| Error::Protocol(e.into()))? + .index_only(); let state_transition = delete_document_transition_builder .sign(self, signing_key, signer, platform_version) .await?; - let proof_result = state_transition - .broadcast_and_wait::(self, put_settings) - .await?; + // An indexOnly document keeps no row: its proof shows the entry is gone, which it would + // also be had it never existed, so it cannot prove that this delete executed. That is the + // strongest proof such a document has, so it is accepted rather than failing a delete + // that landed. + let proof_result = if index_only { + state_transition + .broadcast_and_wait_for_affected_state::( + self, + put_settings, + ) + .await? + } else { + state_transition + .broadcast_and_wait::(self, put_settings) + .await? + }; match proof_result { StateTransitionProofResult::VerifiedDocuments(documents) => { diff --git a/packages/rs-sdk/src/platform/transition/broadcast.rs b/packages/rs-sdk/src/platform/transition/broadcast.rs index 73d1a107e98..6c2dc7526a7 100644 --- a/packages/rs-sdk/src/platform/transition/broadcast.rs +++ b/packages/rs-sdk/src/platform/transition/broadcast.rs @@ -45,7 +45,9 @@ pub trait BroadcastStateTransition { /// transition executed. For the transition families whose proofs can /// only authenticate the affected state (balance top-ups, credit /// transfers and withdrawals, address funds movements, shields, - /// no-history token operations, key limits updates), this returns + /// no-history token operations, key limits updates, contract updates, + /// contract moderation and fee claims, and creates and deletes of + /// indexOnly documents), this returns /// [`Error::ExecutionNotProved`] — use /// [`wait_for_affected_state`](Self::wait_for_affected_state) for those /// flows and treat the result as a height-pinned snapshot. @@ -395,7 +397,7 @@ pub fn require_execution_proved( StateTransitionProofGuarantee::ExecutionProved => Ok(result), StateTransitionProofGuarantee::AffectedState => Err(Error::ExecutionNotProved( format!( - "received a verified {} snapshot for this transition family; use the *_affected_state wait APIs and treat the result as a height-pinned snapshot", + "received a verified {} snapshot for this transition family; wait with the affected-state APIs instead (wait_for_affected_state in Rust, waitForAffectedState or broadcastAndWaitForAffectedState in JavaScript) and treat the result as a height-pinned snapshot", result ), )), diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index 231db20015e..e090797d275 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -6,7 +6,7 @@ use crate::platform::transition::put_settings::PutSettings; use crate::{Error, Sdk}; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; -use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::DocumentType; use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters, INITIAL_REVISION}; @@ -36,7 +36,10 @@ pub trait PutDocument>: Waitable { settings: Option, ) -> Result; - /// Puts a document on platform and waits for the confirmation proof + /// Puts a document on platform and waits for the confirmation proof. For an + /// indexOnly document type the proof shows the document's entry at the proof's + /// block, not that this put wrote it: no stronger proof exists for such a + /// document. #[allow(clippy::too_many_arguments)] async fn put_to_platform_and_wait_for_response( &self, @@ -204,6 +207,7 @@ impl> PutDocument for Document { signer: &S, settings: Option, ) -> Result { + let index_only = document_type.index_only(); let state_transition = self .put_to_platform( sdk, @@ -216,6 +220,13 @@ impl> PutDocument for Document { ) .await?; + // An indexOnly document keeps no row: its proof shows the entry the create leaves, not + // that this create executed, and no stronger proof exists for it. + if index_only { + return wait_for_document_and_owner_balance(sdk, state_transition, settings) + .await + .map(|(document, _owner_balance)| document); + } Self::wait_for_response(sdk, state_transition, settings).await } } diff --git a/packages/wasm-sdk/src/state_transitions/broadcast.rs b/packages/wasm-sdk/src/state_transitions/broadcast.rs index 20880dfcc4b..5e16031272a 100644 --- a/packages/wasm-sdk/src/state_transitions/broadcast.rs +++ b/packages/wasm-sdk/src/state_transitions/broadcast.rs @@ -226,7 +226,9 @@ impl WasmSdk { /// whose proofs cannot be bound to the execution of one specific /// transition (balance top-ups, credit transfers and withdrawals, /// address funds movements, shields, no-history token operations, key - /// limits updates). This method accepts those outcomes instead. The result is a verified, + /// limits updates, contract updates, contract moderation and fee claims, + /// and creates and deletes of indexOnly documents). This method accepts + /// those outcomes instead. The result is a verified, /// height-pinned snapshot of the affected state — NOT evidence that this /// specific transition executed. /// diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index cbd6531f7f2..052ba79d5b3 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -168,7 +168,10 @@ impl WasmSdk { /// /// @returns Promise resolving to the confirmed Document as Platform /// committed it — its final `id` and the consensus-populated system fields - /// (`$createdAt` and friends) included. Keep THIS instance + /// (`$createdAt` and friends) included. For an indexOnly document type + /// the proof shows the document's entry at the proof's block, not that + /// this create wrote it: no stronger proof exists for such a document. + /// Keep THIS instance /// when you later intend to delete an indexOnly document /// whose type requires `$createdAt`: the delete carries the /// document's values, and the pre-broadcast wrapper never @@ -447,7 +450,10 @@ impl WasmSdk { /// 3. Broadcasts and waits for confirmation /// /// @param options - Delete options including document (or document identifiers), identity key, and signer - /// @returns Promise that resolves when the document is deleted + /// @returns Promise that resolves when the document is deleted. For an indexOnly + /// document type the proof shows the document's entry gone at the proof's + /// block, not that this delete removed it: no stronger proof exists for + /// such a document. #[wasm_bindgen(js_name = "documentDelete")] pub async fn document_delete( &self, From af86fc7141d5f9974eb26c9e198c6f8261dff0e7 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 23:06:03 +0700 Subject: [PATCH 096/113] fix(sdk): accept vote poll end-date timestamps (#5139) Co-authored-by: Claude Opus 5.5 --- packages/wasm-sdk/src/queries/voting/polls.rs | 94 ++++++++++++------- .../wasm-sdk/tests/functional/voting.spec.ts | 26 +++++ 2 files changed, 84 insertions(+), 36 deletions(-) diff --git a/packages/wasm-sdk/src/queries/voting/polls.rs b/packages/wasm-sdk/src/queries/voting/polls.rs index 929c7e0657f..b0efb70b944 100644 --- a/packages/wasm-sdk/src/queries/voting/polls.rs +++ b/packages/wasm-sdk/src/queries/voting/polls.rs @@ -20,10 +20,11 @@ const VOTE_POLLS_BY_END_DATE_QUERY_TS: &'static str = r#" */ export interface VotePollsByEndDateQuery { /** - * Starting timestamp (milliseconds) to filter polls. + * Starting timestamp (milliseconds) to filter polls. Accepts the + * `timestampMs` bigint of a returned entry, for paging. * @default undefined */ - startTimeMs?: number; + startTimeMs?: number | bigint; /** * Include the `startTimeMs` boundary when true. @@ -35,7 +36,7 @@ export interface VotePollsByEndDateQuery { * Ending timestamp (milliseconds) to filter polls. * @default undefined */ - endTimeMs?: number; + endTimeMs?: number | bigint; /** * Include the `endTimeMs` boundary when true. @@ -69,42 +70,19 @@ extern "C" { pub type VotePollsByEndDateQueryJs; } -fn timestamp_from_option( - value: Option, - field: &str, -) -> Result, WasmSdkError> { - match value { - Some(raw) => { - if !raw.is_finite() || raw < 0.0 { - return Err(WasmSdkError::invalid_argument(format!( - "{} must be a non-negative finite number", - field - ))); - } - - if raw.fract() != 0.0 { - return Err(WasmSdkError::invalid_argument(format!( - "{} must be an integer value", - field - ))); - } - - let timestamp = raw as u64; - Ok(Some(timestamp)) - } - None => Ok(None), - } -} - +/// Timestamps are integers here, not `f64`: the query reaches serde through +/// `platform_value`, which turns a whole JS number (or a bigint) into an +/// integer `Value` and refuses to read one as `f64`. Negative and fractional +/// values are refused by the same conversion. #[derive(Default, Deserialize)] #[serde(rename_all = "camelCase")] struct VotePollsByEndDateQueryInput { #[serde(default)] - start_time_ms: Option, + start_time_ms: Option, #[serde(default)] start_time_included: Option, #[serde(default)] - end_time_ms: Option, + end_time_ms: Option, #[serde(default)] end_time_included: Option, #[serde(default)] @@ -140,11 +118,10 @@ fn build_vote_polls_by_end_date_drive_query( )); } - let start_time = timestamp_from_option(start_time_ms, "startTimeMs")? - .map(|timestamp| (timestamp, start_time_included.unwrap_or(true))); + let start_time = + start_time_ms.map(|timestamp| (timestamp, start_time_included.unwrap_or(true))); - let end_time = timestamp_from_option(end_time_ms, "endTimeMs")? - .map(|timestamp| (timestamp, end_time_included.unwrap_or(true))); + let end_time = end_time_ms.map(|timestamp| (timestamp, end_time_included.unwrap_or(true))); let limit = convert_optional_limit(limit, "limit")?; let offset = convert_optional_limit(offset, "offset")?; @@ -245,3 +222,48 @@ impl WasmSdk { )) } } + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::platform_value::{self, Value}; + + /// The step `from_object` runs after `serde_wasm_bindgen` has turned the + /// JS query into a `Value`: a whole JS number arrives as `Value::I64`, a + /// bigint as `Value::I64` or `Value::U64`, a fractional number as + /// `Value::Float`. + fn parse(entries: Vec<(&str, Value)>) -> Result { + let map = entries + .into_iter() + .map(|(key, value)| (Value::Text(key.to_string()), value)) + .collect(); + let input: VotePollsByEndDateQueryInput = platform_value::from_value(Value::Map(map)) + .map_err(|e| WasmSdkError::invalid_argument(e.to_string()))?; + build_vote_polls_by_end_date_drive_query(input) + } + + #[test] + fn whole_number_and_bigint_timestamps_are_read() { + let query = parse(vec![ + ("startTimeMs", Value::I64(1_727_500_000_000)), + ("startTimeIncluded", Value::Bool(false)), + ("endTimeMs", Value::U64(1_727_600_000_000)), + ]) + .expect("integer timestamps should be accepted"); + + assert_eq!(query.start_time, Some((1_727_500_000_000, false))); + assert_eq!(query.end_time, Some((1_727_600_000_000, true))); + } + + #[test] + fn negative_and_fractional_timestamps_are_refused() { + assert!(parse(vec![("startTimeMs", Value::I64(-1))]).is_err()); + assert!(parse(vec![("endTimeMs", Value::Float(1.5))]).is_err()); + } + + #[test] + fn inclusion_flag_without_its_timestamp_is_refused() { + assert!(parse(vec![("startTimeIncluded", Value::Bool(true))]).is_err()); + assert!(parse(vec![("endTimeIncluded", Value::Bool(false))]).is_err()); + } +} diff --git a/packages/wasm-sdk/tests/functional/voting.spec.ts b/packages/wasm-sdk/tests/functional/voting.spec.ts index 0cdd0e1293e..7eaae367638 100644 --- a/packages/wasm-sdk/tests/functional/voting.spec.ts +++ b/packages/wasm-sdk/tests/functional/voting.spec.ts @@ -52,4 +52,30 @@ describe('Voting', function describeVoting() { }); }); }); + + describe('getVotePollsByEndDate()', () => { + const DAY_MS = 24 * 60 * 60 * 1000; + + it('should accept millisecond timestamps given as numbers', async () => { + const now = Date.now(); + + const entries = await client.getVotePollsByEndDate({ + startTimeMs: now - 30 * DAY_MS, + endTimeMs: now + 30 * DAY_MS, + limit: 10, + }); + + expect(entries).to.be.an('array'); + }); + + it('should accept millisecond timestamps given as bigints', async () => { + const entries = await client.getVotePollsByEndDate({ + startTimeMs: BigInt(Date.now()), + startTimeIncluded: false, + limit: 10, + }); + + expect(entries).to.be.an('array'); + }); + }); }); From ad99cc12db3b92530336e4fdb7634488133b40dc Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:17:52 +0300 Subject: [PATCH 097/113] fix(ci): resolve Kotlin release NDK from runner environment --- .github/workflows/release-kotlin-sdk.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-kotlin-sdk.yml b/.github/workflows/release-kotlin-sdk.yml index 801b0a53c56..7d7308f9fbf 100644 --- a/.github/workflows/release-kotlin-sdk.yml +++ b/.github/workflows/release-kotlin-sdk.yml @@ -229,9 +229,10 @@ jobs: - name: Build native library (both ABIs, release profile) working-directory: packages/kotlin-sdk - env: - ANDROID_NDK_HOME: ${{ env.ANDROID_SDK_ROOT }}/ndk/28.1.13356709 - run: ./build_android.sh --abi all --profile release --verify + # Image-provided variables exist in the shell, not the Actions env context. + run: | + export ANDROID_NDK_HOME="${ANDROID_SDK_ROOT:?}/ndk/28.1.13356709" + ./build_android.sh --abi all --profile release --verify - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 From 4d133157c2bd43a5d02fb86c63b2bc1e7da116a3 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Mon, 28 Sep 2026 23:59:35 +0700 Subject: [PATCH 098/113] feat(sdk)!: DataContract.validateUpdate in @dashevo/wasm-dpp2 (#5140) Co-authored-by: Claude Opus 5.5 --- packages/wasm-dpp2/Cargo.toml | 6 + packages/wasm-dpp2/README.md | 42 +++++ packages/wasm-dpp2/scripts/build-optimized.sh | 5 + packages/wasm-dpp2/scripts/build.sh | 5 + packages/wasm-dpp2/src/consensus_error.rs | 6 + packages/wasm-dpp2/src/data_contract/model.rs | 60 +++++++ .../unit/DataContractValidateUpdate.spec.ts | 170 ++++++++++++++++++ .../unit/DocumentPropertyConstraints.spec.ts | 30 +++- .../unit/DocumentPropertyReference.spec.ts | 13 +- .../tests/unit/DocumentTypedArrays.spec.ts | 9 +- 10 files changed, 336 insertions(+), 10 deletions(-) create mode 100644 packages/wasm-dpp2/tests/unit/DataContractValidateUpdate.spec.ts diff --git a/packages/wasm-dpp2/Cargo.toml b/packages/wasm-dpp2/Cargo.toml index d405cffcacb..fd7c1e2bfcd 100644 --- a/packages/wasm-dpp2/Cargo.toml +++ b/packages/wasm-dpp2/Cargo.toml @@ -31,3 +31,9 @@ hex = "0.4.3" anyhow = "1.0.75" thiserror = "1.0" sha2 = "0.10.8" + +[features] +# `DataContract.validateUpdate`: the contract update rules consensus runs. It +# needs dpp's `validation`, which also compiles the document meta-schema into +# the full validation of `DataContract.fromJSON` / `fromObject` / `fromBytes`. +validation = ["dpp/validation"] diff --git a/packages/wasm-dpp2/README.md b/packages/wasm-dpp2/README.md index 4d9aa64fa0a..2dcb747dfd1 100644 --- a/packages/wasm-dpp2/README.md +++ b/packages/wasm-dpp2/README.md @@ -46,3 +46,45 @@ carried before it is passed to `DocumentCreateTransition` is replaced: the transition can only carry the id consensus recomputes. For the same reason `new Document({...})` refuses an explicit `id` that disagrees with the one its `identityContractNonce` derives. No app needs to reimplement the hash. + +## Checking a contract update + +A data contract update is refused when it breaks an update rule: the version +must rise by exactly one, the indexes of an existing document type are fixed, +a property may be added but not removed, a new required property needs +`requiredSince`, and so on for every keyword (see the book, *Contract +Keywords*, the *On update* row of each keyword). `DataContract.validateUpdate` +runs those rules with the code consensus runs on the update transition +(`DataContract::validate_update`), so an app, a CI job or a review tool can +check a proposed contract before paying for a transition the platform would +refuse: + +```ts +const version = new PlatformVersion(14); +const current = DataContract.fromJSON(currentJson, true, version); +const proposed = DataContract.fromJSON(proposedJson, true, version); + +const errors = current.validateUpdate(proposed, undefined, version); +// [] when the update is valid; otherwise ConsensusError objects, e.g. +// errors[0].code === 10217 when an index of an existing type changed +``` + +Each error is a `ConsensusError` with the `code` a refused transition reaches +JS with. The second argument is the `BlockInfo` of the block the update would +be executed in, of which only the time is read (an added token's +pre-programmed distributions may not start before it); `undefined` uses the +device clock. The two contracts are compared whatever their ids, as the +platform compares the stored contract with the proposed one. Build the +proposed contract with full validation, as above, since the transition's +structural checks run there. No state is read, so what the platform checks +against state on top is not covered: that the contract exists, that group +members, identities named by token configurations and appointed moderators +exist, that references into other contracts resolve, and the signature, nonce +and fees. + +This package is built with the crate's `validation` feature, which the method +needs. It also makes full validation (`fromJSON(…, true, …)`, `fromObject`, +`fromBytes`, `new DataContract({ fullValidation: true })`) run the document +meta-schema, and contract parse errors come back as consensus errors with +codes. `@dashevo/wasm-sdk` and `@dashevo/evo-sdk` build these bindings without +it, to keep their size, so they have no `validateUpdate`. diff --git a/packages/wasm-dpp2/scripts/build-optimized.sh b/packages/wasm-dpp2/scripts/build-optimized.sh index 4afe54f3366..7275243e766 100755 --- a/packages/wasm-dpp2/scripts/build-optimized.sh +++ b/packages/wasm-dpp2/scripts/build-optimized.sh @@ -8,6 +8,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" echo "Building wasm-dpp2 with full optimization for npm release..." +# The npm package ships DataContract.validateUpdate, which needs the crate's +# `validation` feature (see Cargo.toml). wasm-sdk builds wasm-dpp2 with its +# default features, so evo-sdk does not carry it. +export CARGO_BUILD_FEATURES="validation${CARGO_BUILD_FEATURES:+,$CARGO_BUILD_FEATURES}" + "$SCRIPT_DIR/../../scripts/build-wasm.sh" --package wasm-dpp2 --opt-level full cd "$SCRIPT_DIR/../pkg" diff --git a/packages/wasm-dpp2/scripts/build.sh b/packages/wasm-dpp2/scripts/build.sh index f8ccba2fc35..e78666e453d 100755 --- a/packages/wasm-dpp2/scripts/build.sh +++ b/packages/wasm-dpp2/scripts/build.sh @@ -15,4 +15,9 @@ if [ "${CARGO_BUILD_PROFILE:-}" = "dev" ] || [ "${CI:-}" != "true" ]; then OPT_LEVEL="minimal" fi +# The npm package ships DataContract.validateUpdate, which needs the crate's +# `validation` feature (see Cargo.toml). wasm-sdk builds wasm-dpp2 with its +# default features, so evo-sdk does not carry it. +export CARGO_BUILD_FEATURES="validation${CARGO_BUILD_FEATURES:+,$CARGO_BUILD_FEATURES}" + exec "$SCRIPT_DIR/../../scripts/build-wasm.sh" --package wasm-dpp2 --opt-level "$OPT_LEVEL" diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index b69b2c5ff15..bfd65c5c042 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -383,6 +383,12 @@ impl ConsensusErrorWasm { } } +impl From for ConsensusErrorWasm { + fn from(error: ConsensusError) -> Self { + ConsensusErrorWasm(error) + } +} + impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError); #[cfg(test)] diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index cd907fa000c..0cc36207e25 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -1,3 +1,7 @@ +#[cfg(feature = "validation")] +use crate::block::BlockInfoWasm; +#[cfg(feature = "validation")] +use crate::consensus_error::ConsensusErrorWasm; use crate::data_contract::DocumentWasm; use crate::data_contract::document_type_distinct_from::{ DocumentPropertyDistinctFromArrayJs, DocumentPropertyDistinctFromMapJs, @@ -36,6 +40,8 @@ use crate::utils::{ try_from_options_optional_with, try_from_options_with, try_to_object, try_to_u16, try_to_u32, }; use crate::version::{PlatformVersionLikeJs, PlatformVersionWasm}; +#[cfg(feature = "validation")] +use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; use dpp::data_contract::accessors::v1::{DataContractV1Getters, DataContractV1Setters}; use dpp::data_contract::config::DataContractConfig; @@ -47,6 +53,8 @@ use dpp::data_contract::errors::DataContractError; use dpp::data_contract::group::Group; use dpp::data_contract::schema::DataContractSchemaMethodsV0; use dpp::data_contract::serialized_version::DataContractInSerializationFormat; +#[cfg(feature = "validation")] +use dpp::data_contract::validate_update::DataContractUpdateValidationMethodsV0; use dpp::data_contract::{ DataContract, GroupContractPosition, TokenConfiguration, TokenContractPosition, }; @@ -914,6 +922,58 @@ impl DataContractWasm { Ok(check_property_constraints(document_type, &document.document)?.into()) } + /// Whether `newContract` is an update of this contract the platform + /// accepts, judged with the code consensus runs on a data contract update + /// transition (`DataContract::validate_update`): the owner may not change, + /// the version must rise by exactly one, the config and every existing + /// document type follow their update rules, `schemaDefs` stay compatible, + /// groups and tokens are kept, and an added token's pre-programmed + /// distributions may not start before the block time. + /// + /// Returns the consensus errors a refused update would carry, each with + /// the `code` a rejected transition reaches JS with, or an empty array + /// when the update is valid. Most checks stop at the first failure, so a + /// refused update usually reports one error. + /// + /// The two contracts are compared whatever their ids; the platform judges + /// an update against the stored contract with the new contract's id. + /// Build `newContract` with full validation + /// (`DataContract.fromJSON(json, true, platformVersion)`): the transition's + /// structural checks run there, not here. No state is read, so what the + /// platform checks against state on top is not covered: that the contract + /// exists, that group members, identities named by token configurations + /// and appointed moderators exist, that references into other contracts + /// resolve, and the signature, nonce and fees. + /// + /// `blockInfo` is the block the update would be executed in; only its + /// time is read. `undefined` lets the device clock stand in for it. + #[cfg(feature = "validation")] + #[wasm_bindgen(js_name = "validateUpdate")] + pub fn validate_update( + &self, + #[wasm_bindgen(js_name = "newContract")] new_contract: &DataContractWasm, + #[wasm_bindgen(js_name = "blockInfo", unchecked_param_type = "BlockInfo | undefined")] + block_info: JsValue, + #[wasm_bindgen(js_name = "platformVersion")] platform_version: PlatformVersionLikeJs, + ) -> WasmDppResult> { + let platform_version = PlatformVersionWasm::try_from(platform_version)?; + let block_info = if block_info.is_undefined() || block_info.is_null() { + BlockInfo::default_with_time(js_sys::Date::now() as u64) + } else { + BlockInfo::from(&*block_info.to_wasm::("BlockInfo")?) + }; + + let result = + self.0 + .validate_update(&new_contract.0, &block_info, &platform_version.into())?; + + Ok(result + .errors + .into_iter() + .map(ConsensusErrorWasm::from) + .collect()) + } + /// All `encryptedFor` declarations of one document type, in schema /// property order: which byte array properties are encrypted, for whom, /// under which key ids and under which scheme. diff --git a/packages/wasm-dpp2/tests/unit/DataContractValidateUpdate.spec.ts b/packages/wasm-dpp2/tests/unit/DataContractValidateUpdate.spec.ts new file mode 100644 index 00000000000..1ba452bbc1a --- /dev/null +++ b/packages/wasm-dpp2/tests/unit/DataContractValidateUpdate.spec.ts @@ -0,0 +1,170 @@ +/** + * `DataContract.validateUpdate`: the contract update rules consensus runs on a + * data contract update transition (`DataContract::validate_update`), without + * reading state. It returns the consensus errors a refused update would carry, + * or an empty array for a valid one. + */ +import { expect } from './helpers/chai.ts'; +import { initWasm, wasm } from '../../dist/dpp.compressed.js'; + +let PlatformVersion: typeof wasm.PlatformVersion; + +before(async () => { + await initWasm(); + ({ PlatformVersion } = wasm); +}); + +const contractId = '4fJLR2GYTPFdomuTVvNy3VRrvWgvkKPzqehEBpNf2nk6'; +const ownerId = 'CXH2kZCATjvDTnQAPVg28EgPg9WySUvwvnR5ZkmNqY5i'; +const otherOwnerId = '9tSsCqKHTZ8ro16MydChSxgHBukFW36eMLJKKRtebJEn'; + +type Json = Record; + +/** Version 1 of a small contract: a `note` with one index, and a `tag`. */ +function baseJson(): Json { + return { + $formatVersion: '1', + id: contractId, + ownerId, + version: 1, + documentSchemas: { + note: { + type: 'object', + properties: { + title: { type: 'string', maxLength: 63, position: 0 }, + body: { type: 'string', maxLength: 1000, position: 1 }, + }, + required: ['title'], + indices: [{ name: 'byTitle', properties: [{ title: 'asc' }] }], + additionalProperties: false, + }, + tag: { + type: 'object', + properties: { + label: { type: 'string', maxLength: 32, position: 0 }, + }, + additionalProperties: false, + }, + }, + }; +} + +/** Version 2: a copy of version 1 for a test to change. */ +function nextJson(): Json { + return { ...structuredClone(baseJson()), version: 2 }; +} + +function contract(value: Json) { + return wasm.DataContract.fromJSON(value, true, new PlatformVersion(14)); +} + +function codesOf(next: Json, blockInfo?: InstanceType) { + return contract(baseJson()) + .validateUpdate(contract(next), blockInfo, new PlatformVersion(14)) + .map((error: InstanceType) => error.code); +} + +describe('DataContract.validateUpdate()', () => { + it('should accept an update that adds an optional property and raises the version by one', () => { + const next = nextJson(); + next.documentSchemas.note.properties.color = { type: 'string', maxLength: 16, position: 2 }; + + expect(codesOf(next)).to.deep.equal([]); + }); + + it('should accept raising maxLength and adding a document type', () => { + const next = nextJson(); + next.documentSchemas.note.properties.body.maxLength = 2000; + next.documentSchemas.folder = { + type: 'object', + properties: { name: { type: 'string', maxLength: 32, position: 0 } }, + additionalProperties: false, + }; + + expect(codesOf(next)).to.deep.equal([]); + }); + + it('should return ConsensusError objects with the code and message consensus reports', () => { + const next = nextJson(); + next.version = 3; + + const [error] = contract(baseJson()).validateUpdate(contract(next), undefined, new PlatformVersion(14)); + + expect(error).to.be.an.instanceof(wasm.ConsensusError); + expect(error.code).to.equal(10212); // InvalidDataContractVersionError + expect(error.message).to.be.a('string').and.not.be.empty(); + }); + + it('should refuse an update that keeps the version', () => { + const next = nextJson(); + next.version = 1; + next.documentSchemas.note.properties.color = { type: 'string', maxLength: 16, position: 2 }; + + expect(codesOf(next)).to.deep.equal([10212]); // InvalidDataContractVersionError + }); + + it('should refuse a change of owner', () => { + const next = nextJson(); + next.ownerId = otherOwnerId; + + expect(codesOf(next)).to.deep.equal([40003]); // DataContractUpdatePermissionError + }); + + it('should refuse removing a document type', () => { + const next = nextJson(); + delete next.documentSchemas.tag; + + expect(codesOf(next)).to.deep.equal([40212]); // DocumentTypeUpdateError + }); + + it('should refuse adding an index to an existing document type', () => { + const next = nextJson(); + next.documentSchemas.note.indices.push({ name: 'byOwner', properties: [{ $ownerId: 'asc' }] }); + + expect(codesOf(next)).to.deep.equal([10217]); // DataContractInvalidIndexDefinitionUpdateError + }); + + it('should refuse removing a property', () => { + const next = nextJson(); + delete next.documentSchemas.note.properties.body; + + expect(codesOf(next)).to.deep.equal([10246]); // IncompatibleDocumentTypeSchemaError + }); + + it('should refuse lowering maxLength', () => { + const next = nextJson(); + next.documentSchemas.note.properties.body.maxLength = 500; + + expect(codesOf(next)).to.deep.equal([10246]); // IncompatibleDocumentTypeSchemaError + }); + + it('should refuse a new required property without requiredSince, and accept it with requiredSince', () => { + const without = nextJson(); + without.documentSchemas.tag.properties.color = { type: 'string', maxLength: 16, position: 1 }; + without.documentSchemas.tag.required = ['color']; + + expect(codesOf(without)).to.deep.equal([10276]); // DataContractInvalidRequiredFieldsUpdateError + + const withSince = structuredClone(without); + withSince.documentSchemas.tag.properties.color.requiredSince = 2; + + expect(codesOf(withSince)).to.deep.equal([]); + }); + + it('should run the document meta-schema in full validation (this build has `validation`)', () => { + const typo = baseJson(); + typo.documentSchemas.tag.documentsMutible = false; // not a keyword + + expect(() => contract(typo)).to.throw(); + }); + + it('should take a block info without consuming it', () => { + const blockInfo = new wasm.BlockInfo({ + timeMs: 1790000000000n, height: 10n, coreHeight: 5, epochIndex: 1, + }); + const next = nextJson(); + + expect(codesOf(next, blockInfo)).to.deep.equal([]); + expect(blockInfo.timeMs).to.equal(1790000000000n); + }); +}); diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts index 90215339ac7..0189cd75ddc 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts @@ -71,13 +71,22 @@ const schemas = { }, }; -function buildContract(contractSchemas: Record, platformVersion = 14) { +/** + * This package is built with dpp's `validation` feature (for + * `DataContract.validateUpdate`), so full validation runs the document + * meta-schema first, as nodes do, and refuses a malformed keyword with + * `JsonSchemaError` (10101) before the parser sees it. The parser's own + * checks are reached with full validation off. + */ +const JSON_SCHEMA_ERROR = 10101; + +function buildContract(contractSchemas: Record, platformVersion = 14, fullValidation = true) { return new wasm.DataContract({ ownerId, identityNonce: BigInt(2), schemas: contractSchemas, definitions: null, - fullValidation: true, + fullValidation, platformVersion: new PlatformVersion(platformVersion), }); } @@ -548,7 +557,12 @@ describe('DataContract: propertyConstraints (v14)', () => { * enforced there. */ it('should report no rules on a pre-v14 contract', () => { - const contract = buildContract(schemas, 13); + // Full validation at protocol version 13 refuses the keyword outright: + // the version 2 meta-schema does not know it. + expect(() => buildContract(schemas, 13)).to.throw(/propertyConstraints/); + + // A contract read back without full validation parses no rules. + const contract = buildContract(schemas, 13, false); expect(contract.documentTypePropertyConstraints('offer')).to.deep.equal([]); expect(contract.checkDocumentPropertyConstraints(offer(contract, { discount: 200 }))) @@ -640,9 +654,19 @@ describe('DataContract: propertyConstraints (v14)', () => { offer: { ...schemas.offer, propertyConstraints: { rule: { equal: ['price'] } } }, }; + // Nodes refuse it at the meta-schema. try { buildContract(malformed); expect.fail('expected to throw'); + } catch (e) { + expect(e).to.be.instanceOf(wasm.WasmDppError); + expect(e.code).to.equal(JSON_SCHEMA_ERROR); + } + + // The parser refuses it too, where the meta-schema does not run. + try { + buildContract(malformed, 14, false); + expect.fail('expected to throw'); } catch (e) { expect(e).to.be.instanceOf(wasm.WasmDppError); expect(e.message).to.match(/must list exactly two operands/); diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index db76fe5ef80..8d06723a2c3 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -578,13 +578,16 @@ describe('DataContract — refersTo declarations (v14)', () => { }, }; - function buildExpressionContract(documentSchemas: object) { + // Full validation runs the meta-schema first in this build (it has + // dpp's `validation` feature); the refusals below check the parser's own + // rule with full validation off, and that full validation refuses too. + function buildExpressionContract(documentSchemas: object, fullValidation = true) { return new wasm.DataContract({ ownerId, identityNonce: BigInt(2), schemas: documentSchemas, definitions: null, - fullValidation: true, + fullValidation, platformVersion: new PlatformVersion(14), }); } @@ -694,9 +697,10 @@ describe('DataContract — refersTo declarations (v14)', () => { anyOf: [{ type: 'identity' }, { type: 'contract' }], }; - expect(() => buildExpressionContract(withContract)).to.throw( + expect(() => buildExpressionContract(withContract, false)).to.throw( /refersTo anyOf\[1\] is a reference of type contract, which a reference expression does not take/, ); + expect(() => buildExpressionContract(withContract)).to.throw(/JsonSchemaError/); }); it('should refuse an anyOf directly inside an anyOf', () => { @@ -706,9 +710,10 @@ describe('DataContract — refersTo declarations (v14)', () => { anyOf: [{ type: 'identity' }, { anyOf: memberId.refersTo.anyOf }], }; - expect(() => buildExpressionContract(flat)).to.throw( + expect(() => buildExpressionContract(flat, false)).to.throw( /refersTo anyOf\[1\] is an anyOf directly inside an anyOf/, ); + expect(() => buildExpressionContract(flat)).to.throw(/JsonSchemaError/); }); }); diff --git a/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts index 3e0b24929fe..8fd9464fd5b 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts @@ -84,13 +84,15 @@ const schemas = { }, }; -function buildContract(contractSchemas: Record, platformVersion = 14) { +// Full validation runs the document meta-schema first in this build (it has +// dpp's `validation` feature); a parser rule is reached with it off. +function buildContract(contractSchemas: Record, platformVersion = 14, fullValidation = true) { return new wasm.DataContract({ ownerId, identityNonce: BigInt(2), schemas: contractSchemas, definitions: null, - fullValidation: true, + fullValidation, platformVersion: new PlatformVersion(platformVersion), }); } @@ -253,9 +255,10 @@ describe('DataContract: typed arrays (v14)', () => { }, }; - expect(() => buildContract(schemasWithKeyReference)).to.throw( + expect(() => buildContract(schemasWithKeyReference, 14, false)).to.throw( /identityPublicKey refersTo is not allowed on the elements of a typed array/, ); + expect(() => buildContract(schemasWithKeyReference)).to.throw(/JsonSchemaError/); }); }); From 08012d9d41af7c523c3ee13a427e634c86795b51 Mon Sep 17 00:00:00 2001 From: infraclaw-dash <283232465+infraclaw-dash@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:07:50 +0000 Subject: [PATCH 099/113] ci: deploy isolated PR Hygiene engine on v4.2 --- .github/workflows/pr-review-policy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-review-policy.yml b/.github/workflows/pr-review-policy.yml index 2ab040772fc..2a223c05e7c 100644 --- a/.github/workflows/pr-review-policy.yml +++ b/.github/workflows/pr-review-policy.yml @@ -55,6 +55,6 @@ jobs: (contains(fromJSON('["coderabbitai", "coderabbitai[bot]"]'), github.event.comment.user.login) && (contains(github.event.comment.body, 'final_review_risk_coverage') || contains(github.event.comment.body, 'rate limited by coderabbit.ai'))) - uses: dashpay/stale_prs_are_bad/.github/workflows/pr-review-reusable.yml@5e561c705a09eb80782557d57e905d444b03dffd + uses: dashpay/stale_prs_are_bad/.github/workflows/pr-review-reusable.yml@b9ec4fbd5bbd3ac4bdb164a09f03bff89114fc46 with: scope: ${{ inputs.scope || 'batch' }} From d22f8ab8f5e3e75bd89e551a6567f20af12ea01f Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 29 Sep 2026 00:51:33 +0700 Subject: [PATCH 100/113] fix(drive): refuse indexOnly prefix pivots whose pages could be incomplete (#5103) Co-authored-by: Claude Opus 5.5 --- book/src/contract-keywords/index-only.md | 2 +- book/src/drive/index-only-document-types.md | 25 +- .../v0/tests/index_only_e2e_tests.rs | 599 +++++++++++++++--- .../src/query/index_only_synthesis.rs | 306 ++++++--- 4 files changed, 745 insertions(+), 187 deletions(-) diff --git a/book/src/contract-keywords/index-only.md b/book/src/contract-keywords/index-only.md index 4171e0eee29..0128ad4632c 100644 --- a/book/src/contract-keywords/index-only.md +++ b/book/src/contract-keywords/index-only.md @@ -71,7 +71,7 @@ A `like` of a social contract whose `post` type cannot be deleted: **No other action.** A document cannot be replaced, transferred, sold or repriced. -**Queries.** A query goes through one index and returns documents rebuilt from its entries: the index's properties, the terminal, and `$ownerId` and `$createdAt` where the index holds them. A query through an index that holds only some of the properties yields only those. The rebuilt `$id` is a hash of the entry's position and addresses nothing, so there is no fetch by `$id` and no `startAt` cursor; a query pages by the terminal instead (`postId > `, with a limit). The proof that a create or delete took effect is the presence or absence of its entry in the **proof index**, an index that involves no `$createdAt` and does not skip. +**Queries.** A query goes through one index and returns documents rebuilt from its entries: the index's properties, the terminal, and `$ownerId` and `$createdAt` where the index holds them. A query through an index that holds only some of the properties yields only those. The rebuilt `$id` is a hash of the entry's position and addresses nothing, so there is no fetch by `$id` and no `startAt` cursor; a query pages by the terminal instead (`postId > `, with a limit). A query that sets the terminal with an equality can put an `in` on the index's last property, with a limit of at least its number of values; a range on an index property in such a query is refused, because its pages could hold fewer rows than exist. List the equality-bound properties first in an index, and page by a range on its terminal instead. The proof that a create or delete took effect is the presence or absence of its entry in the **proof index**, an index that involves no `$createdAt` and does not skip. Rules at registration: diff --git a/book/src/drive/index-only-document-types.md b/book/src/drive/index-only-document-types.md index 34b72a4454a..b1ce228332c 100644 --- a/book/src/drive/index-only-document-types.md +++ b/book/src/drive/index-only-document-types.md @@ -384,11 +384,26 @@ the terminal (`terminal > `, with a limit) walks the entries page by page — **keyset pagination**, the indexOnly replacement for id-shaped `startAt` cursors, which cannot address a position whose synthesized id is a one-way hash. Mixed shapes are served through a -**prefix pivot**: one range or `in` clause may sit on a prefix property -instead of the terminal (`hashtag == h AND postId > p AND $ownerId == -me`), with everything above the pivot equality-bound, everything below -it unconstrained, and the terminal clause an equality. All shapes prove -and verify through the same shared path-query builder. +**prefix pivot**: one `in` clause may sit on the index's last prefix +property instead of the terminal (`hashtag == h AND postId IN [p, q] AND +$ownerId == me`), with everything above it equality-bound, the terminal +clause an equality, and a limit of at least the number of `in` values. + +A range pivot (`postId > p` in the same query), or an `in` pivot with +prefix properties below it, is refused, and the error names the index +shape that serves the query: one that lists the equality-bound +properties, the terminal's included, before the ranged property. A +pivot walk opens one branch per pivot value, and grovedb charges a +branch that holds no row one slot of the limit, so a page of such a +query could hold fewer rows than exist, and the response carries no +cursor to say where it stopped. These shapes stay refused until the +storage layer can report where a page stopped. An `in` pivot on the last +prefix property opens at most one branch per value, so a limit that +covers its values is never used up early. When another index serves the +same query without an incomplete pivot, index selection prefers it over +a pivot index that would win the name-order tie-break. + +All shapes prove and verify through the same shared path-query builder. Not supported on the read surface: by-`$id` fetches (no primary tree — rejected with guidance) and `startAt` cursors (rejected with the keyset diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs index ed6b197a6ed..97acc2744e8 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs @@ -217,6 +217,7 @@ pub(super) fn count_top_k( const POST_A: [u8; 32] = [0xA1; 32]; const POST_B: [u8; 32] = [0xB2; 32]; +const POST_C: [u8; 32] = [0xC3; 32]; const OWNER_1: [u8; 32] = [0x11; 32]; const OWNER_2: [u8; 32] = [0x22; 32]; const OWNER_3: [u8; 32] = [0x33; 32]; @@ -1011,15 +1012,156 @@ fn should_serve_terminal_range_keyset_pagination() { assert_grovedb_is_consistent(&drive); } -/// Mixed shape: a range on a PREFIX property (the pivot) with a terminal -/// equality — `hashtag == h AND postId > p AND $ownerId == me`. The path -/// stops at the pivot, the range selects its values, and the terminal -/// equality runs beneath each of them. Proved and unproved paths agree. +/// The one refusal a query shape gets on every side: the unproved read, +/// proof generation and the verifier each refuse it as an unsupported +/// shape whose message carries every `expected` fragment. +fn assert_refused_on_read_prove_and_verify( + drive: &Drive, + query: &crate::query::DriveDocumentQuery<'_>, + expected: &[&str], +) { + use crate::error::query::QuerySyntaxError; + use crate::error::Error; + + let assert_refusal = |error: Error, side: &str| match &error { + Error::Query(QuerySyntaxError::Unsupported(message)) => { + for fragment in expected { + assert!( + message.contains(fragment), + "{side}: the refusal should say {fragment:?}: {message}" + ); + } + } + other => panic!("{side}: expected an unsupported query shape, got: {other}"), + }; + assert_refusal( + drive + .query_documents(query.clone(), None, false, None, None) + .expect_err("the unproved read must refuse the shape"), + "unproved read", + ); + assert_refusal( + query + .clone() + .execute_with_proof(drive, None, None, platform_version()) + .expect_err("proof generation must refuse the shape"), + "proof generation", + ); + assert_refusal( + query + .verify_proof(&[], platform_version()) + .expect_err("the verifier must refuse the shape"), + "verifier", + ); +} + +/// The `postId` values of `documents`, in result order. +fn post_ids(documents: &[Document]) -> Vec<[u8; 32]> { + use dpp::document::DocumentV0Getters; + + documents + .iter() + .map(|document| { + document + .properties() + .get("postId") + .expect("postId recovered") + .to_identifier_bytes() + .expect("postId is an identifier") + .try_into() + .expect("32 bytes") + }) + .collect() +} + +/// `hashtag == dash AND postId AND $ownerId == owner`, +/// ordered by `postId`: the mixed shape with its clause on a prefix +/// property (the pivot) of `byHashtagPost` and an equality on its +/// terminal. +fn hashtag_post_pivot_query( + contract: &DataContract, + operator: crate::query::WhereOperator, + value: Value, + owner: [u8; 32], + limit: u16, +) -> crate::query::DriveDocumentQuery<'_> { + use crate::query::{OrderClause, WhereClause, WhereOperator}; + + let mut query = likes_query( + contract, + vec![ + WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("dash".to_string()), + }, + WhereClause { + field: "postId".to_string(), + operator, + value, + }, + WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(owner), + }, + ], + Some(limit), + ); + query.order_by.insert( + "postId".to_string(), + OrderClause { + field: "postId".to_string(), + ascending: true, + }, + ); + query +} + +/// A range on a PREFIX property (the pivot) with a terminal equality, +/// `hashtag == h AND postId > p AND $ownerId == me`, is refused on the +/// unproved read, in proof generation and by the verifier: its pages +/// could hold fewer rows than exist. The refusal names the index shape +/// that serves the query instead. +#[test] +fn should_refuse_range_pivot_with_terminal_equality() { + use crate::query::WhereOperator; + + let (drive, contract) = setup_likes(); + for (post, owner, seed) in [ + (POST_A, OWNER_1, 1u64), + (POST_B, OWNER_1, 2), + (POST_B, OWNER_2, 3), + ] { + let like = build_like(&contract, "dash", post, owner, seed); + insert_like(&drive, &contract, &like, true).expect("insert like"); + } + + let query = hashtag_post_pivot_query( + &contract, + WhereOperator::GreaterThan, + Value::Identifier(POST_A), + OWNER_1, + 10, + ); + assert_refused_on_read_prove_and_verify( + &drive, + &query, + &[ + "range clause on `postId`", + "index \"byHashtagPost\"", + "(hashtag, $ownerId) before `postId`", + ], + ); +} + +/// A range pivot on the FIRST property with the one below it +/// unconstrained, `hashtag >= h AND $ownerId == me`, would walk every post +/// under each matched hashtag before the terminal equality, and is +/// refused the same way. #[test] -fn should_serve_prefix_pivot_with_terminal_equality() { +fn should_refuse_range_pivot_on_first_property_with_unconstrained_below() { use crate::query::{OrderClause, WhereClause, WhereOperator}; - use dpp::document::DocumentV0Getters; - use dpp::platform_value::Value; let (drive, contract) = setup_likes(); for (post, owner, seed) in [ @@ -1036,14 +1178,9 @@ fn should_serve_prefix_pivot_with_terminal_equality() { vec![ WhereClause { field: "hashtag".to_string(), - operator: WhereOperator::Equal, + operator: WhereOperator::GreaterThanOrEquals, value: Value::Text("dash".to_string()), }, - WhereClause { - field: "postId".to_string(), - operator: WhereOperator::GreaterThan, - value: Value::Identifier(POST_A), - }, WhereClause { field: "$ownerId".to_string(), operator: WhereOperator::Equal, @@ -1053,68 +1190,205 @@ fn should_serve_prefix_pivot_with_terminal_equality() { Some(10), ); query.order_by.insert( + "hashtag".to_string(), + OrderClause { + field: "hashtag".to_string(), + ascending: true, + }, + ); + assert_refused_on_read_prove_and_verify( + &drive, + &query, + &["range clause on `hashtag`", "($ownerId) before `hashtag`"], + ); +} + +/// The sparse layout a range pivot cannot page through: OWNER_1 liked +/// POST_C but not POST_B, so under `postId > POST_A` the POST_B branch +/// holds no row for OWNER_1. That branch takes a slot of the limit, so a +/// LIMIT 1 page would come back empty although a row exists. Every limit +/// is refused instead of serving such a page, and the index the refusal +/// points to (`byLiker`, with `$ownerId` in its prefix) returns the row on +/// the first page. +#[test] +fn should_refuse_range_pivot_rather_than_return_a_short_page() { + use crate::query::{OrderClause, WhereClause, WhereOperator}; + + let (drive, contract) = setup_likes(); + for (post, owner, seed) in [(POST_B, OWNER_2, 1u64), (POST_C, OWNER_1, 2)] { + let like = build_like(&contract, "dash", post, owner, seed); + insert_like(&drive, &contract, &like, true).expect("insert like"); + } + + for limit in 1..=3u16 { + let query = hashtag_post_pivot_query( + &contract, + WhereOperator::GreaterThan, + Value::Identifier(POST_A), + OWNER_1, + limit, + ); + assert_refused_on_read_prove_and_verify( + &drive, + &query, + &[ + "range clause on `postId`", + "could hold fewer rows than exist", + ], + ); + } + + let mut keyset = likes_query( + &contract, + vec![ + WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(OWNER_1), + }, + WhereClause { + field: "postId".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::Identifier(POST_A), + }, + ], + Some(1), + ); + keyset.order_by.insert( "postId".to_string(), OrderClause { field: "postId".to_string(), ascending: true, }, ); + assert_eq!( + keyset + .index_only_query_index(platform_version()) + .expect("the keyset query resolves an index") + .name, + "byLiker" + ); + let outcome = drive + .query_documents(keyset.clone(), None, false, None, None) + .expect("the keyset page executes"); + assert_eq!( + post_ids(outcome.documents()), + vec![POST_C], + "the first page holds the row" + ); + let (proof, _) = keyset + .clone() + .execute_with_proof(&drive, None, None, platform_version()) + .expect("keyset proof generation"); + let (_root, verified) = keyset + .verify_proof(proof.as_slice(), platform_version()) + .expect("keyset proof verification"); + assert_eq!(post_ids(&verified), vec![POST_C]); +} + +/// The pivot that stays served: an `in` clause on the LAST prefix +/// property with a terminal equality. Each value opens at most one branch, +/// so a limit of at least the number of values returns every row, even +/// with a value (POST_A) whose branch holds no row for the owner. Proved +/// and unproved paths agree row for row. +#[test] +fn should_serve_in_pivot_on_last_prefix_property_as_complete_pages() { + use crate::query::WhereOperator; + use dpp::document::DocumentV0Getters; + let (drive, contract) = setup_likes(); + for (post, owner, seed) in [ + (POST_A, OWNER_2, 1u64), + (POST_B, OWNER_1, 2), + (POST_C, OWNER_1, 3), + (POST_C, OWNER_3, 4), + ] { + let like = build_like(&contract, "dash", post, owner, seed); + insert_like(&drive, &contract, &like, true).expect("insert like"); + } + + let query = hashtag_post_pivot_query( + &contract, + WhereOperator::In, + Value::Array(vec![ + Value::Identifier(POST_A), + Value::Identifier(POST_B), + Value::Identifier(POST_C), + ]), + OWNER_1, + 3, + ); let outcome = drive .query_documents(query.clone(), None, false, None, None) - .expect("pivot query executes"); + .expect("the in pivot executes"); let documents = outcome.documents(); - assert_eq!(documents.len(), 1, "only OWNER_1's like beyond POST_A"); - assert_eq!(documents[0].owner_id().to_buffer(), OWNER_1); assert_eq!( - documents[0] - .properties() - .get("postId") - .expect("postId recovered") - .to_identifier_bytes() - .expect("identifier"), - POST_B.to_vec() + post_ids(documents), + vec![POST_B, POST_C], + "every like of OWNER_1 among the values, in postId order" ); + assert!(documents + .iter() + .all(|document| document.owner_id().to_buffer() == OWNER_1)); let (proof, _) = query .clone() .execute_with_proof(&drive, None, None, platform_version()) - .expect("pivot proof generation"); + .expect("in pivot proof generation"); let (_root, verified) = query .verify_proof(proof.as_slice(), platform_version()) - .expect("pivot proof verification"); - assert_eq!(verified.len(), 1); - assert_eq!(verified[0].id(), documents[0].id()); + .expect("in pivot proof verification"); + assert_eq!( + verified.iter().map(|d| d.id()).collect::>(), + documents.iter().map(|d| d.id()).collect::>(), + "proved and unproved pages must agree row for row" + ); assert_grovedb_is_consistent(&drive); } -/// A pivot on the FIRST property with the one below it unconstrained: -/// `hashtag >= h AND $ownerId == me` walks every post under each matched -/// hashtag through an insert-all level before the terminal equality. +/// An `in` pivot whose limit is below its number of values could stop +/// before the last value, so it is refused with the limit it needs. #[test] -fn should_serve_first_property_pivot_with_unconstrained_below() { - use crate::query::{OrderClause, WhereClause, WhereOperator}; - use dpp::document::DocumentV0Getters; - use dpp::platform_value::Value; +fn should_refuse_in_pivot_with_limit_below_its_value_count() { + use crate::query::WhereOperator; let (drive, contract) = setup_likes(); - for (post, owner, seed) in [ - (POST_A, OWNER_1, 1u64), - (POST_B, OWNER_1, 2), - (POST_B, OWNER_2, 3), - ] { - let like = build_like(&contract, "dash", post, owner, seed); - insert_like(&drive, &contract, &like, true).expect("insert like"); - } + let query = hashtag_post_pivot_query( + &contract, + WhereOperator::In, + Value::Array(vec![ + Value::Identifier(POST_A), + Value::Identifier(POST_B), + Value::Identifier(POST_C), + ]), + OWNER_1, + 2, + ); + assert_refused_on_read_prove_and_verify( + &drive, + &query, + &["needs a limit of at least its 3 values, got 2"], + ); +} +/// An `in` pivot ABOVE the last prefix property walks the properties +/// below it value by value, so it is refused like a range pivot. +#[test] +fn should_refuse_in_pivot_above_the_last_prefix_property() { + use crate::query::{OrderClause, WhereClause, WhereOperator}; + + let (drive, contract) = setup_likes(); let mut query = likes_query( &contract, vec![ WhereClause { field: "hashtag".to_string(), - operator: WhereOperator::GreaterThanOrEquals, - value: Value::Text("dash".to_string()), + operator: WhereOperator::In, + value: Value::Array(vec![ + Value::Text("dash".to_string()), + Value::Text("news".to_string()), + ]), }, WhereClause { field: "$ownerId".to_string(), @@ -1131,39 +1405,140 @@ fn should_serve_first_property_pivot_with_unconstrained_below() { ascending: true, }, ); + assert_refused_on_read_prove_and_verify( + &drive, + &query, + &[ + "`in` clause on `hashtag`", + "must sit on the index's last prefix property", + ], + ); +} + +/// The matcher breaks a tie between equally good indexes by name, so an +/// index on which the query forms an incomplete pivot can sort ahead of +/// one that serves the query in full. `aByPost` ([postId] → $ownerId) +/// sorts before `byLiker` ([$ownerId] → postId): `$ownerId == me AND +/// postId > p` is a range pivot on the first and a terminal range under a +/// fixed prefix on the second, so the second serves it. +#[test] +fn should_serve_through_an_index_without_a_pivot_when_one_matches() { + use crate::query::{OrderClause, WhereClause, WhereOperator}; + use dpp::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; + use dpp::platform_value::platform_value; + + let pv = platform_version(); + let contract = DataContract::from_value( + platform_value!({ + "$formatVersion": "1", + "id": Identifier::from([0x5C; 32]), + "ownerId": Identifier::from([0x5D; 32]), + "version": 1u32, + "documentSchemas": { + "like": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "aByPost", + "properties": [{ "postId": "asc" }], + "terminal": "$ownerId" + }, + { + "name": "byLiker", + "properties": [{ "$ownerId": "asc" }], + "terminal": "postId" + } + ], + "properties": { + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32u32, + "maxItems": 32u32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0u32 + } + }, + "required": ["postId"], + "additionalProperties": false + } + } + }), + true, + pv, + ) + .expect("the contract parses"); + let drive = setup_drive_with_initial_state_structure(None); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + pv, + ) + .expect("the contract applies"); + let document_type = contract + .document_type_for_name(DOCTYPE) + .expect("like doctype exists"); + for (post, owner, seed) in [(POST_B, OWNER_2, 1u64), (POST_C, OWNER_1, 2)] { + let mut like = document_type + .random_document(Some(seed), pv) + .expect("random document"); + like.set_properties(std::collections::BTreeMap::from([( + "postId".to_string(), + Value::Identifier(post), + )])); + like.set_owner_id(Identifier::from(owner)); + insert_like(&drive, &contract, &like, true).expect("insert like"); + } + let mut query = likes_query( + &contract, + vec![ + WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(OWNER_1), + }, + WhereClause { + field: "postId".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::Identifier(POST_A), + }, + ], + Some(1), + ); + query.order_by.insert( + "postId".to_string(), + OrderClause { + field: "postId".to_string(), + ascending: true, + }, + ); + assert_eq!( + query + .index_only_query_index(pv) + .expect("the query resolves an index") + .name, + "byLiker" + ); let outcome = drive .query_documents(query.clone(), None, false, None, None) - .expect("first-property pivot query executes"); - let documents = outcome.documents(); - assert_eq!(documents.len(), 2, "both of OWNER_1's likes"); - let mut posts: Vec> = documents - .iter() - .map(|document| { - assert_eq!(document.owner_id().to_buffer(), OWNER_1); - document - .properties() - .get("postId") - .expect("postId recovered") - .to_identifier_bytes() - .expect("identifier") - }) - .collect(); - posts.sort(); - assert_eq!(posts, vec![POST_A.to_vec(), POST_B.to_vec()]); - + .expect("the keyset page executes"); + assert_eq!(post_ids(outcome.documents()), vec![POST_C]); let (proof, _) = query .clone() - .execute_with_proof(&drive, None, None, platform_version()) - .expect("first-property pivot proof generation"); + .execute_with_proof(&drive, None, None, pv) + .expect("keyset proof generation"); let (_root, verified) = query - .verify_proof(proof.as_slice(), platform_version()) - .expect("first-property pivot proof verification"); - let mut verified_ids: Vec<_> = verified.iter().map(|d| d.id()).collect(); - let mut queried_ids: Vec<_> = documents.iter().map(|d| d.id()).collect(); - verified_ids.sort(); - queried_ids.sort(); - assert_eq!(verified_ids, queried_ids); + .verify_proof(proof.as_slice(), pv) + .expect("keyset proof verification"); + assert_eq!(post_ids(&verified), vec![POST_C]); assert_grovedb_is_consistent(&drive); } @@ -1273,10 +1648,10 @@ fn should_refuse_equality_below_pivot() { ); } -/// A pivot range without an orderBy on it is refused, mirroring the +/// A pivot `in` clause without an orderBy on it is refused, mirroring the /// stored-document rule. #[test] -fn should_require_order_by_for_pivot_range() { +fn should_require_order_by_for_in_pivot() { use crate::error::query::QuerySyntaxError; use crate::query::{WhereClause, WhereOperator}; use assert_matches::assert_matches; @@ -1293,8 +1668,8 @@ fn should_require_order_by_for_pivot_range() { }, WhereClause { field: "postId".to_string(), - operator: WhereOperator::GreaterThan, - value: Value::Identifier(POST_A), + operator: WhereOperator::In, + value: Value::Array(vec![Value::Identifier(POST_A), Value::Identifier(POST_B)]), }, WhereClause { field: "$ownerId".to_string(), @@ -1306,7 +1681,7 @@ fn should_require_order_by_for_pivot_range() { ); let error = drive .query_documents(query, None, false, None, None) - .expect_err("a pivot range without orderBy must be refused"); + .expect_err("a pivot `in` clause without orderBy must be refused"); assert_matches!( &error, crate::error::Error::Query(QuerySyntaxError::MissingOrderByForRange(_)), @@ -2129,6 +2504,78 @@ fn tip_estimated_fees_upper_bound_actual_fees() { assert_grovedb_is_consistent(&drive); } +/// The range pivot on an integer prefix property: `$ownerId == me AND +/// amount > n AND postId == p` through `byTipperAmount` ([$ownerId, +/// amount] → postId) puts the range on `amount`, above the terminal +/// equality, and is refused like the `postId` pivot of `like`, pointing to +/// an index with `postId` ahead of `amount`. +#[test] +fn should_refuse_amount_range_pivot_on_tips_by_tipper_amount() { + use crate::query::{InternalClauses, OrderClause, WhereClause, WhereOperator}; + + let (drive, contract) = setup_likes(); + for (post, owner, amount, seed) in [ + (POST_A, OWNER_1, 5u64, 1u64), + (POST_B, OWNER_1, 50, 2), + (POST_A, OWNER_2, 500, 3), + ] { + let tip = build_tip(&contract, post, owner, amount, seed); + insert_tip(&drive, &contract, &tip, true).expect("insert tip"); + } + + let mut query = crate::query::DriveDocumentQuery { + contract: &contract, + document_type: contract + .document_type_for_name(TIP_DOCTYPE) + .expect("tip doctype exists"), + internal_clauses: InternalClauses::extract_from_clauses( + vec![ + WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(OWNER_1), + }, + WhereClause { + field: "amount".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::U64(10), + }, + WhereClause { + field: "postId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(POST_A), + }, + ], + platform_version(), + ) + .expect("clauses extract"), + offset: None, + limit: Some(1), + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + }; + query.order_by.insert( + "amount".to_string(), + OrderClause { + field: "amount".to_string(), + ascending: true, + }, + ); + assert_refused_on_read_prove_and_verify( + &drive, + &query, + &[ + "range clause on `amount`", + "index \"byTipperAmount\"", + "($ownerId, postId) before `amount`", + ], + ); +} + // --------------------------------------------------------------------------- // timeRange buckets: bucketed entries (the `beat` doctype) // --------------------------------------------------------------------------- diff --git a/packages/rs-drive/src/query/index_only_synthesis.rs b/packages/rs-drive/src/query/index_only_synthesis.rs index bfcfecb51ea..c2070667771 100644 --- a/packages/rs-drive/src/query/index_only_synthesis.rs +++ b/packages/rs-drive/src/query/index_only_synthesis.rs @@ -40,7 +40,7 @@ use crate::error::query::QuerySyntaxError; use crate::error::Error; use crate::query::{ index_admissible_for_skip_if_absent, BestIndexOutcome, DriveDocumentQuery, InternalClauses, - WhereClause, + WhereClause, WhereOperator, }; use crate::verify::RootHash; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -57,17 +57,16 @@ use grovedb::GroveDb; use std::collections::BTreeMap; /// A resolved indexOnly terminal route: the matcher's winning index, the -/// clause on its terminal, and — for mixed shapes — the *prefix pivot*: a -/// range or `in` clause sitting on one of the index's prefix properties -/// (by position), with every property above it equality-bound and every -/// property below it unconstrained. +/// clause on its terminal and, for mixed shapes, the *prefix pivot*: an +/// `in` clause sitting on the index's last prefix property, with every +/// property above it equality-bound. pub(crate) struct IndexOnlyTerminalRoute<'a> { /// The index the entries are addressed through. pub index: &'a Index, /// The clause on the index's terminal property; `None` only for the /// first keyset page (order-by on the terminal, no cursor clause yet). pub terminal_clause: Option<&'a WhereClause>, - /// `(position, clause)` of a range / `in` clause on a prefix + /// `(position, clause)` of an `in` clause on the index's last prefix /// property. When present the terminal clause is always an equality. pub prefix_pivot: Option<(usize, &'a WhereClause)>, /// COMPOSITE terminals only: the equality clauses on the terminal's @@ -98,12 +97,15 @@ impl DriveDocumentQuery<'_> { /// nothing outside the index (a range or `in` on the terminal /// requires ordering by it, mirroring the stored-document rule). /// - /// Mixed shapes are served through a *prefix pivot*: one range or - /// `in` clause may sit on a prefix property instead of the terminal - /// (`hashtag == h AND postId > p AND $ownerId == me`), provided every - /// property above the pivot is equality-bound, properties below it - /// are unconstrained, the terminal clause is an equality, and the - /// pivot is ordered by (`MissingOrderByForRange` otherwise). + /// Mixed shapes are served through a *prefix pivot*: one `in` clause + /// may sit on the index's last prefix property instead of the + /// terminal (`hashtag == h AND postId IN [p, q] AND $ownerId == me`), + /// provided every property above it is equality-bound, the terminal + /// clause is an equality, the limit (if any) covers every `in` value, + /// and the pivot is ordered by (`MissingOrderByForRange` otherwise). + /// A range pivot, or an `in` pivot above the last prefix property, is + /// refused: its pages could hold fewer rows than exist (see + /// [`Self::refuse_incomplete_pivot_pages`]). /// /// Returns `Ok(None)` when the matcher finds no terminal-using index /// (the generic route's miss error stands), `Ok(Some(..))` with the @@ -176,31 +178,43 @@ impl DriveDocumentQuery<'_> { } } - let Some((index, _difference, terminal_used)) = self - .document_type - .index_for_types_matching_including_terminal( - equal_fields.as_slice(), - range_field, - in_field, - order_by_keys.as_slice(), - // Bucketed indexes never serve the terminal route: only - // resolved time ranges may bind to bucket keys, and those - // opted out above — a raw query name-matching a bucketed - // index's properties must not walk its grid-keyed levels. - // A skipIfAbsent index additionally requires its trigger - // bound — it is a sparse projection, and while the - // contiguous matcher already forces position 0 to be bound - // whenever any deeper property is used, an all-unused match - // inside the difference budget could still slip through - // (see [`index_admissible_for_skip_if_absent`]). - |index| { - index.time_range.is_none() - && index_admissible_for_skip_if_absent(index, &bound_fields) - }, - platform_version, - ) - .map_err(|e| Error::Protocol(Box::new(e)))? - else { + // Bucketed indexes never serve the terminal route: only resolved + // time ranges may bind to bucket keys, and those opted out above, + // so a raw query name-matching a bucketed index's properties must + // not walk its grid-keyed levels. A skipIfAbsent index + // additionally requires its trigger bound: it is a sparse + // projection, and while the contiguous matcher already forces + // position 0 to be bound whenever any deeper property is used, an + // all-unused match inside the difference budget could still slip + // through (see [`index_admissible_for_skip_if_absent`]). + let admissible = |index: &Index| { + index.time_range.is_none() && index_admissible_for_skip_if_absent(index, &bound_fields) + }; + let matching = |filter: &dyn Fn(&Index) -> bool| { + self.document_type + .index_for_types_matching_including_terminal( + equal_fields.as_slice(), + range_field, + in_field, + order_by_keys.as_slice(), + filter, + platform_version, + ) + .map_err(|e| Error::Protocol(Box::new(e))) + }; + // An index on which the query would form a pivot with incomplete + // pages must not win over an index that serves the query in full, + // so such indexes are skipped first. Only when no other index + // matches is the choice rerun without that filter, so the pivot + // arm below refuses the shape with its targeted error. + let complete_match = matching(&|index: &Index| { + admissible(index) && self.index_only_pivot_pages_are_complete(index, &bound_fields) + })?; + let matched = match complete_match { + Some(matched) => Some(matched), + None => matching(&admissible)?, + }; + let Some((index, _difference, terminal_used)) = matched else { return Ok(None); }; if !terminal_used { @@ -390,43 +404,42 @@ impl DriveDocumentQuery<'_> { } } Some((pivot_position, pivot_clause)) => { - // Mixed shape: everything above the pivot equality-bound, - // everything below it unconstrained, terminal clause an - // equality, pivot ordered by. + // Mixed shape: an `in` clause on the last prefix property + // with a limit covering every value, everything above it + // equality-bound, terminal clause an equality, pivot + // ordered by. + self.refuse_incomplete_pivot_pages(index, pivot_position, pivot_clause)?; let terminal_is_equality = match index.single_terminal() { Some(terminal) => self.internal_clauses.equal_clauses.contains_key(terminal), None => terminal_equalities.len() == components.len(), }; if !terminal_is_equality { return Err(shape_error( - "a range or `in` clause on an indexOnly prefix property requires \ - an EQUALITY clause on the terminal: two simultaneous non-equality \ + "an `in` clause on an indexOnly prefix property requires an \ + EQUALITY clause on the terminal: two simultaneous non-equality \ levels have no single pagination order", )); } - for (position, property) in index.properties.iter().enumerate() { - let has_equality = self - .internal_clauses - .equal_clauses - .contains_key(property.name.as_str()); - if position < pivot_position && !has_equality { - return Err(shape_error( - "every prefix property ABOVE a pivot range/`in` clause must \ - carry an equality clause", - )); - } - if position > pivot_position && has_equality { - return Err(shape_error( - "prefix properties BELOW a pivot range/`in` clause must be \ - unconstrained: an equality below the pivot is not yet \ - supported", - )); - } + if index + .properties + .iter() + .take(pivot_position) + .any(|property| { + !self + .internal_clauses + .equal_clauses + .contains_key(property.name.as_str()) + }) + { + return Err(shape_error( + "every prefix property ABOVE a pivot `in` clause must carry an \ + equality clause", + )); } if !self.order_by.contains_key(pivot_clause.field.as_str()) { return Err(Error::Query(QuerySyntaxError::MissingOrderByForRange( - "a range or `in` clause on an indexOnly prefix property \ - requires an orderBy on that property", + "an `in` clause on an indexOnly prefix property requires an \ + orderBy on that property", ))); } } @@ -441,16 +454,127 @@ impl DriveDocumentQuery<'_> { })) } + /// Refuses a prefix pivot whose pages could hold fewer rows than + /// exist. + /// + /// The pivot walk opens one branch per pivot value, and grovedb + /// charges a branch that yields no row one slot of the path query's + /// limit, on the unproved read and in the proof alike. A page of + /// `limit` therefore covers `limit` branches, not `limit` rows, and + /// the response carries no cursor or "more" flag to tell a short page + /// from the last one. The `in` pivot on the last prefix property is + /// the one shape whose pages are provably complete: each value opens + /// at most one branch (its `0` level and the terminal equality's + /// single member key) and an absent value opens none, so the walk + /// takes at most one slot per value, and a limit of at least the + /// number of values is never exhausted before the last branch. A + /// range pivot, and an `in` pivot with properties below it, can open + /// more branches than the limit has slots, so they are refused until + /// the storage layer can report where a page stopped. + fn refuse_incomplete_pivot_pages( + &self, + index: &Index, + pivot_position: usize, + pivot_clause: &WhereClause, + ) -> Result<(), Error> { + let components = index.terminal_components(); + let terminal_names = components.join(", "); + let use_instead = || { + let mut equality_bound: Vec<&str> = index + .properties + .iter() + .take(pivot_position) + .map(|property| property.name.as_str()) + .collect(); + equality_bound.extend(components.iter().map(String::as_str)); + format!( + "query through an index that lists the equality-bound properties ({}) \ + before `{}`, so the equalities fix the path and the clause on `{}` runs \ + below them", + equality_bound.join(", "), + pivot_clause.field, + pivot_clause.field, + ) + }; + if pivot_clause.operator != WhereOperator::In { + return Err(Error::Query(QuerySyntaxError::Unsupported(format!( + "a range clause on `{}`, a prefix property of indexOnly index \"{}\", is not \ + supported in a query that also names the index's terminal ({}): a page of \ + that query could hold fewer rows than exist, and the response cannot say \ + where it stopped; {}", + pivot_clause.field, + index.name, + terminal_names, + use_instead(), + )))); + } + if pivot_position + 1 != index.properties.len() { + return Err(Error::Query(QuerySyntaxError::Unsupported(format!( + "an `in` clause on `{}`, a prefix property of indexOnly index \"{}\", in a \ + query that also names the index's terminal ({}), must sit on the index's \ + last prefix property: the properties below it are walked value by value, \ + so a page could hold fewer rows than exist; {}", + pivot_clause.field, + index.name, + terminal_names, + use_instead(), + )))); + } + let in_value_count = pivot_clause.in_values().into_data_with_error()??.len(); + if let Some(limit) = self.limit { + if usize::from(limit) < in_value_count { + return Err(Error::Query(QuerySyntaxError::Unsupported(format!( + "an `in` clause on `{}`, the last prefix property of indexOnly index \ + \"{}\", in a query that also names the index's terminal ({}), needs a \ + limit of at least its {} values, got {}: every value takes one place \ + in the limit whether or not it has a row", + pivot_clause.field, index.name, terminal_names, in_value_count, limit, + )))); + } + } + Ok(()) + } + + /// Whether every prefix pivot this query would form on `index` serves + /// complete pages (see [`Self::refuse_incomplete_pivot_pages`]). A + /// pivot forms only where the query names one of the index's terminal + /// components (`named_fields`: its clause and orderBy fields) and puts + /// a range or `in` clause on one of its prefix properties. + fn index_only_pivot_pages_are_complete(&self, index: &Index, named_fields: &[&str]) -> bool { + let components = index.terminal_components(); + if !components + .iter() + .any(|component| named_fields.contains(&component.as_str())) + { + return true; + } + self.internal_clauses + .range_clause + .iter() + .chain(self.internal_clauses.in_clauses.iter()) + .filter(|clause| !components.contains(&clause.field)) + .filter_map(|clause| { + index + .properties + .iter() + .position(|property| property.name == clause.field) + .map(|position| (position, clause)) + }) + .all(|(position, clause)| { + self.refuse_incomplete_pivot_pages(index, position, clause) + .is_ok() + }) + } + /// Build the path query for a terminal-clause indexOnly query. One /// builder for the server's execution, the prover and the verifier. /// /// Without a pivot: the fully determined prefix path down to the `0` /// entry level, with the terminal clause lowered over the member - /// keys. With a prefix pivot: the path stops at the pivot property, - /// the pivot clause ranges over its values, and a subquery chain - /// walks each selected value through the unconstrained properties - /// below it (`insert_all` per level) down to `0`, where the terminal - /// equality selects the member key. + /// keys. With a prefix pivot: the path stops at the pivot property + /// (the index's last prefix property), the pivot's `in` clause + /// selects its values, and a subquery under each one descends to + /// `0`, where the terminal equality selects the member key. pub(crate) fn index_only_terminal_path_query( &self, document_type_path: Vec>, @@ -512,50 +636,23 @@ impl DriveDocumentQuery<'_> { (terminal_query, index.properties.len()) } Some((pivot_position, pivot_clause)) => { - // The pivot clause ranges over its property's values; - // below it, one `insert_all` level per unconstrained - // property, then `0` and the terminal equality. Built - // innermost-out. - let mut chain = terminal_query; - let mut chain_is_terminal = true; - for position in ((pivot_position + 1)..index.properties.len()).rev() { - let property = &index.properties[position]; - let mut values_query = grovedb::Query::new_with_direction(direction_for( - &property.name, - property.ascending, - )); - values_query.insert_all(); - if chain_is_terminal { - values_query.set_subquery_key(vec![0]); - } else { - values_query.set_subquery_key( - index.properties[position + 1].name.as_bytes().to_vec(), - ); - } - values_query.set_subquery(chain); - chain = values_query; - chain_is_terminal = false; + // Selection admits a pivot only on the last prefix + // property, so `0` sits directly under each of its + // values and the terminal equality runs there. + if pivot_position + 1 != index.properties.len() { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "terminal-route selection admits a pivot only on the last prefix \ + property", + ))); } - let mut pivot_query = pivot_clause.to_path_query( self.document_type, &None, direction_for(pivot_clause.field.as_str(), true), platform_version, )?; - if chain_is_terminal { - // The pivot is the last property: `0` sits directly - // under each of its values. - pivot_query.set_subquery_key(vec![0]); - } else { - pivot_query.set_subquery_key( - index.properties[pivot_position + 1] - .name - .as_bytes() - .to_vec(), - ); - } - pivot_query.set_subquery(chain); + pivot_query.set_subquery_key(vec![0]); + pivot_query.set_subquery(terminal_query); (pivot_query, *pivot_position) } }; @@ -611,7 +708,6 @@ impl DriveDocumentQuery<'_> { terminal_tail: Option<&crate::query::WhereClause>, platform_version: &PlatformVersion, ) -> Result { - use crate::query::WhereOperator; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; const MAX_KEY_LENGTH: usize = u8::MAX as usize; From c135e7cd5575bbbda57f3ca136fa1d36a54e497f Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:54:55 +0300 Subject: [PATCH 101/113] ci(wasm-sdk): bound optimizer threads independently of compilation --- .github/actions/npm-release-build/action.yaml | 2 +- .github/scripts/run-npm-build.py | 64 ++++++++++++++++ .../scripts/tests/test_npm_build_budget.py | 75 +++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/run-npm-build.py create mode 100644 .github/scripts/tests/test_npm_build_budget.py diff --git a/.github/actions/npm-release-build/action.yaml b/.github/actions/npm-release-build/action.yaml index d139698a414..a7d9151bc5e 100644 --- a/.github/actions/npm-release-build/action.yaml +++ b/.github/actions/npm-release-build/action.yaml @@ -97,7 +97,7 @@ runs: - name: Build packages shell: bash - run: yarn build + run: python3 .github/scripts/run-npm-build.py env: CARGO_BUILD_PROFILE: release diff --git a/.github/scripts/run-npm-build.py b/.github/scripts/run-npm-build.py new file mode 100644 index 00000000000..6d78990163e --- /dev/null +++ b/.github/scripts/run-npm-build.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Bound Binaryen's thread pool without changing package build commands/passes.""" +import os +from pathlib import Path +import re +import sys + + +def cpu_budget(cgroup=Path('/sys/fs/cgroup'), affinity=None): + """Container CPU-time quota and affinity, not the host's hardware count.""" + if affinity is None: + affinity = len(os.sched_getaffinity(0)) + limits = [affinity] + maximum = cgroup / 'cpu.max' + if maximum.exists(): + quota, period = maximum.read_text().split() + if quota != 'max': + limits.append(max(1, int(quota) // int(period))) + else: + # Docker cgroup v1 may mount the cpu controller under either name. + for controller in (cgroup / 'cpu', cgroup / 'cpu,cpuacct'): + quota_file = controller / 'cpu.cfs_quota_us' + if quota_file.exists(): + quota = int(quota_file.read_text()) + period = int((controller / 'cpu.cfs_period_us').read_text()) + if quota > 0: + limits.append(max(1, quota // period)) + break + return min(limits) + + +def build_environment(environment, budget): + env = dict(environment) + # Compile jobs can be memory-limited independently of the optimizer pool. + # Preserve an explicit measured Binaryen setting supplied by the runner. + requested = env.get('BINARYEN_CORES', str(budget)) + if not re.fullmatch(r'[1-9][0-9]*', requested): + raise ValueError('BINARYEN_CORES must be a positive integer') + if int(requested) > budget: + raise ValueError('BINARYEN_CORES exceeds the container CPU budget') + env['BINARYEN_CORES'] = requested + return env + + +def main(): + try: + budget = cpu_budget() + env = build_environment(os.environ, budget) + except (OSError, ValueError, ZeroDivisionError) as error: + print(f'::error::Cannot select WASM build thread budget: {error}', file=sys.stderr) + return 2 + print(f'::notice::NPM build: CPU budget={budget}, ' + f'Binaryen threads={env["BINARYEN_CORES"]}; Cargo configuration unchanged', + flush=True) + # Bash is already required by this CI action. Its time keyword needs no + # additional package and preserves yarn's exit status. Cancellation remains + # the runner's existing process-tree cleanup responsibility. + # The command and all optimization flags remain unchanged. + env['TIMEFORMAT'] = 'NPM build timing: wall=%3R user=%3U system=%3S seconds' + os.execve('/bin/bash', ['bash', '-c', 'time yarn build'], env) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/scripts/tests/test_npm_build_budget.py b/.github/scripts/tests/test_npm_build_budget.py new file mode 100644 index 00000000000..48e569347ea --- /dev/null +++ b/.github/scripts/tests/test_npm_build_budget.py @@ -0,0 +1,75 @@ +"""Exercise CPU discovery and the real build launcher without compiling source.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / 'run-npm-build.py' +SPEC = importlib.util.spec_from_file_location('npm_build', SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class NpmBuildBudgetTests(unittest.TestCase): + def test_should_honor_quota_and_affinity(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for maximum, affinity, expected in [('2000000 100000', 32, 20), + ('2000000 100000', 8, 8), + ('max 100000', 6, 6), + ('150000 100000', 8, 1)]: + with self.subTest(maximum=maximum, affinity=affinity): + (root / 'cpu.max').write_text(maximum) + self.assertEqual(MODULE.cpu_budget(root, affinity), expected) + + def test_should_support_legacy_quota_and_no_quota(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.assertEqual(MODULE.cpu_budget(root, 8), 8) + controller = root / 'cpu,cpuacct' + controller.mkdir() + (controller / 'cpu.cfs_period_us').write_text('100000') + (controller / 'cpu.cfs_quota_us').write_text('400000') + self.assertEqual(MODULE.cpu_budget(root, 32), 4) + (controller / 'cpu.cfs_quota_us').write_text('-1') + self.assertEqual(MODULE.cpu_budget(root, 32), 32) + + def test_should_preserve_separate_compile_and_optimizer_budgets(self): + original = {'CARGO_BUILD_JOBS': '20', 'BINARYEN_CORES': '4', 'RUSTFLAGS': 'unchanged'} + self.assertEqual(MODULE.build_environment(original, 20), original) + self.assertEqual(MODULE.build_environment({'CARGO_BUILD_JOBS': '2'}, 8), + {'CARGO_BUILD_JOBS': '2', 'BINARYEN_CORES': '8'}) + + def test_should_reject_invalid_or_excessive_thread_count(self): + for value in ['', '0', '-1', '1.5', 'auto', '21', '4; false']: + with self.subTest(value=value), self.assertRaises(ValueError): + MODULE.build_environment({'BINARYEN_CORES': value}, 20) + + def test_should_preserve_command_environment_and_failure_exit_status(self): + # Use an executable scratch directory: some CI workspaces mount /tmp noexec. + with tempfile.TemporaryDirectory(dir=Path.cwd()) as directory: + root = Path(directory) + yarn = root / 'yarn' + yarn.write_text('#!/usr/bin/env python3\nimport json, os, sys\n' + 'print(json.dumps({"args":sys.argv[1:], ' + '"cores":os.environ["BINARYEN_CORES"], ' + '"cargo":os.environ["CARGO_BUILD_JOBS"]}))\n' + 'sys.exit(int(os.environ["BUILD_TEST_EXIT"]))\n') + yarn.chmod(0o700) + for code in [0, 23]: + env = dict(os.environ, PATH=str(root) + ':' + os.environ['PATH'], + BINARYEN_CORES='1', CARGO_BUILD_JOBS='2', BUILD_TEST_EXIT=str(code)) + result = subprocess.run(['python3', str(SCRIPT)], env=env, + capture_output=True, text=True) + self.assertEqual(result.returncode, code, result.stderr) + payload = json.loads(next(line for line in result.stdout.splitlines() + if line.startswith('{'))) + self.assertEqual(payload, {'args': ['build'], 'cores': '1', 'cargo': '2'}) + self.assertIn('NPM build timing: wall=', result.stderr) + + +if __name__ == '__main__': + unittest.main() From 1368fbbee977ebda09ace36392e40790e69364e5 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 29 Sep 2026 01:10:04 +0700 Subject: [PATCH 102/113] fix(platform-wallet): a ProUpServTx always carries an output (#5105) Co-authored-by: Claude Opus 5.5 --- .../src/masternode/update_service.rs | 299 ++++++++++++++---- 1 file changed, 241 insertions(+), 58 deletions(-) diff --git a/packages/rs-platform-wallet/src/masternode/update_service.rs b/packages/rs-platform-wallet/src/masternode/update_service.rs index 90a66fda515..0d12b781ce5 100644 --- a/packages/rs-platform-wallet/src/masternode/update_service.rs +++ b/packages/rs-platform-wallet/src/masternode/update_service.rs @@ -26,7 +26,7 @@ use dashcore::blsful::{Bls12381G2Impl, SecretKey as BlsSecretKey, SignatureSchem use dashcore::hash_types::InputsHash; use dashcore::hashes::Hash; use dashcore::platform_node_id::PlatformNodeId; -use dashcore::{Address as DashAddress, Network, Txid}; +use dashcore::{Address as DashAddress, Network, Transaction, Txid}; use key_wallet::wallet::managed_wallet_info::transaction_builder::{ BuilderError, TransactionBuilder, TransactionSigner, }; @@ -161,13 +161,14 @@ async fn fetch_operator_reward( operator_reward_from_registration(pro_tx_hash, &fetched.transaction) } -/// Read `operatorReward` out of a fetched registration transaction — +/// Read `operatorReward` out of a fetched registration transaction, /// binding the response to the request first: DAPI's get-transaction reply /// is not authenticated, so the decoded transaction must hash to the -/// SPV-authenticated proTxHash before its payload is trusted. Without this -/// check a faulty or malicious endpoint could answer with an unrelated -/// zero-reward ProRegTx and steer [`resolve_operator_payout_script`] into -/// clearing a real operator payout. +/// requested proTxHash before its payload is trusted. Only the operator +/// reward is read from it; the service values come from the synced +/// masternode list's entry for that proTxHash. Without the hash check a +/// reply carrying an unrelated zero-reward ProRegTx would steer +/// [`resolve_operator_payout_script`] into clearing a real operator payout. pub(crate) fn operator_reward_from_registration( pro_tx_hash: &[u8; 32], transaction: &dashcore::Transaction, @@ -351,12 +352,88 @@ pub(crate) fn service_payload_fields(service: SocketAddr) -> (u128, u16) { (u128::from_le_bytes(octets), service.port()) } +/// The funding builder for a ProUpServTx: the placeholder payload, the +/// finalizer that commits it to the selected inputs and signs it, and one +/// zero-value data output. +/// +/// A ProUpServTx moves no value, so without that output a selection whose +/// surplus is below the dust limit (all of it booked as fee, no change +/// output) leaves the transaction with no outputs, which consensus refuses. +/// The data output is the placeholder Dash Core's own `protx +/// update_service` adds for the same reason, so the transaction always +/// carries an output; change is still returned whenever the surplus clears +/// the dust limit. +pub(crate) fn update_service_builder( + placeholder: ProviderUpdateServicePayload, + operator_secret: Zeroizing<[u8; 32]>, +) -> Result { + Ok(TransactionBuilder::new() + .add_op_return(&[]) + .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))? + .set_special_payload(TransactionPayload::ProviderUpdateServicePayloadType( + placeholder, + )) + .set_payload_finalizer(move |unsigned| { + finalize_update_service_payload(unsigned, &operator_secret) + })) +} + +/// The payload finalizer: write `inputs_hash` over the selected inputs and +/// the operator-BLS `payload_sig` (basic scheme over `base_payload_hash()`, +/// modern serialization: the exact convention `verify_message_digest` +/// checks real mainnet signatures with). +/// +/// Refuses a transaction without outputs before signing anything. key-wallet +/// releases the build's reservation on any finalizer error, so the refusal +/// leaves the selected inputs spendable. +pub(crate) fn finalize_update_service_payload( + unsigned: &Transaction, + operator_secret: &[u8; 32], +) -> Result { + if unsigned.output.is_empty() { + return Err(BuilderError::InvalidData( + "the assembled ProUpServTx has no outputs, which the network refuses".into(), + )); + } + let Some(TransactionPayload::ProviderUpdateServicePayloadType(placeholder)) = + &unsigned.special_transaction_payload + else { + return Err(BuilderError::InvalidData( + "the ProUpServTx placeholder payload is missing from the assembled transaction".into(), + )); + }; + let mut finalized = placeholder.clone(); + finalized.inputs_hash = unsigned.hash_inputs(); + + let secret = Option::>::from( + BlsSecretKey::::from_be_bytes(operator_secret), + ) + .ok_or_else(|| { + BuilderError::SigningFailed("the operator key is not a valid BLS secret".into()) + })?; + let signature = secret + .sign( + SignatureSchemes::Basic, + finalized.base_payload_hash().as_byte_array(), + ) + .map_err(|e| BuilderError::SigningFailed(format!("BLS payload signing failed: {e}")))?; + let signature_bytes: [u8; 96] = signature + .to_bytes_with_mode(dashcore::blsful::SerializationFormat::Modern) + .as_slice() + .try_into() + .map_err(|_| { + BuilderError::SigningFailed("BLS signature did not serialize to 96 bytes".into()) + })?; + finalized.payload_sig = BLSSignature::from(signature_bytes); + Ok(TransactionPayload::ProviderUpdateServicePayloadType( + finalized, + )) +} + /// Fund and finalize the ProUpServTx: input selection reserves the funding /// inputs, the payload finalizer writes `inputs_hash` and the operator-BLS -/// `payload_sig` (basic scheme over `base_payload_hash()`, modern -/// serialization — the exact convention `verify_message_digest` checks real -/// mainnet signatures with), and only then are the inputs ECDSA-signed, -/// since their sighashes cover the finished payload. +/// `payload_sig`, and only then are the inputs ECDSA-signed, since their +/// sighashes cover the finished payload. /// /// Stops at the signed transaction; the caller broadcasts or abandons it. pub(crate) async fn build_sign_update_service( @@ -369,52 +446,7 @@ where B: TransactionBroadcaster + ?Sized, S: TransactionSigner + ?Sized + Sync, { - let builder = TransactionBuilder::new() - .set_special_payload(TransactionPayload::ProviderUpdateServicePayloadType( - placeholder, - )) - .set_payload_finalizer(move |unsigned| { - let Some(TransactionPayload::ProviderUpdateServicePayloadType(placeholder)) = - &unsigned.special_transaction_payload - else { - return Err(BuilderError::InvalidData( - "the ProUpServTx placeholder payload is missing from the assembled \ - transaction" - .into(), - )); - }; - let mut finalized = placeholder.clone(); - finalized.inputs_hash = unsigned.hash_inputs(); - - let secret = Option::>::from( - BlsSecretKey::::from_be_bytes(&operator_secret), - ) - .ok_or_else(|| { - BuilderError::SigningFailed("the operator key is not a valid BLS secret".into()) - })?; - let signature = secret - .sign( - SignatureSchemes::Basic, - finalized.base_payload_hash().as_byte_array(), - ) - .map_err(|e| { - BuilderError::SigningFailed(format!("BLS payload signing failed: {e}")) - })?; - let signature_bytes: [u8; 96] = signature - .to_bytes_with_mode(dashcore::blsful::SerializationFormat::Modern) - .as_slice() - .try_into() - .map_err(|_| { - BuilderError::SigningFailed( - "BLS signature did not serialize to 96 bytes".into(), - ) - })?; - finalized.payload_sig = BLSSignature::from(signature_bytes); - Ok(TransactionPayload::ProviderUpdateServicePayloadType( - finalized, - )) - }); - + let builder = update_service_builder(placeholder, operator_secret)?; core.finalize_transaction(builder, &SEND_FUNDING_SOURCES, 0, signer) .await } @@ -430,9 +462,10 @@ mod tests { use super::super::list::test_support::{evonode, masternode}; use super::*; use crate::broadcaster::BroadcastError; - use crate::test_support::funded_wallet_manager; + use crate::test_support::{ + funded_wallet_manager, funded_wallet_manager_with_outputs, WalletSigner, + }; use dashcore::blsful::{PublicKey as BlsPublicKey, Signature as BlsSignature}; - use dashcore::Transaction; use key_wallet::account::StandardAccountType; use std::sync::{Arc, Mutex}; @@ -463,6 +496,40 @@ mod tests { } } + impl RecordingBroadcaster { + fn sent_count(&self) -> usize { + self.sent.lock().expect("broadcaster lock").len() + } + } + + /// A BIP44-funded core wallet holding one UTXO per value in `outputs`, + /// with a broadcaster that records what it is given. + async fn core_with_outputs( + outputs: &[u64], + ) -> ( + CoreWallet, + Arc, + WalletSigner, + ) { + let (wallet_manager, wallet_id, generation, signer) = + funded_wallet_manager_with_outputs(StandardAccountType::BIP44Account, outputs).await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let broadcaster = Arc::new(RecordingBroadcaster::default()); + let core = CoreWallet::new( + sdk, + wallet_manager, + wallet_id, + broadcaster.clone(), + generation, + ); + (core, broadcaster, signer) + } + + fn regular_placeholder() -> ProviderUpdateServicePayload { + let entry = operator_entry(0x44, false); + prepare_update_service_placeholder(&entry, None, ScriptBuf::new()).expect("placeholder") + } + /// The IPv4-mapped little-endian encoding, pinned against the known /// testnet ProUpServTx vector in dashcore's own payload tests /// (52.36.64.148:19999). @@ -702,6 +769,10 @@ mod tests { let tx = &sent[0]; assert_eq!(tx.txid(), txid); assert_eq!(tx.version, 3); + assert!( + !tx.output.is_empty(), + "a ProUpServTx always carries an output" + ); assert!( tx.input.iter().all(|input| !input.script_sig.is_empty()), "every funding input is ECDSA-signed" @@ -741,4 +812,116 @@ mod tests { .verify(&public_key, payload.base_payload_hash().as_byte_array()) .expect("operator BLS signature verifies over base_payload_hash"); } + + /// A lone 600-duff UTXO covers the fee but leaves less than the dust + /// limit over, so no change output is added. The zero-value data output + /// still gives the transaction an output. + #[tokio::test] + async fn should_carry_an_output_when_the_only_utxo_leaves_no_change() { + let (core, broadcaster, signer) = core_with_outputs(&[600]).await; + + let prepared = build_sign_update_service( + &core, + regular_placeholder(), + Zeroizing::new(OPERATOR_SECRET), + &signer, + ) + .await + .expect("600 duffs pay the fee"); + + let tx = prepared.transaction(); + assert_eq!(tx.input.len(), 1); + assert_eq!(tx.output.len(), 1, "one output even with no change"); + assert!(tx.output[0].script_pubkey.is_op_return()); + assert_eq!(tx.output[0].value, 0, "the data output moves no value"); + assert_eq!(prepared.fee(), 600, "the sub-dust surplus is the fee"); + assert_eq!(broadcaster.sent_count(), 0, "preparing must not broadcast"); + } + + /// Branch-and-bound prefers the small UTXO, whose surplus is booked as + /// fee; the transaction still carries an output. + #[tokio::test] + async fn should_carry_an_output_when_selection_prefers_the_small_utxo() { + let (core, _broadcaster, signer) = core_with_outputs(&[1_000_000_000, 600]).await; + + let prepared = build_sign_update_service( + &core, + regular_placeholder(), + Zeroizing::new(OPERATOR_SECRET), + &signer, + ) + .await + .expect("update service builds"); + + let tx = prepared.transaction(); + assert!( + !tx.output.is_empty(), + "a ProUpServTx always carries an output" + ); + let Some(TransactionPayload::ProviderUpdateServicePayloadType(payload)) = + &tx.special_transaction_payload + else { + panic!("the transaction must carry the ProUpServTx payload"); + }; + assert_eq!(payload.inputs_hash, tx.hash_inputs()); + } + + /// A wallet that cannot cover the fee fails in coin selection, before + /// anything is reserved, signed or sent. + #[tokio::test] + async fn should_fail_before_broadcast_when_the_fee_cannot_be_covered() { + let (core, broadcaster, signer) = core_with_outputs(&[200]).await; + + let err = build_sign_update_service( + &core, + regular_placeholder(), + Zeroizing::new(OPERATOR_SECRET), + &signer, + ) + .await + .expect_err("200 duffs cannot pay the fee"); + + assert!( + matches!(err, PlatformWalletError::CorePooledInsufficientFunds { .. }), + "an insufficient-funds error, got {err:?}" + ); + assert_eq!(broadcaster.sent_count(), 0, "nothing is broadcast"); + } + + /// Without the data output a lone 600-duff UTXO assembles into a + /// transaction with no outputs. The finalizer refuses it before signing, + /// and the refusal releases the reservation: the same UTXO funds the + /// next build. + #[tokio::test] + async fn should_release_the_reservation_when_the_transaction_has_no_outputs() { + let (core, broadcaster, signer) = core_with_outputs(&[600]).await; + + let secret = Zeroizing::new(OPERATOR_SECRET); + let without_output = TransactionBuilder::new() + .set_special_payload(TransactionPayload::ProviderUpdateServicePayloadType( + regular_placeholder(), + )) + .set_payload_finalizer(move |unsigned| { + finalize_update_service_payload(unsigned, &secret) + }); + let err = core + .finalize_transaction(without_output, &SEND_FUNDING_SOURCES, 0, &signer) + .await + .expect_err("a transaction with no outputs is refused"); + assert!( + matches!(&err, PlatformWalletError::TransactionBuild(message) if message.contains("no outputs")), + "the refusal names the missing outputs, got {err:?}" + ); + assert_eq!(broadcaster.sent_count(), 0, "nothing is broadcast"); + + let rebuilt = build_sign_update_service( + &core, + regular_placeholder(), + Zeroizing::new(OPERATOR_SECRET), + &signer, + ) + .await + .expect("the released UTXO funds the next build"); + assert_eq!(rebuilt.transaction().input.len(), 1); + } } From b40e4db1498386af55192721cd9d18c7f477677f Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 29 Sep 2026 01:13:38 +0700 Subject: [PATCH 103/113] feat: add Value::same_scalar_data for comparing single values across variants (#5125) Co-authored-by: Claude Opus 5.5 --- book/src/contract-keywords/refers-to.md | 1 + .../document_reference_validation/v0/mod.rs | 38 +-- .../batch/tests/document/agreement_values.rs | 211 ++++++++++++++ .../batch/tests/document/mod.rs | 1 + .../v0/mod.rs | 4 +- .../data_contract_create/mod.rs | 23 +- ...dation-contract-agreement-long-values.json | 52 ++++ packages/rs-platform-value/src/eq.rs | 274 ++++++++++++++++++ .../rs-platform-version/src/version/v14.rs | 13 + 9 files changed, 589 insertions(+), 28 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/agreement_values.rs create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-long-values.json diff --git a/book/src/contract-keywords/refers-to.md b/book/src/contract-keywords/refers-to.md index b20fb5fe96f..95a3a456f86 100644 --- a/book/src/contract-keywords/refers-to.md +++ b/book/src/contract-keywords/refers-to.md @@ -151,6 +151,7 @@ Binds the referring document to the document it references: each pair `{ "> = match referenced_property.as_str() { OWNER_ID => Some(Cow::Owned(Value::Identifier( referenced_document.owner_id().to_buffer(), @@ -1167,21 +1171,11 @@ fn validate_reference_target_v0( // differing value would be. (Some(_), None) | (None, Some(_)) => return Ok(mismatch()), }; - let Ok(referring_encoded) = document_type.serialize_value_for_key( - referring_property, - &referring_value, - platform_version, - ) else { - return Ok(mismatch()); - }; - let Ok(referenced_encoded) = referenced_document_type.serialize_value_for_key( - referenced_property, - &referenced_value, - platform_version, - ) else { - return Ok(mismatch()); - }; - if referring_encoded != referenced_encoded { + // In place in generation 0, which every table selects: a + // `propertyAgreement` only parses from protocol version + // 14 (`apply_property_reference` 0), so before it no + // document type carries a pair to reach this comparison + if !referring_value.same_scalar_data(&referenced_value) { return Ok(mismatch()); } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/agreement_values.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/agreement_values.rs new file mode 100644 index 00000000000..85beef881ad --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/agreement_values.rs @@ -0,0 +1,211 @@ +//! `propertyAgreement` values through the full ABCI pipeline (protocol +//! version 14). A pair holds when its two sides are the same value of their +//! type, whatever its size: the long-values fixture's `like.hashtag` agrees +//! with its post's `hashtag`, both strings of up to 280 characters that no +//! index bounds. + +use super::*; + +mod agreement_values_tests { + use super::super::reference_test_setup::{ + assert_successful, create_document, register_contract_at, + }; + use super::*; + use crate::platform_types::platform_state::PlatformState; + use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::identity::signer::Signer; + use dpp::identity::IdentityPublicKey; + use dpp::prelude::DataContract; + + const LONG_VALUES_CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-long-values.json"; + + fn assert_mismatch(result: &StateTransitionsProcessingResult, because: &str) { + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(_) + ), + .. + }], + "{because}" + ); + } + + /// Creates a post under `post_hashtag`, asserting success, then a like on + /// it under `like_hashtag`, and returns the like's result. Uses two + /// nonces from `nonce`. + #[allow(clippy::too_many_arguments)] + async fn like_a_post>( + platform: &TempPlatform, + platform_state: &PlatformState, + contract: &DataContract, + post_hashtag: &str, + like_hashtag: &str, + owner: Identifier, + key: &IdentityPublicKey, + nonce: u64, + signer: &S, + rng: &mut StdRng, + platform_version: &PlatformVersion, + ) -> StateTransitionsProcessingResult { + let (post, result) = create_document( + platform, + platform_state, + contract, + "post", + &[("hashtag", Value::Text(post_hashtag.to_string()))], + owner, + key, + nonce, + signer, + rng, + platform_version, + ) + .await; + assert_successful(&result, "the post must be created"); + let (_, result) = create_document( + platform, + platform_state, + contract, + "like", + &[ + ("postId", Value::Identifier(post.id().to_buffer())), + ("hashtag", Value::Text(like_hashtag.to_string())), + ], + owner, + key, + nonce + 1, + signer, + rng, + platform_version, + ) + .await; + result + } + + /// No tree key holds more than 255 bytes, and no index bounds these + /// hashtags: equal values of 256 bytes and more agree. + #[tokio::test] + async fn should_agree_on_equal_strings_longer_than_a_tree_key() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let mut rng = StdRng::seed_from_u64(5310); + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + let contract = register_contract_at( + &platform, + LONG_VALUES_CONTRACT_PATH, + identity.id(), + true, + platform_version, + ); + + let long_ascii = "a".repeat(256); + // Four bytes each: 64 make 256 bytes, 70 make 280 + let emoji_64 = "\u{1F600}".repeat(64); + let emoji_70 = "\u{1F600}".repeat(70); + for (nonce, hashtag) in [(2, &long_ascii), (4, &emoji_64), (6, &emoji_70)] { + let result = like_a_post( + &platform, + &platform_state, + &contract, + hashtag, + hashtag, + identity.id(), + &key, + nonce, + &signer, + &mut rng, + platform_version, + ) + .await; + assert_successful( + &result, + &format!( + "a like echoing its post's {}-byte hashtag must agree", + hashtag.len() + ), + ); + } + + let result = like_a_post( + &platform, + &platform_state, + &contract, + &long_ascii, + &format!("{}b", "a".repeat(255)), + identity.id(), + &key, + 8, + &signer, + &mut rng, + platform_version, + ) + .await; + assert_mismatch(&result, "long hashtags that differ must still disagree"); + } + + /// The tree key of the empty string is the one of `"\0"`; as values they + /// differ. + #[tokio::test] + async fn should_refuse_an_empty_string_against_a_nul_character() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let mut rng = StdRng::seed_from_u64(5311); + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + let contract = register_contract_at( + &platform, + LONG_VALUES_CONTRACT_PATH, + identity.id(), + true, + platform_version, + ); + + for (nonce, post_hashtag, like_hashtag) in [(2, "", "\0"), (4, "\0", "")] { + let result = like_a_post( + &platform, + &platform_state, + &contract, + post_hashtag, + like_hashtag, + identity.id(), + &key, + nonce, + &signer, + &mut rng, + platform_version, + ) + .await; + assert_mismatch( + &result, + &format!("{post_hashtag:?} and {like_hashtag:?} are different hashtags"), + ); + } + + let result = like_a_post( + &platform, + &platform_state, + &contract, + "", + "", + identity.id(), + &key, + 6, + &signer, + &mut rng, + platform_version, + ) + .await; + assert_successful(&result, "two empty hashtags agree"); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index dfebd8a8519..2923da65e26 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -1,4 +1,5 @@ mod action_fees; +mod agreement_values; mod contract_owner_requirement; mod creation; mod deletable_document_reference; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs index a17c7bda926..d17d47233ad 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs @@ -674,8 +674,8 @@ fn validate_reference_target_declaration_v0( "agreement properties must be plain values, not object containers", )); } - // The write-time check compares index key encodings, which a - // list does not have, so an agreement on one would never hold + // The write-time check compares single values, which a list is + // not, so an agreement on one would never hold if matches!(referring_type, DocumentPropertyType::TypedArray(_)) || matches!( referenced.property_type, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs index b4f7f9a6616..46e336873ca 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs @@ -5979,10 +5979,10 @@ mod tests { ); } - /// An agreement is checked at write time by comparing index key - /// encodings, which a typed array does not have, so one between two - /// typed arrays of the same element type is refused at registration - /// rather than refusing every write that carries them. + /// An agreement is checked at write time by comparing single values, + /// which a typed array is not, so one between two typed arrays of the + /// same element type is refused at registration rather than refusing + /// every write that carries them. #[tokio::test] async fn should_reject_agreement_on_typed_array_properties() { let result = run_contract_create( @@ -6001,6 +6001,21 @@ mod tests { ); } + /// No index bounds these agreement properties, so their values may be + /// longer than a tree key: the pair compares values, not keys. + #[tokio::test] + async fn should_register_an_agreement_on_strings_longer_than_a_tree_key() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-long-values.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + /// No stored document carries a transient value, so an agreement with /// one on the referenced side could only hold for a referring document /// omitting its own side, and a required one never. A property inside a diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-long-values.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-long-values.json new file mode 100644 index 00000000000..f6dbb94997b --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-long-values.json @@ -0,0 +1,52 @@ +{ + "$formatVersion": "1", + "id": "7BnTp1vqZWEMQFYwXnxdBoFqNVxwkv5CYDD3n1QU2GnL", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "post": { + "type": "object", + "canBeDeleted": false, + "properties": { + "hashtag": { + "type": "string", + "position": 0, + "maxLength": 280 + } + }, + "required": [], + "additionalProperties": false + }, + "like": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "properties": { + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "post", + "propertyAgreement": { + "hashtag": "hashtag" + } + } + }, + "hashtag": { + "type": "string", + "position": 1, + "maxLength": 280 + } + }, + "required": [ + "postId" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-platform-value/src/eq.rs b/packages/rs-platform-value/src/eq.rs index d76c6121f06..3f64836ba74 100644 --- a/packages/rs-platform-value/src/eq.rs +++ b/packages/rs-platform-value/src/eq.rs @@ -214,6 +214,82 @@ impl Value { _ => self == other, } } + + /// Returns `true` when two single values hold the same data: + /// [`equal_underlying_data`](Self::equal_underlying_data) for values that + /// are not containers, with floats compared by their bits. + /// + /// * All "bytes-like" variants (`Bytes`, `Bytes20`, `Bytes32`, `Bytes36`, + /// `Identifier`) compare equal when their byte sequences match, so an + /// identifier equals the same 32 bytes carried as `Bytes32` or `Bytes`. + /// `Text` is not bytes-like: a string never equals bytes. + /// * All integer variants (`U*`, `I*`) compare equal when they represent + /// the same numeric value, whatever width each is carried at. A `U128` + /// past `i128::MAX` equals only the same `U128`. + /// * When either side is a `Float`, both sides are read as an `f64` and + /// compared by bits (`f64::to_bits`), not with `==`: `-0.0` is not + /// `0.0`, as their document tree keys are not, and a NaN equals a NaN + /// of the same bits, itself included. This is bit equality, not tree + /// key equality: the tree key encoding maps a few distinct bit patterns + /// (a negative NaN and a negative subnormal) to one key. + /// An integer on the other side is read as the `f64` it converts to, as + /// [`as_float`](Self::as_float) reads it: a document stores `date` and + /// `number` properties as floats while a transition may carry one as an + /// integer. Past 2^53 an integer rounds to the nearest `f64`, as it does + /// when stored, so the comparison is not transitive there: `2^53 + 1` + /// and `2^53` both equal the float `2^53` but not each other. + /// * An `Array` whose every element is a `U8` holds the bytes it lists, as + /// [`to_identifier_bytes`](Self::to_identifier_bytes) reads it, so it + /// equals the same bytes carried as bytes, an identifier, or another + /// such array: a transition may carry an identifier or a byte array + /// that way. Any other `Array`, and a `Map` on either side, is not a + /// single value and never compares equal, not even to an equal + /// container. + /// * Otherwise the two must be the same variant and compare with `==`: + /// two `Null`s are equal and a `Null` equals nothing else, text compares + /// as text (so `""` is not `"\0"`, though a tree key encodes both as + /// `[0]`), and booleans as booleans. + /// + /// A value of any size compares: two equal strings or byte arrays longer + /// than the 255 bytes a tree key holds are equal. + /// + /// From protocol version 14, document reference validation 0 judges each + /// `propertyAgreement` pair of a `refersTo` reference with it. A change to + /// its result for some pair of values changes which documents are + /// accepted; make such a change a new method instead of editing this one. + pub fn same_scalar_data(&self, other: &Value) -> bool { + /// The bytes `value` holds: a bytes-like variant's, or those an array + /// of `U8`s lists. `None` for anything else. + fn held_bytes(value: &Value) -> Option> { + match value { + Value::Array(items) => items + .iter() + .map(|item| match item { + Value::U8(byte) => Some(*byte), + _ => None, + }) + .collect(), + _ => value.as_bytes_slice().ok().map(<[u8]>::to_vec), + } + } + + match (self, other) { + // 1) an array is a single value only as the bytes it lists + (Value::Array(_), _) | (_, Value::Array(_)) => matches!( + (held_bytes(self), held_bytes(other)), + (Some(this), Some(that)) if this == that + ), + // 2) a map is no single value + (Value::Map(_), _) | (_, Value::Map(_)) => false, + // 3) floats by bits, an integer read as the float it converts to + (Value::Float(_), _) | (_, Value::Float(_)) => matches!( + (self.as_float(), other.as_float()), + (Some(this), Some(that)) if this.to_bits() == that.to_bits() + ), + // 4) bytes by bytes, integers by value, anything else by `==` + _ => self.equal_underlying_data(other), + } + } } #[cfg(test)] @@ -328,6 +404,204 @@ mod underlying_data_tests { } } +#[cfg(test)] +mod same_scalar_data_tests { + use crate::Value; + + /// Compares both ways, checking the two directions agree. + fn same(left: &Value, right: &Value) -> bool { + let forward = left.same_scalar_data(right); + assert_eq!( + forward, + right.same_scalar_data(left), + "{left:?} and {right:?} compare differently each way" + ); + forward + } + + /// A tree key encodes both as `[0]`; as values they differ. + #[test] + fn should_not_equate_the_empty_string_with_a_nul_character() { + let empty = Value::Text(String::new()); + let nul = Value::Text("\0".to_string()); + + assert!(!same(&empty, &nul)); + assert!(same(&empty, &empty.clone())); + assert!(same(&nul, &nul.clone())); + } + + /// A transition may carry an integer at another width than the stored + /// document decodes it at. + #[test] + fn should_equate_the_same_integer_carried_at_different_widths() { + assert!(same(&Value::U64(513), &Value::U16(513))); + assert!(same(&Value::I64(-7), &Value::I32(-7))); + assert!(same(&Value::U8(100), &Value::I128(100))); + assert!(!same(&Value::U64(514), &Value::U16(513))); + assert!(!same(&Value::I8(-1), &Value::U64(255))); + } + + #[test] + fn should_equate_a_u128_past_i128_only_with_the_same_u128() { + let past_i128 = u128::MAX; + + assert!(same(&Value::U128(past_i128), &Value::U128(past_i128))); + assert!(!same(&Value::U128(past_i128), &Value::U128(past_i128 - 1))); + assert!(!same( + &Value::U128(i128::MAX as u128 + 1), + &Value::I128(i128::MAX) + )); + assert!(!same(&Value::U128(past_i128), &Value::U64(u64::MAX))); + assert!(same( + &Value::U128(i128::MAX as u128), + &Value::I128(i128::MAX) + )); + } + + #[test] + fn should_equate_an_identifier_with_the_same_32_bytes() { + let id = [7u8; 32]; + + assert!(same(&Value::Identifier(id), &Value::Bytes32(id))); + assert!(same(&Value::Bytes(id.to_vec()), &Value::Identifier(id))); + assert!(!same(&Value::Identifier(id), &Value::Identifier([8u8; 32]))); + assert!(!same(&Value::Identifier(id), &Value::Bytes(vec![7u8; 31]))); + } + + /// No tree key holds more than 255 bytes; a value of any size compares. + #[test] + fn should_equate_equal_strings_and_byte_arrays_longer_than_a_tree_key() { + let long_ascii = Value::Text("a".repeat(256)); + // 280 bytes of UTF-8 in 70 characters + let long_emoji = Value::Text("\u{1F600}".repeat(70)); + + assert!(same(&long_ascii, &long_ascii.clone())); + assert!(same(&long_emoji, &long_emoji.clone())); + assert!(!same(&long_ascii, &Value::Text("a".repeat(257)))); + assert!(same( + &Value::Bytes(vec![9; 300]), + &Value::Bytes(vec![9; 300]) + )); + assert!(!same( + &Value::Bytes(vec![9; 300]), + &Value::Bytes(vec![9; 301]) + )); + } + + /// Floats compare by bits, where `equal_underlying_data` compares them + /// with `==`. + #[test] + fn should_compare_floats_by_their_bits() { + let quiet_nan = Value::Float(f64::NAN); + let other_nan = Value::Float(f64::from_bits(f64::NAN.to_bits() | 1)); + + assert!(!same(&Value::Float(-0.0), &Value::Float(0.0))); + assert!(Value::Float(-0.0).equal_underlying_data(&Value::Float(0.0))); + assert!(same(&Value::Float(1.5), &Value::Float(1.5))); + assert!(!same(&Value::Float(1.5), &Value::Float(2.5))); + assert!(same(&quiet_nan, &quiet_nan.clone())); + assert!(!quiet_nan.equal_underlying_data(&quiet_nan.clone())); + assert!(!same(&quiet_nan, &other_nan)); + } + + /// A document stores a date as a float; a transition may carry it as an + /// integer. + #[test] + fn should_read_an_integer_against_a_float_as_the_float_it_converts_to() { + assert!(same(&Value::U64(1_700_000_000_000), &Value::Float(1.7e12))); + assert!(!same(&Value::U64(1_700_000_000_001), &Value::Float(1.7e12))); + assert!(same(&Value::I32(-3), &Value::Float(-3.0))); + assert!(same(&Value::U64(0), &Value::Float(0.0))); + assert!(!same(&Value::U64(0), &Value::Float(-0.0))); + assert!(!same(&Value::Text("1".to_string()), &Value::Float(1.0))); + assert!(!same(&Value::Bool(true), &Value::Float(1.0))); + } + + /// Past 2^53 an integer rounds to the nearest `f64`, as storage rounds + /// it, so two integers that differ can both equal one float. + #[test] + fn should_round_an_integer_past_2_pow_53_to_the_nearest_float() { + let two_pow_53 = 1u64 << 53; + let float_two_pow_53 = Value::Float(two_pow_53 as f64); + + assert!(same(&Value::U64(two_pow_53 + 1), &float_two_pow_53)); + assert!(same(&Value::U64(two_pow_53), &float_two_pow_53)); + assert!(!same(&Value::U64(two_pow_53 + 1), &Value::U64(two_pow_53))); + + assert!(same( + &Value::I128(i128::MIN), + &Value::Float(-(2.0f64.powi(127))) + )); + assert!(same( + &Value::U128(u128::MAX), + &Value::Float(2.0f64.powi(128)) + )); + assert!(!same(&Value::U128(u128::MAX), &Value::Float(f64::INFINITY))); + } + + #[test] + fn should_never_equate_a_map_or_an_array_of_anything_but_bytes() { + let empty_map = Value::Map(vec![]); + let one_entry_map = Value::Map(vec![(Value::Text("a".into()), Value::U8(1))]); + let wide_integers = Value::Array(vec![Value::U64(1), Value::U64(2)]); + let texts = Value::Array(vec![Value::Text("a".into())]); + + assert!(!same(&empty_map, &empty_map.clone())); + assert!(!same(&one_entry_map, &one_entry_map.clone())); + assert!(one_entry_map.equal_underlying_data(&one_entry_map.clone())); + assert!(!same(&wide_integers, &wide_integers.clone())); + assert!(!same(&wide_integers, &Value::Bytes(vec![1, 2]))); + assert!(!same(&texts, &texts.clone())); + assert!(!same(&empty_map, &Value::Null)); + assert!(!same(&empty_map, &Value::Array(vec![]))); + } + + /// A transition may carry an identifier or a byte array as an array of + /// `U8`s, which `to_identifier_bytes` reads as those bytes. + #[test] + fn should_read_an_array_of_u8_as_the_bytes_it_lists() { + let id = [7u8; 32]; + let listed = Value::Array(id.iter().copied().map(Value::U8).collect()); + + assert!(same(&listed, &Value::Identifier(id))); + assert!(same(&listed, &Value::Bytes32(id))); + assert!(same(&listed, &Value::Bytes(id.to_vec()))); + assert!(same(&listed, &listed.clone())); + assert!(same(&Value::Array(vec![]), &Value::Bytes(vec![]))); + assert!(!same(&listed, &Value::Identifier([8u8; 32]))); + assert!(!same(&Value::Array(vec![Value::U8(1)]), &Value::Float(1.0))); + assert!(!same(&Value::Array(vec![Value::U8(1)]), &Value::U8(1))); + assert!(!same( + &Value::Array(vec![Value::U8(97)]), + &Value::Text("a".into()) + )); + assert!(!same(&Value::Array(vec![Value::U8(1)]), &Value::Null)); + } + + #[test] + fn should_equate_two_nulls_and_nothing_else_with_a_null() { + assert!(same(&Value::Null, &Value::Null)); + assert!(!same(&Value::Null, &Value::Text(String::new()))); + assert!(!same(&Value::Null, &Value::Bytes(vec![]))); + assert!(!same(&Value::Null, &Value::U64(0))); + assert!(!same(&Value::Null, &Value::Bool(false))); + assert!(!same(&Value::Null, &Value::Float(0.0))); + } + + #[test] + fn should_not_equate_values_of_different_kinds() { + assert!(!same(&Value::Text("1".to_string()), &Value::U64(1))); + assert!(!same(&Value::Bool(true), &Value::U8(1))); + assert!(!same(&Value::U64(1), &Value::Bool(true))); + assert!(!same( + &Value::Text("abc".to_string()), + &Value::Bytes(b"abc".to_vec()) + )); + assert!(same(&Value::Bool(true), &Value::Bool(true))); + assert!(!same(&Value::Bool(true), &Value::Bool(false))); + } +} + #[cfg(test)] #[allow(clippy::approx_constant)] mod tests { diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 5aee58db46e..5aab06f9cf2 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1439,6 +1439,19 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// preallocates nothing for a referenced value wider than the referring /// property can hold, which no referring document can agree with. /// +/// 56. **A `propertyAgreement` pair compares values, not index keys**: +/// document reference validation 0 judges each pair as two single values +/// (`Value::same_scalar_data`): strings as text, byte arrays and +/// identifiers as bytes, integers as numbers at any width, floats by +/// their `f64` bits (an integer against a float read as the float it +/// converts to, as a `number` carried as an integer is stored), booleans +/// as booleans. An identifier or byte array carried as an array of +/// `U8`s is the bytes it lists, as before; any other array agrees with +/// nothing. It compared the two sides' index key encodings, under which +/// `""` agreed with `"\0"`, and two equal values over 255 bytes, which +/// an unindexed string of 64 characters or more can hold, were refused +/// (`ReferencedDocumentPropertyMismatchError`, 40127). +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's From 1464314a7a0a543fccda21bed6bcd4521f0eda69 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 29 Sep 2026 01:16:56 +0700 Subject: [PATCH 104/113] fix(dpp)!: refuse a dotted property path in sum and average keywords (PV14) (#5104) Co-authored-by: Claude Opus 5.5 --- .../document/v3/document-meta.json | 14 +- .../v3/dotted_aggregate_name_tests.rs | 174 ++++++++++++++ .../class_methods/try_from_schema/v3/mod.rs | 2 + .../contract_structure_error_tests.rs | 224 +++++++++++++++++- .../rs-platform-version/src/version/v14.rs | 16 ++ 5 files changed, 415 insertions(+), 15 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dotted_aggregate_name_tests.rs diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 31114081bd8..dd3a8341e36 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's properties, comparisons between integer expressions, of a string or identifier property with constants or with another property of its kind, in (value membership) and present or absent tests combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the generatedFrom property keyword (a string property whose value a built-in function generates from other properties of the same document, on arrival when a document leaves it out), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's properties, comparisons between integer expressions, of a string or identifier property with constants or with another property of its kind, in (value membership) and present or absent tests combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the generatedFrom property keyword (a string property whose value a built-in function generates from other properties of the same document, on arrival when a document leaves it out), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), refuses a dotted path in summable, averageable, documentsSummable and documentsAverageable, whose value is read from the top level of the document (the same word-character pattern; no contract on mainnet or testnet names one), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "referenceOperands": { @@ -1793,7 +1793,8 @@ "type": "string", "minLength": 1, "maxLength": 64, - "description": "Name of an integer document property whose values are aggregated into a sum at the index. When set, the index's value trees become SumTrees and each per-document index reference is a ReferenceWithSumItem contributing the named property's value to ancestor sum-bearing trees. The property must exist on the document type, be in `required`, and have an integer type." + "pattern": "^[a-zA-Z0-9_]{1,64}$", + "description": "Name of an integer document property whose values are aggregated into a sum at the index. When set, the index's value trees become SumTrees and each per-document index reference is a ReferenceWithSumItem contributing the named property's value to ancestor sum-bearing trees. The property must exist on the document type, be in `required`, and have an integer type. It must be a top-level property: the value is read from the top level of the document, so a dotted path to a property nested in an object is refused (from protocol version 14)." }, "rangeSummable": { "type": "boolean", @@ -1803,7 +1804,8 @@ "type": "string", "minLength": 1, "maxLength": 64, - "description": "Syntactic sugar: `averageable: \"\"` is shorthand for `countable: \"countable\"` + `summable: \"\"`. Enables average queries (which return `(count, sum)` pairs the client divides) without forcing authors to think in terms of count + sum. Same on-disk layout as setting both underlying flags. If you set both `averageable` and `summable`, they must name the same property." + "pattern": "^[a-zA-Z0-9_]{1,64}$", + "description": "Syntactic sugar: `averageable: \"\"` is shorthand for `countable: \"countable\"` + `summable: \"\"`. Enables average queries (which return `(count, sum)` pairs the client divides) without forcing authors to think in terms of count + sum. Same on-disk layout as setting both underlying flags. If you set both `averageable` and `summable`, they must name the same property. Like `summable`, it names a top-level property, never a dotted path (from protocol version 14)." }, "rangeAverageable": { "type": "boolean", @@ -2056,7 +2058,8 @@ "type": "string", "minLength": 1, "maxLength": 64, - "description": "Name of an integer document property aggregated into the primary-key SumTree (one sum per document type). Stores documents as `ItemWithSumItem` so the primary-key tree's root sum is the total of the named property across all docs of this type. Property must exist on the document type, be in `required`, and have an integer type. Composes with `documentsKeepHistory: true` — keep-history doctypes get a `SumTree` per-document subtree with a `ReferenceWithSumItem` on the `0`-key carrying the current version's value, so the doctype-level root aggregate reflects current versions only (historical versions don't double-count)." + "pattern": "^[a-zA-Z0-9_]{1,64}$", + "description": "Name of an integer document property aggregated into the primary-key SumTree (one sum per document type). Stores documents as `ItemWithSumItem` so the primary-key tree's root sum is the total of the named property across all docs of this type. Property must exist on the document type, be in `required`, and have an integer type. It must be a top-level property: the value is read from the top level of the document, so a dotted path to a property nested in an object is refused (from protocol version 14). Composes with `documentsKeepHistory: true` — keep-history doctypes get a `SumTree` per-document subtree with a `ReferenceWithSumItem` on the `0`-key carrying the current version's value, so the doctype-level root aggregate reflects current versions only (historical versions don't double-count)." }, "rangeSummable": { "type": "boolean", @@ -2066,7 +2069,8 @@ "type": "string", "minLength": 1, "maxLength": 64, - "description": "Syntactic sugar: `documentsAverageable: \"\"` is shorthand for `documentsCountable: true` + `documentsSummable: \"\"`. Enables doctype-wide average queries (returns `(count, sum)` the client divides) without authors having to compose the count + sum flags. Same on-disk layout. If you set both `documentsAverageable` and `documentsSummable`, they must name the same property. Composes with `documentsKeepHistory: true` via the per-doc SumTree + ReferenceWithSumItem layout described under `documentsSummable`." + "pattern": "^[a-zA-Z0-9_]{1,64}$", + "description": "Syntactic sugar: `documentsAverageable: \"\"` is shorthand for `documentsCountable: true` + `documentsSummable: \"\"`. Enables doctype-wide average queries (returns `(count, sum)` the client divides) without authors having to compose the count + sum flags. Same on-disk layout. If you set both `documentsAverageable` and `documentsSummable`, they must name the same property. Like `documentsSummable`, it names a top-level property, never a dotted path (from protocol version 14). Composes with `documentsKeepHistory: true` via the per-doc SumTree + ReferenceWithSumItem layout described under `documentsSummable`." }, "rangeAverageable": { "type": "boolean", diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dotted_aggregate_name_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dotted_aggregate_name_tests.rs new file mode 100644 index 00000000000..d4dd148b5f8 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dotted_aggregate_name_tests.rs @@ -0,0 +1,174 @@ +//! The property an aggregate keyword names is a top-level one. +//! +//! `summable` and `averageable` on an index, and `documentsSummable` and +//! `documentsAverageable` on the document type, name the integer property each +//! document contributes to the sum. Drive reads that value from the top level +//! of the document, while the parser resolves the name in the flattened +//! properties and the required fields, which also hold the dotted path of a +//! property nested in an object. A dotted name therefore registered, and then +//! every document create of the type failed. Meta-schema v3 (protocol version +//! 14) refuses the dot at registration. Meta-schema v2 (protocol version 13) +//! keeps admitting it, and a stored contract, parsed without full validation, +//! keeps loading. + +use super::immutable_tests::parse_dispatched; +use super::*; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; +use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; +use crate::data_contract::DataContract; +use crate::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureTrusted, + PlatformSerializableWithPlatformVersion, +}; +use platform_value::string_encoding::Encoding; +use serde_json::json; + +/// The dotted path of `amount`, an integer in the `payment` object. +const DOTTED: &str = "payment.amount"; + +/// A document type with a required `payment` object holding a required integer +/// `amount`, a string `label` to index, and `keys` set at its top level. +fn schema_with(keys: serde_json::Value) -> serde_json::Value { + let mut schema = json!({ + "type": "object", + "properties": { + "payment": { + "type": "object", + "properties": { + "amount": {"type": "integer", "minimum": 0, "maximum": 1000, "position": 0} + }, + "required": ["amount"], + "additionalProperties": false, + "position": 0 + }, + "label": {"type": "string", "maxLength": 20, "position": 1} + }, + "required": ["payment", "label"], + "additionalProperties": false + }); + for (key, value) in keys.as_object().expect("the keys are an object") { + schema[key] = value.clone(); + } + schema +} + +/// Each aggregate keyword naming `name`, with the path of the keyword in the +/// document type schema. +fn each_aggregate_keyword(name: &str) -> Vec<(serde_json::Value, &'static str)> { + let index_with = |keyword: &str| { + json!({ + "indices": [ + {"name": "byLabel", "properties": [{"label": "asc"}], keyword: name} + ] + }) + }; + vec![ + (index_with("summable"), "/indices/0/summable"), + (index_with("averageable"), "/indices/0/averageable"), + (json!({ "documentsSummable": name }), "/documentsSummable"), + ( + json!({ "documentsAverageable": name }), + "/documentsAverageable", + ), + ] +} + +fn to_value(schema: serde_json::Value) -> Value { + platform_value::to_value(schema).expect("the schema converts") +} + +/// The name each keyword resolved to on a parsed document type. +fn aggregate_name( + document_type: &(impl DocumentTypeV0Getters + DocumentTypeV2Getters), +) -> Option { + document_type + .documents_summable() + .map(str::to_string) + .or_else(|| { + document_type + .indexes() + .values() + .find_map(|index| index.summable.clone()) + }) +} + +#[test] +fn should_refuse_a_dotted_name_in_each_aggregate_keyword_at_registration() { + for (keys, path) in each_aggregate_keyword(DOTTED) { + let result = parse_dispatched(to_value(schema_with(keys)), PlatformVersion::latest(), true); + match result { + Err(ProtocolError::ConsensusError(error)) => match *error { + ConsensusError::BasicError(BasicError::JsonSchemaError(error)) => { + assert_eq!(error.keyword(), "pattern", "{path}: {error}"); + assert_eq!(error.instance_path(), path, "{error}"); + } + other => panic!("{path}: expected a JSON schema error, got {other}"), + }, + other => panic!("{path}: expected a consensus error, got {other:?}"), + } + } +} + +#[test] +fn should_accept_a_top_level_name_in_each_aggregate_keyword_at_registration() { + for (keys, path) in each_aggregate_keyword("total") { + let mut schema = schema_with(keys); + schema["properties"]["total"] = + json!({"type": "integer", "minimum": 0, "maximum": 1000, "position": 2}); + schema["required"] = json!(["payment", "label", "total"]); + + let document_type = parse_dispatched(to_value(schema), PlatformVersion::latest(), true) + .unwrap_or_else(|error| panic!("{path}: {error:?}")); + assert_eq!(aggregate_name(&document_type).as_deref(), Some("total")); + } +} + +/// Meta-schema v2 shipped bounding only the name's length, and it keeps doing +/// so for every block of protocol version 13. +#[test] +fn should_keep_accepting_a_dotted_name_at_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13 exists"); + for (keys, path) in each_aggregate_keyword(DOTTED) { + let document_type = parse_dispatched(to_value(schema_with(keys)), platform_version, true) + .unwrap_or_else(|error| panic!("{path}: {error:?}")); + assert_eq!(aggregate_name(&document_type).as_deref(), Some(DOTTED)); + } +} + +/// Drive reads a stored contract without full validation, so the meta-schema +/// does not run on it, and a contract registered at protocol version 13 with a +/// dotted name keeps loading at protocol version 14. +#[test] +fn should_load_a_contract_registered_at_protocol_version_13_with_a_dotted_name_at_protocol_version_14( +) { + let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13 exists"); + for (keys, path) in each_aggregate_keyword(DOTTED) { + let contract = json!({ + "$formatVersion": "1", + "id": Identifier::from([7; 32]).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { "payment": schema_with(keys) } + }); + let registered = DataContract::from_value(to_value(contract), true, platform_version_13) + .unwrap_or_else(|error| panic!("{path}: {error:?}")); + let stored = registered + .serialize_to_bytes_with_platform_version(platform_version_13) + .expect("the contract serializes"); + + let loaded = + DataContract::versioned_deserialize_trusted(&stored, false, PlatformVersion::latest()) + .unwrap_or_else(|error| panic!("{path}: {error:?}")); + let document_type = loaded + .document_type_for_name("payment") + .expect("the payment type"); + assert_eq!( + aggregate_name(&document_type).as_deref(), + Some(DOTTED), + "{path}" + ); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 2fe80a58ced..6044a5d039b 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -1085,6 +1085,8 @@ impl DocumentType { #[cfg(test)] mod documents_ttl_tests; +#[cfg(all(test, feature = "validation"))] +mod dotted_aggregate_name_tests; #[cfg(test)] mod immutable_tests; #[cfg(test)] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs index f8654178c6b..d7c7d9d0fd9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/contract_structure_error_tests.rs @@ -1,5 +1,5 @@ -//! Registering a contract whose document type breaks a structural rule of the parser, through -//! `check_tx` and block processing. +//! Registering a contract whose document type breaks a structural rule of the parser or of the +//! document meta-schema, through `check_tx` and block processing. use crate::execution::validation::state_transition::state_transitions::data_contract_common::contract_structure_test_harness::{ assert_paid_contract_structure_error, assert_unpaid_internal_error, check_and_process, @@ -7,21 +7,45 @@ use crate::execution::validation::state_transition::state_transitions::data_cont SUMMED_U64_MESSAGE, TERMINAL_WITHOUT_INDEX_ONLY_MESSAGE, }; use crate::execution::validation::state_transition::state_transitions::tests::setup_identity; -use crate::test::helpers::setup::TestPlatformBuilder; +use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; +use assert_matches::assert_matches; +use dpp::consensus::basic::BasicError; +use dpp::consensus::ConsensusError; use dpp::dash_to_credits; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::DataContract; use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_nonce::IDENTITY_NONCE_VALUE_FILTER; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dpp::platform_value::Value; +use dpp::identity::{Identity, IdentityPublicKey}; +use dpp::platform_value::{platform_value, Value}; +use dpp::serialization::PlatformSerializable; +use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dpp::state_transition::batch_transition::BatchTransition; use dpp::state_transition::data_contract_create_transition::methods::DataContractCreateTransitionMethodsV0; use dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; use dpp::tests::fixtures::get_data_contract_fixture; use platform_version::version::{PlatformVersion, ProtocolVersion}; +use simple_signer::signer::SimpleSigner; + +/// A registration through `check_tx` and one block, with the platform it ran on and the owner of +/// the contract, so a test can go on to use the contract. +struct Registration { + platform: TempPlatform, + owner: Identity, + signer: SimpleSigner, + key: IdentityPublicKey, + outcome: Outcome, +} /// Registers a contract whose only document type is `item`, with `schema`. async fn register_contract_with_schema( schema: Value, protocol_version: ProtocolVersion, -) -> Outcome { +) -> Registration { let platform_version = PlatformVersion::get(protocol_version).expect("expected the protocol version"); let mut platform = TestPlatformBuilder::new() @@ -58,7 +82,7 @@ async fn register_contract_with_schema( .await; let owner_id = identity.id(); - check_and_process( + let outcome = check_and_process( &platform, owner_id, transition_bytes, @@ -69,7 +93,14 @@ async fn register_contract_with_schema( .expect("expected to fetch the identity nonce") }, platform_version, - ) + ); + Registration { + platform, + owner: identity, + signer, + key, + outcome, + } } #[tokio::test] @@ -78,7 +109,8 @@ async fn should_refuse_a_summed_u64_property_with_a_paid_consensus_error() { summed_u64_schema(), PlatformVersion::latest().protocol_version, ) - .await; + .await + .outcome; assert_eq!(outcome.nonce_before, Some(0)); assert_paid_contract_structure_error(&outcome, SUMMED_U64_MESSAGE, 1); @@ -90,7 +122,8 @@ async fn should_refuse_a_terminal_outside_an_index_only_type_with_a_paid_consens terminal_without_index_only_schema(), PlatformVersion::latest().protocol_version, ) - .await; + .await + .outcome; assert_eq!(outcome.nonce_before, Some(0)); assert_paid_contract_structure_error(&outcome, TERMINAL_WITHOUT_INDEX_ONLY_MESSAGE, 1); @@ -98,7 +131,178 @@ async fn should_refuse_a_terminal_outside_an_index_only_type_with_a_paid_consens #[tokio::test] async fn should_keep_refusing_a_summed_u64_property_unpaid_at_protocol_version_13() { - let outcome = register_contract_with_schema(summed_u64_schema(), 13).await; + let outcome = register_contract_with_schema(summed_u64_schema(), 13) + .await + .outcome; assert_unpaid_internal_error(&outcome, SUMMED_U64_MESSAGE); } + +/// A document type summing `payment.amount` in an index: the dotted path of `amount`, an integer +/// in the required `payment` object. Drive reads a document's sum contribution from its top level, +/// where no property has that name. +fn dotted_summable_schema() -> Value { + platform_value!({ + "type": "object", + "properties": { + "payment": { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "minimum": 0, + "maximum": 1000, + "position": 0, + }, + }, + "required": ["amount"], + "additionalProperties": false, + "position": 0, + }, + "label": { + "type": "string", + "maxLength": 20, + "position": 1, + }, + }, + "required": ["payment", "label"], + "indices": [ + {"name": "byLabel", "properties": [{"label": "asc"}], "summable": "payment.amount"}, + ], + "additionalProperties": false, + }) +} + +/// The document meta-schema refuses the name. `check_tx` parses a contract without full +/// validation, so the meta-schema does not run there, and the block refuses the transition with +/// the meta-schema's error, charging the owner and bumping its nonce. +#[tokio::test] +async fn should_refuse_a_dotted_summable_name_with_a_paid_consensus_error() { + let outcome = register_contract_with_schema( + dotted_summable_schema(), + PlatformVersion::latest().protocol_version, + ) + .await + .outcome; + + assert_matches!( + outcome.check_tx.as_deref(), + Ok([]), + "check_tx: {:?}", + outcome.check_tx + ); + assert_matches!( + &outcome.block, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::JsonSchemaError(error)), + .. + } if error.keyword() == "pattern" && error.instance_path() == "/indices/0/summable", + "block: {:?}", + outcome.block + ); + assert_eq!(outcome.nonce_before, Some(0)); + // The stored nonce keeps the nonces skipped below it in its high bits. + assert_eq!( + outcome + .nonce_after + .map(|nonce| nonce & IDENTITY_NONCE_VALUE_FILTER), + Some(1), + "the rejection bumps the nonce" + ); + assert!( + outcome.balance_after < outcome.balance_before, + "the rejection is charged: {:?} -> {:?}", + outcome.balance_before, + outcome.balance_after + ); +} + +/// Meta-schema v2 bounds only the length of the name, so the contract still registers at +/// protocol version 13, and each document create of the type still fails in Drive, as an internal +/// error that leaves the owner untouched. +#[tokio::test] +async fn should_keep_registering_a_dotted_summable_name_at_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + let registration = register_contract_with_schema(dotted_summable_schema(), 13).await; + let outcome = ®istration.outcome; + + assert_matches!( + outcome.check_tx.as_deref(), + Ok([]), + "check_tx: {:?}", + outcome.check_tx + ); + assert_matches!( + &outcome.block, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "block: {:?}", + outcome.block + ); + + let owner_id = registration.owner.id(); + let contract_id = DataContract::generate_data_contract_id_v0(owner_id, 1); + let contract = registration + .platform + .drive + .fetch_contract(contract_id.to_buffer(), None, None, None, platform_version) + .value + .expect("expected to fetch the contract") + .expect("expected the contract to be registered"); + let item = contract + .contract + .document_type_for_name("item") + .expect("expected the item document type"); + + // The registration took the owner's first nonce on the contract + let identity_contract_nonce = 2; + let entropy = [9; 32]; + let mut document = item + .create_document_from_data( + platform_value!({"payment": {"amount": 5}, "label": "first"}), + owner_id, + 0, + 0, + entropy, + platform_version, + ) + .expect("expected to create the document"); + document + .set_id_for_creation(item, &entropy, identity_contract_nonce, platform_version) + .expect("expected to set the document id"); + let create_transition = BatchTransition::new_document_creation_transition_from_document( + document, + item, + entropy, + ®istration.key, + identity_contract_nonce, + 0, + None, + ®istration.signer, + platform_version, + None, + ) + .await + .expect("expected to create the document create transition"); + + let create_outcome = check_and_process( + ®istration.platform, + owner_id, + create_transition + .serialize_to_bytes() + .expect("expected to serialize the document create transition"), + |platform| { + platform + .drive + .fetch_identity_contract_nonce( + owner_id.to_buffer(), + contract_id.to_buffer(), + true, + None, + platform_version, + ) + .expect("expected to fetch the contract nonce") + }, + platform_version, + ); + assert_unpaid_internal_error(&create_outcome, "summable property absent"); +} diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 5aab06f9cf2..b4d2a4da5d2 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1426,6 +1426,22 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// extended in place and is inert before this version, where the three /// slots are `None` and the meta-schemas refuse the keyword. /// +/// 54. **An aggregate keyword names a top-level property**: `summable` and +/// `averageable` on an index, and `documentsSummable` and +/// `documentsAverageable` on a document type, name the integer property +/// each document adds to the sum. Drive reads its value from the top level +/// of the document, but the parser resolves the name among the flattened +/// properties and required fields, which also hold the dotted path of a +/// property nested in an object, and meta-schemas v1 and v2 bound only the +/// name's length. A contract naming `payment.amount` registered, and every +/// document create of the type then failed in Drive as an internal error. +/// Meta-schema v3 (`CONTRACT_VERSIONS_V6`) gives the four keywords the +/// property-name pattern `^[a-zA-Z0-9_]{1,64}$`, so a create or an update +/// carrying a dotted name is refused under full validation +/// (`JsonSchemaError`, 10101, paid in a block; `check_tx` does not fully +/// validate a contract). A contract stored with one still loads, since a +/// stored contract is parsed without full validation, but can no longer be +/// updated; no contract on mainnet or testnet names one. /// 55. **A preallocated index's agreement source fits a tree key**: contract /// create and update state validation 1 refuse, paid, a /// `propertyAgreement` pair through which a preallocated index is keyed From 201c2f9c219ca2759f93dfbbbbbad881acd470ae Mon Sep 17 00:00:00 2001 From: infraclaw-dash <283232465+infraclaw-dash@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:41:53 +0000 Subject: [PATCH 105/113] ci: restore legacy runner image template compatibility --- .github/workflows/runner-image-candidate.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/runner-image-candidate.yml b/.github/workflows/runner-image-candidate.yml index 86ff3d2d677..4188f707971 100644 --- a/.github/workflows/runner-image-candidate.yml +++ b/.github/workflows/runner-image-candidate.yml @@ -23,10 +23,10 @@ jobs: if: >- (github.event.action != 'closed' && !github.event.pull_request.draft) || (github.event.action == 'closed' && github.event.pull_request.merged) - uses: dashpay/dash-selfhosted-image/.github/workflows/platform-candidate.yml@07811cd919f6956ba9c6d69a3a1bff4550eb3761 + uses: dashpay/dash-selfhosted-image/.github/workflows/platform-candidate.yml@aaea7df12716c386db223ea67a853b6b0efbd45a with: pull_request: ${{ github.event.pull_request.number }} - control_revision: 07811cd919f6956ba9c6d69a3a1bff4550eb3761 + control_revision: aaea7df12716c386db223ea67a853b6b0efbd45a mode: ${{ github.event.action == 'closed' && 'promote' || 'candidate' }} secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} From 9433a95296600c5261a13ea9ef15ff23e74b97a7 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 28 Sep 2026 14:51:06 -0500 Subject: [PATCH 106/113] ci(wasm-sdk): inline Binaryen thread cap as a bash step Replace the Python launcher and its tests with a few lines in the existing build step: cap BINARYEN_CORES at min(nproc, cgroup v2 cpu.max quota), keep any runner-provided value, log it, and time `yarn build` with the exit status preserved. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/actions/npm-release-build/action.yaml | 13 +++- .github/scripts/run-npm-build.py | 64 ---------------- .../scripts/tests/test_npm_build_budget.py | 75 ------------------- 3 files changed, 12 insertions(+), 140 deletions(-) delete mode 100644 .github/scripts/run-npm-build.py delete mode 100644 .github/scripts/tests/test_npm_build_budget.py diff --git a/.github/actions/npm-release-build/action.yaml b/.github/actions/npm-release-build/action.yaml index a7d9151bc5e..24244729dd1 100644 --- a/.github/actions/npm-release-build/action.yaml +++ b/.github/actions/npm-release-build/action.yaml @@ -95,9 +95,20 @@ runs: tar -xzf "$ARCHIVE" -C "$RUNNER_TEMP" echo "$RUNNER_TEMP/binaryen-version_121/bin" >> "$GITHUB_PATH" + # Binaryen sizes its thread pool from the host CPU count; inside a Docker + # CPU quota that oversubscribes and spins on futexes. Cap it at the quota + # (nproc in Ubuntu 24.04's coreutils honors affinity but not cpu.max). + # A runner-provided BINARYEN_CORES still wins. - name: Build packages shell: bash - run: python3 .github/scripts/run-npm-build.py + run: | + cores=$(nproc) + if read -r quota period 2>/dev/null < /sys/fs/cgroup/cpu.max && [ "$quota" != max ]; then + cores=$(( quota / period < cores ? quota / period : cores )) + fi + export BINARYEN_CORES="${BINARYEN_CORES:-$(( cores > 0 ? cores : 1 ))}" + echo "::notice::Binaryen threads: $BINARYEN_CORES" + time yarn build env: CARGO_BUILD_PROFILE: release diff --git a/.github/scripts/run-npm-build.py b/.github/scripts/run-npm-build.py deleted file mode 100644 index 6d78990163e..00000000000 --- a/.github/scripts/run-npm-build.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 -"""Bound Binaryen's thread pool without changing package build commands/passes.""" -import os -from pathlib import Path -import re -import sys - - -def cpu_budget(cgroup=Path('/sys/fs/cgroup'), affinity=None): - """Container CPU-time quota and affinity, not the host's hardware count.""" - if affinity is None: - affinity = len(os.sched_getaffinity(0)) - limits = [affinity] - maximum = cgroup / 'cpu.max' - if maximum.exists(): - quota, period = maximum.read_text().split() - if quota != 'max': - limits.append(max(1, int(quota) // int(period))) - else: - # Docker cgroup v1 may mount the cpu controller under either name. - for controller in (cgroup / 'cpu', cgroup / 'cpu,cpuacct'): - quota_file = controller / 'cpu.cfs_quota_us' - if quota_file.exists(): - quota = int(quota_file.read_text()) - period = int((controller / 'cpu.cfs_period_us').read_text()) - if quota > 0: - limits.append(max(1, quota // period)) - break - return min(limits) - - -def build_environment(environment, budget): - env = dict(environment) - # Compile jobs can be memory-limited independently of the optimizer pool. - # Preserve an explicit measured Binaryen setting supplied by the runner. - requested = env.get('BINARYEN_CORES', str(budget)) - if not re.fullmatch(r'[1-9][0-9]*', requested): - raise ValueError('BINARYEN_CORES must be a positive integer') - if int(requested) > budget: - raise ValueError('BINARYEN_CORES exceeds the container CPU budget') - env['BINARYEN_CORES'] = requested - return env - - -def main(): - try: - budget = cpu_budget() - env = build_environment(os.environ, budget) - except (OSError, ValueError, ZeroDivisionError) as error: - print(f'::error::Cannot select WASM build thread budget: {error}', file=sys.stderr) - return 2 - print(f'::notice::NPM build: CPU budget={budget}, ' - f'Binaryen threads={env["BINARYEN_CORES"]}; Cargo configuration unchanged', - flush=True) - # Bash is already required by this CI action. Its time keyword needs no - # additional package and preserves yarn's exit status. Cancellation remains - # the runner's existing process-tree cleanup responsibility. - # The command and all optimization flags remain unchanged. - env['TIMEFORMAT'] = 'NPM build timing: wall=%3R user=%3U system=%3S seconds' - os.execve('/bin/bash', ['bash', '-c', 'time yarn build'], env) - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/.github/scripts/tests/test_npm_build_budget.py b/.github/scripts/tests/test_npm_build_budget.py deleted file mode 100644 index 48e569347ea..00000000000 --- a/.github/scripts/tests/test_npm_build_budget.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Exercise CPU discovery and the real build launcher without compiling source.""" -import importlib.util -import json -import os -from pathlib import Path -import subprocess -import tempfile -import unittest - -SCRIPT = Path(__file__).resolve().parents[1] / 'run-npm-build.py' -SPEC = importlib.util.spec_from_file_location('npm_build', SCRIPT) -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -class NpmBuildBudgetTests(unittest.TestCase): - def test_should_honor_quota_and_affinity(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - for maximum, affinity, expected in [('2000000 100000', 32, 20), - ('2000000 100000', 8, 8), - ('max 100000', 6, 6), - ('150000 100000', 8, 1)]: - with self.subTest(maximum=maximum, affinity=affinity): - (root / 'cpu.max').write_text(maximum) - self.assertEqual(MODULE.cpu_budget(root, affinity), expected) - - def test_should_support_legacy_quota_and_no_quota(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - self.assertEqual(MODULE.cpu_budget(root, 8), 8) - controller = root / 'cpu,cpuacct' - controller.mkdir() - (controller / 'cpu.cfs_period_us').write_text('100000') - (controller / 'cpu.cfs_quota_us').write_text('400000') - self.assertEqual(MODULE.cpu_budget(root, 32), 4) - (controller / 'cpu.cfs_quota_us').write_text('-1') - self.assertEqual(MODULE.cpu_budget(root, 32), 32) - - def test_should_preserve_separate_compile_and_optimizer_budgets(self): - original = {'CARGO_BUILD_JOBS': '20', 'BINARYEN_CORES': '4', 'RUSTFLAGS': 'unchanged'} - self.assertEqual(MODULE.build_environment(original, 20), original) - self.assertEqual(MODULE.build_environment({'CARGO_BUILD_JOBS': '2'}, 8), - {'CARGO_BUILD_JOBS': '2', 'BINARYEN_CORES': '8'}) - - def test_should_reject_invalid_or_excessive_thread_count(self): - for value in ['', '0', '-1', '1.5', 'auto', '21', '4; false']: - with self.subTest(value=value), self.assertRaises(ValueError): - MODULE.build_environment({'BINARYEN_CORES': value}, 20) - - def test_should_preserve_command_environment_and_failure_exit_status(self): - # Use an executable scratch directory: some CI workspaces mount /tmp noexec. - with tempfile.TemporaryDirectory(dir=Path.cwd()) as directory: - root = Path(directory) - yarn = root / 'yarn' - yarn.write_text('#!/usr/bin/env python3\nimport json, os, sys\n' - 'print(json.dumps({"args":sys.argv[1:], ' - '"cores":os.environ["BINARYEN_CORES"], ' - '"cargo":os.environ["CARGO_BUILD_JOBS"]}))\n' - 'sys.exit(int(os.environ["BUILD_TEST_EXIT"]))\n') - yarn.chmod(0o700) - for code in [0, 23]: - env = dict(os.environ, PATH=str(root) + ':' + os.environ['PATH'], - BINARYEN_CORES='1', CARGO_BUILD_JOBS='2', BUILD_TEST_EXIT=str(code)) - result = subprocess.run(['python3', str(SCRIPT)], env=env, - capture_output=True, text=True) - self.assertEqual(result.returncode, code, result.stderr) - payload = json.loads(next(line for line in result.stdout.splitlines() - if line.startswith('{'))) - self.assertEqual(payload, {'args': ['build'], 'cores': '1', 'cargo': '2'}) - self.assertIn('NPM build timing: wall=', result.stderr) - - -if __name__ == '__main__': - unittest.main() From b66233d7e1a082fabab1712df20af3e5dc36056d Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 28 Sep 2026 14:59:19 -0500 Subject: [PATCH 107/113] ci(wasm-sdk): default Binaryen to four threads Four optimizer threads were measured ~3x faster than a host-sized pool with byte-identical output, which makes cgroup quota detection unnecessary. A runner-provided BINARYEN_CORES still takes precedence. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/actions/npm-release-build/action.yaml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/actions/npm-release-build/action.yaml b/.github/actions/npm-release-build/action.yaml index 24244729dd1..6250df11fa2 100644 --- a/.github/actions/npm-release-build/action.yaml +++ b/.github/actions/npm-release-build/action.yaml @@ -95,18 +95,14 @@ runs: tar -xzf "$ARCHIVE" -C "$RUNNER_TEMP" echo "$RUNNER_TEMP/binaryen-version_121/bin" >> "$GITHUB_PATH" - # Binaryen sizes its thread pool from the host CPU count; inside a Docker - # CPU quota that oversubscribes and spins on futexes. Cap it at the quota - # (nproc in Ubuntu 24.04's coreutils honors affinity but not cpu.max). - # A runner-provided BINARYEN_CORES still wins. + # Binaryen otherwise sizes its thread pool from the host CPU count, which + # inside a Docker CPU quota oversubscribes and spins on futexes. Four + # threads measured ~3x faster with byte-identical output. Cargo's job + # budget is unaffected; a runner-provided BINARYEN_CORES still wins. - name: Build packages shell: bash run: | - cores=$(nproc) - if read -r quota period 2>/dev/null < /sys/fs/cgroup/cpu.max && [ "$quota" != max ]; then - cores=$(( quota / period < cores ? quota / period : cores )) - fi - export BINARYEN_CORES="${BINARYEN_CORES:-$(( cores > 0 ? cores : 1 ))}" + export BINARYEN_CORES="${BINARYEN_CORES:-4}" echo "::notice::Binaryen threads: $BINARYEN_CORES" time yarn build env: From 28064599062d99687b679875eec2273d89e2f854 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 28 Sep 2026 14:59:38 -0500 Subject: [PATCH 108/113] perf(wasm-sdk): optimize release WASM with a single -Oz pass The full-optimization recipe ran -Oz four times with --converge, --flatten/--rereloop and extra passes, and first probed 14 optional flags by optimizing the entire module once per flag. On wasm-sdk this took over an hour with a host-sized thread pool (16m41s at four threads). One standard -Oz pass with the same features and producer stripping takes 2m18s for +2.0% raw / +0.35% gzip size. Minimal (-O2) and no-optimization builds and the wasm-sdk optimized.wasm copy are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/scripts/build-wasm.sh | 96 ++++++++-------------------------- 1 file changed, 21 insertions(+), 75 deletions(-) diff --git a/packages/scripts/build-wasm.sh b/packages/scripts/build-wasm.sh index 507b468c2c7..b85674e097c 100755 --- a/packages/scripts/build-wasm.sh +++ b/packages/scripts/build-wasm.sh @@ -206,83 +206,29 @@ if [ "$OPT_LEVEL" != "none" ] && command -v wasm-opt &> /dev/null; then WASM_PATH="pkg/$WASM_FILE" + # A single standard -Oz pass: repeating it, --converge/--flatten, and + # probing optional passes against the full module cost over an hour on + # wasm-sdk for ~0.35% gzip size. if [ "$OPT_LEVEL" = "full" ]; then - # Check wasm-opt version to determine available options - WASM_OPT_VERSION=$(wasm-opt --version 2>/dev/null || echo "") - - # Core optimization flags that should work with most versions - CORE_FLAGS=( - --strip-producers - -Oz - --enable-bulk-memory - --enable-nontrapping-float-to-int - --flatten - --rereloop - -Oz - --converge - --vacuum - --merge-blocks - --simplify-locals - --remove-unused-brs - --remove-unused-module-elements - --remove-unused-names - -Oz - -Oz - ) - - # Additional flags to test for compatibility - OPTIONAL_FLAGS=( - "--code-folding" - "--const-hoisting" - "--dce" - "-tnh" - "--gsi" - "--inlining-optimizing" - "--optimize-added-constants" - "--optimize-casts" - "--optimize-instructions" - "--optimize-stack-ir" - "--remove-unused-types" - "--post-emscripten" - "--generate-global-effects" - "--abstract-type-refining" - ) - - # Test which optional flags are supported - SUPPORTED_FLAGS=() - for flag in "${OPTIONAL_FLAGS[@]}"; do - if wasm-opt "$flag" "$WASM_PATH" -o /dev/null 2>/dev/null; then - SUPPORTED_FLAGS+=("$flag") - else - echo "Note: $flag not supported by this wasm-opt version, skipping..." - fi - done - - # Run optimization with core flags and any supported optional flags - wasm-opt \ - "${CORE_FLAGS[@]}" \ - "${SUPPORTED_FLAGS[@]}" \ - "$WASM_PATH" \ - -o \ - "$WASM_PATH" - - # Create optimized version for wasm-sdk - if [ "$PACKAGE_NAME" = "wasm-sdk" ]; then - cp "$WASM_PATH" "pkg/optimized.wasm" - fi + OPT_FLAG=-Oz else - # Minimal optimization for development builds - # Explicitly enable features used by newer toolchains: - # - bulk memory (memory.copy) - # - non-trapping float-to-int (i32/i64.trunc_sat_fXX_[su]) - wasm-opt \ - --strip-producers \ - -O2 \ - --enable-bulk-memory \ - --enable-nontrapping-float-to-int \ - "$WASM_PATH" \ - -o \ - "$WASM_PATH" + OPT_FLAG=-O2 + fi + # Explicitly enable features used by newer toolchains: + # - bulk memory (memory.copy) + # - non-trapping float-to-int (i32/i64.trunc_sat_fXX_[su]) + wasm-opt \ + --strip-producers \ + "$OPT_FLAG" \ + --enable-bulk-memory \ + --enable-nontrapping-float-to-int \ + "$WASM_PATH" \ + -o \ + "$WASM_PATH" + + # Create optimized version for wasm-sdk + if [ "$OPT_LEVEL" = "full" ] && [ "$PACKAGE_NAME" = "wasm-sdk" ]; then + cp "$WASM_PATH" "pkg/optimized.wasm" fi else if [ "$OPT_LEVEL" != "none" ]; then From c795f81ce9b4623857079ae58f32a116c18feb8f Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 29 Sep 2026 03:31:38 +0700 Subject: [PATCH 109/113] feat(drive): compute a document type's GroveDB layout for the SDKs (#5153) Co-authored-by: Claude Opus 5.5 --- book/src/drive/grovedb-structure.md | 32 + packages/js-evo-sdk/README.md | 16 + packages/rs-drive/Cargo.toml | 8 + .../v2/mod.rs | 7 +- .../drive/document/index_level_tree_types.rs | 127 +- .../v2/mod.rs | 24 +- .../v2/mod.rs | 7 +- .../mod.rs | 15 +- .../rs-drive/src/drive/document/layout.rs | 1331 +++++++++++++++++ packages/rs-drive/src/drive/document/mod.rs | 13 +- .../drive/document/primary_key_tree_type.rs | 2 +- .../drive/document/ranked_index_tree_type.rs | 2 +- .../v1/mod.rs | 32 +- packages/rs-drive/src/fees/op.rs | 124 +- packages/wasm-dpp2/src/data_contract/model.rs | 6 + packages/wasm-sdk/src/document_type_layout.rs | 89 ++ packages/wasm-sdk/src/lib.rs | 1 + .../tests/unit/document-type-layout.spec.ts | 172 +++ 18 files changed, 1886 insertions(+), 122 deletions(-) create mode 100644 packages/rs-drive/src/drive/document/layout.rs create mode 100644 packages/wasm-sdk/src/document_type_layout.rs create mode 100644 packages/wasm-sdk/tests/unit/document-type-layout.spec.ts diff --git a/book/src/drive/grovedb-structure.md b/book/src/drive/grovedb-structure.md index 7a31a6f28a9..108373f8d31 100644 --- a/book/src/drive/grovedb-structure.md +++ b/book/src/drive/grovedb-structure.md @@ -181,6 +181,38 @@ shape is that the fixture writes the same keys in the same batches as the block pipeline does, since a Merk batch of several keys gives another tree than the same keys written one at a time. +## One document type's layout + +The description covers every document type at once, so its document layers +are templates: an index property, one of its values, the `[0]` where an index +ends, and which tree types each of them can be. Which of those a given +document type gets depends on its keywords (`unique`, `countable`, +`summable`, the ranked keys, `timeRange`, `indexOnly`, `documentsKeepHistory`, +...), and the rules that pick them live in the index walkers. + +`drive::document::layout::document_type_layout` applies those rules to one +document type and returns its concrete layout: the document type tree, the +documents by id, and for each index the property and value trees down to +where it ends, each with the tree or element type Drive writes, the +zero-contribution wrapper a continuation tree gets under an aggregating value +tree, the ranking axes of an indexed tree, the indexes that use the layer, +and conditions such as the tree a unique index falls back to when a value is +null. Each layer names the node of the description it is an instance of, so a +viewer can link to it. It needs only the contract, so it is compiled with the +`verify` feature as well, and the JavaScript SDKs expose it as +`documentTypeLayout(contract, documentTypeName, platformVersion)`. It follows +the v2 index walkers, so it refuses a platform version before 14. + +The tree types come from the functions the walkers call, and the element +choices the walkers make inline are held to them by +`should_lay_out_what_drive_writes`. It applies 19 test contracts (plain, +unique, compound, countable, summable, ranked and chained indexes, history, +time windows, indexOnly types with flat and preallocated indexes), inserts +documents, and fails on any element whose kind or wrapper the layout does not +predict, and on any layer of the layout Drive did not write. It does not cover +contested indexes, time windows with a `ttl`, or document updates and +deletes. + ## Pull requests that change the structure When a pull request changes `grovedb-structure.json`, the diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 76707484d0f..a3e1b0764a5 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -19,6 +19,7 @@ Evo SDK provides a high-level, strongly-typed interface for interacting with [Da - [Building a document create transition by hand](#building-a-document-create-transition-by-hand) - [Immutable properties (`immutable`)](#immutable-properties-immutable) - [Property constraints (`propertyConstraints`)](#property-constraints-propertyconstraints) +- [How a document type is stored (`documentTypeLayout`)](#how-a-document-type-is-stored-documenttypelayout) - [Chained queries (provable semi-join)](#chained-queries-provable-semi-join) - [Composite queries (a page plus its sub-queries)](#composite-queries-a-page-plus-its-sub-queries) - [Contributing](#contributing) @@ -457,6 +458,21 @@ if (broken) { Rules come back in name order, the order consensus checks them in; `contract.documentPropertyConstraints` maps every document type that declares rules to its list. The `PropertyConstraintCondition`, `PropertyConstraintExpression` and `PropertyConstraintEqualityOperand` types spell out the rule grammar, and `violation` is one of `NotMet`, `Overflow`, `DivisionByZero`, `NegativeExponent` or `NotAnInteger`, the reason consensus would report. +## How a document type is stored (`documentTypeLayout`) + +`documentTypeLayout(contract, documentTypeName, platformVersion)` returns the GroveDB layout of a document type as Drive writes it: the document type tree, the documents by id and, for each index, the property and value trees down to where the index ends. Each layer carries the tree or element type Drive writes there (a count or sum tree, a ranked indexed tree, a reference, an indexOnly item), the wrapper a continuation tree gets under an aggregating value tree, the indexes that use it, and conditions such as the tree a unique index falls back to when a value is null. It runs locally with Drive's own rules, those of protocol version 14 on (an earlier version is refused), so it needs no connection: + +```ts +import { documentTypeLayout, PlatformVersion } from '@dashevo/evo-sdk'; + +const { root } = documentTypeLayout(contract, 'review', new PlatformVersion(14)); +// root.children: the documents by id ([0]) and one tree per first index property; +// each node: { key, role, element, wrapper?, rankedAxes, indexes, notes, alternative?, children, structureNode } +// (wrapper and alternative are left out when there is none) +``` + +`structureNode` names the layer of Drive's GroveDB structure description it is an instance of, as the [GroveDB structure viewer](https://dashpay.github.io/grovedb-structure-viewer/) shows it (`#/`). + ## Chained queries (provable semi-join) A `refersTo: permanentDocument` declaration also lights up the read side: a **chained query** answers `SELECT * FROM post WHERE $id IN (SELECT postId FROM like WHERE $ownerId = me)` in one verified round trip. The node returns the inner indexOnly page and the referenced documents under ONE merged proof — a single quorum-signed state root by construction — and the SDK re-derives the outer query itself and checks it against the *proven* inner values — the node cannot substitute, omit, or inject joined documents. For a `permanentDocument` join property a missing referenced document fails verification outright, since such a reference cannot dangle. For a `deletableDocument` join property a referenced document that was deleted since is proven absent: it has no entry in `outerDocuments` (so match the two halves by id, not by position) and its id is listed in `missingOuterIds`, in first-appearance order. The node still cannot pass an existing document off as deleted. diff --git a/packages/rs-drive/Cargo.toml b/packages/rs-drive/Cargo.toml index 64ea1dca5a1..38ea8666225 100644 --- a/packages/rs-drive/Cargo.toml +++ b/packages/rs-drive/Cargo.toml @@ -53,6 +53,11 @@ intmap = { version = "3.0.1", features = ["serde"], optional = true } chrono = { version = "0.4.35", optional = true } itertools = { version = "0.13", optional = true } grovedb = { workspace = true, optional = true } +# `TreeType` for the tree-type rules shared by the index walkers and +# `drive::document::layout`. grovedb re-exports it only with its `minimal` +# feature; the `verify` build reaches it here (grovedb already depends on +# grovedb-merk in both builds). +grovedb-merk = { workspace = true, optional = true } grovedb-costs = { workspace = true, optional = true } grovedb-path = { workspace = true } grovedb-query = { workspace = true } @@ -129,6 +134,7 @@ server = [ "grovedb/estimated_costs", "grovedb-storage", "grovedb-costs", + "dep:grovedb-merk", "itertools", "rand", #todo: this should be removed eventually "enum-map", @@ -145,6 +151,8 @@ grovedb_operations_logging = [] shielded_test_data = ["grovedb/unsafe-dump-load"] verify = [ "grovedb/verify", + "dep:grovedb-merk", + "grovedb-merk/verify", "grovedb-costs", "dpp/state-transitions", "dpp/system_contracts", diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs index 41c28cfdec3..4a3db34cd21 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs @@ -9,7 +9,8 @@ use std::collections::HashMap; use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; use crate::drive::document::index_level_tree_types::{ - index_level_tree_types_with_continuation_demotion, time_range_index_keys, + index_level_tree_types_with_continuation_demotion, index_only_level_skips_when_absent, + time_range_index_keys, }; use crate::drive::document::time_range_ttl::entry_key_bucket_start; use crate::drive::document::unique_event_id; @@ -236,9 +237,7 @@ impl Drive { // resolves a value, so estimation sweeps this branch as // written — a deliberate over-estimate that keeps the dry // run an upper bound. - None if document_type.index_only() - && !document_type.required_fields().contains(property_name) => - { + None if index_only_level_skips_when_absent(document_type, property_name) => { continue; } // A stored type's absent value keeps its null-layout empty diff --git a/packages/rs-drive/src/drive/document/index_level_tree_types.rs b/packages/rs-drive/src/drive/document/index_level_tree_types.rs index dd9593a88d8..41082765217 100644 --- a/packages/rs-drive/src/drive/document/index_level_tree_types.rs +++ b/packages/rs-drive/src/drive/document/index_level_tree_types.rs @@ -75,13 +75,18 @@ use crate::drive::document::ranked_index_tree_type::property_name_tree_type_and_ranked_axes_for_level; use crate::error::Error; +#[cfg(feature = "server")] use crate::util::object_size_info::DriveKeyInfo; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +#[cfg(feature = "server")] +use dpp::data_contract::document_type::TimeRangeTransform; use dpp::data_contract::document_type::{ - IndexCountability, IndexLevel, IndexLevelTypeInfo, TimeRangeTransform, + DocumentTypeRef, IndexCountability, IndexLevel, IndexLevelTypeInfo, }; +#[cfg(feature = "server")] use grovedb::batch::key_info::KeyInfo; use grovedb::element::IndexAxis; -use grovedb::TreeType; +use grovedb_merk::tree_type::TreeType; /// The two tree types an index sub-level materializes: the /// property-name tree (keys = the property's distinct values) and the @@ -172,6 +177,7 @@ pub(crate) fn index_level_tree_types_with_continuation_demotion( /// never exceed it, so the clamp only bounds estimation work for a transform /// built outside validation, and reading it from the version keeps the /// estimated fan-out in step with whatever a future protocol version allows. +#[cfg(feature = "server")] pub(crate) fn time_range_index_keys<'a>( transform: Option<&TimeRangeTransform>, document_top_field: DriveKeyInfo<'a>, @@ -313,6 +319,123 @@ fn derive_value_tree_type( } } +/// The wrapper an empty continuation property-name tree is inserted in so +/// it contributes nothing to the aggregating value tree above it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ZeroContributionWrapper { + /// `Element::NonCounted`: counted as zero by a count-bearing parent. + NonCounted, + /// `Element::NotSummed`: summed as zero by a sum-bearing parent. + NotSummed, + /// `Element::NotCountedOrSummed`: neither counted nor summed. + NotCountedOrSummed, +} + +/// Why a continuation tree cannot be made to contribute zero to its parent. +/// `LowLevelDriveOperation::for_known_path_key_empty_tree_contributing_zero_to_parent` +/// turns each into its `NotSupported` error. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ZeroContributionRefusal { + /// An indexed (ranked) tree cannot be wrapped under an aggregating parent. + IndexedInner, + /// An indexed tree is a property-name tree, never a value tree. + IndexedParent, + /// A provable count-bearing parent rejects count-suppressed children. + ProvableCountParent, + /// The parent does not aggregate, so no wrapper applies. + NonAggregatingParent, +} + +/// Whether the continuation property-name tree of `sub_level`, under a value +/// tree of `parent_level` whose type is `parent_value_tree_type`, is inserted +/// so it contributes zero to that value tree: the parent aggregates, and it +/// does not count its continuations (a prefix-ranking chain level or a +/// count-propagating level does), unless `sub_level` is a count-exempt +/// branch. The entry-insert walker and the preallocation path both decide +/// with this. +pub(crate) fn continuation_contributes_zero( + parent_value_tree_type: TreeType, + parent_counts_continuations: bool, + sub_level: &IndexLevel, +) -> bool { + !matches!(parent_value_tree_type, TreeType::NormalTree) + && (!parent_counts_continuations || sub_level.count_exempt_branch()) +} + +/// Whether the value trees of `level` count their continuation subtrees +/// (a prefix-ranking chain level or a count-propagating level). +pub(crate) fn level_counts_continuations(level: &IndexLevel) -> bool { + level.ranked_count_grouping() || level.count_propagating() +} + +/// Whether a document without a value for `property`, the first property of +/// a top-level index level, writes nothing under that level: an unrequired +/// property of an indexOnly type is a skipIfAbsent index's trigger (the +/// parser admits no other optional property there). The insert and delete +/// walkers skip with this, and `drive::document::layout` notes it. +pub(crate) fn index_only_level_skips_when_absent( + document_type: DocumentTypeRef, + property: &str, +) -> bool { + document_type.index_only() && !document_type.required_fields().contains(property) +} + +/// The wrapper that makes an empty `inner_tree_type` tree contribute zero to +/// an `aggregating_parent_tree_type` parent, or `None` when it contributes +/// zero unwrapped (a non-sum tree under a sum-only parent): +/// - a `CountTree` parent: `NonCounted`, whatever the inner; +/// - a `CountSumTree` parent: `NotCountedOrSummed` for a sum-bearing inner, +/// else `NonCounted`; +/// - a `SumTree`, `BigSumTree` or `ProvableSumTree` parent: `NotSummed` for a +/// sum-bearing inner, else no wrapper. +/// +/// An indexed inner, an indexed or provable count-bearing parent, and a +/// parent that does not aggregate are refused. +pub(crate) fn zero_contribution_wrapper( + aggregating_parent_tree_type: TreeType, + inner_tree_type: TreeType, +) -> Result, ZeroContributionRefusal> { + if matches!( + inner_tree_type, + TreeType::ProvableSumIndexedTree + | TreeType::ProvableCountIndexedTree + | TreeType::ProvableCountProvableSumIndexedTree + ) { + return Err(ZeroContributionRefusal::IndexedInner); + } + let inner_is_sum_bearing = matches!( + inner_tree_type, + TreeType::SumTree + | TreeType::BigSumTree + | TreeType::ProvableSumTree + | TreeType::CountSumTree + | TreeType::ProvableCountSumTree + | TreeType::ProvableCountProvableSumTree + ); + match aggregating_parent_tree_type { + TreeType::CountTree => Ok(Some(ZeroContributionWrapper::NonCounted)), + TreeType::CountSumTree => Ok(Some(if inner_is_sum_bearing { + ZeroContributionWrapper::NotCountedOrSummed + } else { + ZeroContributionWrapper::NonCounted + })), + TreeType::SumTree | TreeType::BigSumTree | TreeType::ProvableSumTree => { + Ok(inner_is_sum_bearing.then_some(ZeroContributionWrapper::NotSummed)) + } + TreeType::ProvableCountIndexedTree + | TreeType::ProvableSumIndexedTree + | TreeType::ProvableCountProvableSumIndexedTree => { + Err(ZeroContributionRefusal::IndexedParent) + } + TreeType::ProvableCountTree + | TreeType::ProvableCountSumTree + | TreeType::ProvableCountProvableSumTree => { + Err(ZeroContributionRefusal::ProvableCountParent) + } + _ => Err(ZeroContributionRefusal::NonAggregatingParent), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-drive/src/drive/document/insert/add_indices_for_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/insert/add_indices_for_index_level_for_contract_operations/v2/mod.rs index 3cb13df76ee..5a5a0be4870 100644 --- a/packages/rs-drive/src/drive/document/insert/add_indices_for_index_level_for_contract_operations/v2/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_indices_for_index_level_for_contract_operations/v2/mod.rs @@ -1,5 +1,8 @@ use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; -use crate::drive::document::index_level_tree_types::index_level_tree_types_with_continuation_demotion; +use crate::drive::document::index_level_tree_types::{ + continuation_contributes_zero, index_level_tree_types_with_continuation_demotion, + level_counts_continuations, +}; use crate::drive::Drive; use crate::error::fee::FeeError; use crate::error::Error; @@ -127,10 +130,10 @@ impl Drive { // wrapper-choice for child continuations all agree on the // exact variant. let current_layer_tree_type = parent_value_tree_type; - // True iff the parent value tree aggregates anything (count, - // sum, or both) — decides whether continuation children go - // through the zero-contribution helper or the plain one. - let parent_value_tree_aggregates = !matches!(parent_value_tree_type, TreeType::NormalTree); + // Continuation children go through the zero-contribution helper + // when the parent value tree aggregates anything (count, sum, or + // both) — `continuation_contributes_zero`, shared with the + // preallocation path and `drive::document::layout`. // A prefix-ranking chain level (`rankedCountable: { at }`) inverts // that choice for its CHAIN continuation: the value trees count the // continuation's subtree — the total the grouping secondary ranks @@ -144,8 +147,7 @@ impl Drive { // admitted sibling is flag-free at and below the shared levels, so // nothing under a wrapped branch needs the counts the wrapper // suppresses. - let continuations_contribute = - index_level.ranked_count_grouping() || index_level.count_propagating(); + let continuations_contribute = level_counts_continuations(index_level); if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { // On this level we will have a 0 and all the top index paths @@ -214,9 +216,11 @@ impl Drive { .add_path_info(sub_level_index_path_info.clone()); // here we are inserting an empty tree that will have a subtree of all other index properties - if parent_value_tree_aggregates - && (!continuations_contribute || sub_level.count_exempt_branch()) - { + if continuation_contributes_zero( + parent_value_tree_type, + continuations_contribute, + sub_level, + ) { // A ranked terminal level reaching this branch is // rejected inside the helper (it passes `ranked_axes` // straight through): an indexed tree can neither be diff --git a/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs index 465b5513c4f..d5df57a287c 100644 --- a/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs @@ -20,7 +20,8 @@ use dpp::version::PlatformVersion; use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; use crate::drive::document::index_level_tree_types::{ - index_level_tree_types_with_continuation_demotion, time_range_index_keys, + index_level_tree_types_with_continuation_demotion, index_only_level_skips_when_absent, + time_range_index_keys, }; use crate::drive::document::paths::contract_document_type_path_vec; use grovedb::batch::KeyInfoPath; @@ -245,9 +246,7 @@ impl Drive { // skips exactly when apply skips), and the timestamp of a // bucketed level can never land here (its source is // `$createdAt`, required whenever indexed). - None if document_type.index_only() - && !document_type.required_fields().contains(property_name) => - { + None if index_only_level_skips_when_absent(document_type, property_name) => { continue; } // A stored type's absent value keeps its null-layout empty diff --git a/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs b/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs index ebc53e937f5..a4a1ecb2881 100644 --- a/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs @@ -38,8 +38,8 @@ use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; use crate::drive::document::index_level_tree_types::{ - index_level_tree_types_with_continuation_demotion, terminal_member_tree_type, - terminal_value_tree_type, + continuation_contributes_zero, index_level_tree_types_with_continuation_demotion, + level_counts_continuations, terminal_member_tree_type, terminal_value_tree_type, }; use crate::drive::document::index_only::index_only_terminal_max_key_size; use crate::drive::document::index_only_item_estimated_value_size; @@ -340,9 +340,11 @@ impl Drive { // may itself be the plain sibling): its branch tree is // zero-wrapped even under a chain level that counts its // own continuation — matching the entry-insert walkers. - if !matches!(parent_value_tree_type, TreeType::NormalTree) - && (!parent_counts_continuations || sub_level.count_exempt_branch()) - { + if continuation_contributes_zero( + parent_value_tree_type, + parent_counts_continuations, + sub_level, + ) { self.batch_insert_empty_tree_contributing_zero_to_aggregating_parent_if_not_exists( path_key_info, parent_value_tree_type, @@ -452,8 +454,7 @@ impl Drive { index_path_info = Some(path_info); parent_value_tree_type = value_tree_type; - parent_counts_continuations = - sub_level.ranked_count_grouping() || sub_level.count_propagating(); + parent_counts_continuations = level_counts_continuations(sub_level); } let mut path_info = index_path_info.ok_or(Error::Drive( diff --git a/packages/rs-drive/src/drive/document/layout.rs b/packages/rs-drive/src/drive/document/layout.rs new file mode 100644 index 00000000000..9b6f377c026 --- /dev/null +++ b/packages/rs-drive/src/drive/document/layout.rs @@ -0,0 +1,1331 @@ +//! The GroveDB layout of one document type: every tree and element Drive +//! writes under `[DataContractDocuments, contract id, 1, document type name]`, +//! with the element or tree type Drive picks for it, computed from the +//! document type alone. +//! +//! The tree types come from the functions the insert walkers call +//! (`primary_key_tree_type`, `index_level_tree_types_with_continuation_demotion`, +//! `terminal_member_tree_type`, `continuation_contributes_zero`, +//! `zero_contribution_wrapper`, `index_only_level_skips_when_absent`); the +//! element choices the walkers make inline (history, unique terminals, +//! indexOnly entries, sum-carrying references) are restated here and held to +//! the walkers by a test that inserts documents and compares what Drive wrote +//! with this layout, both ways (`tests::should_lay_out_what_drive_writes`). +//! Those are the rules of the v2 index walkers, so only a platform version +//! that selects them has a layout. +//! +//! Each node names the node of the static structure description +//! (`drive::document::structure`, published as `grovedb-structure.json`) it +//! is an instance of, so a viewer can link a concrete layer to the general +//! description of that kind of layer. + +pub use crate::drive::document::index_level_tree_types::ZeroContributionWrapper; +use crate::drive::document::index_level_tree_types::{ + continuation_contributes_zero, index_level_tree_types_with_continuation_demotion, + index_only_level_skips_when_absent, level_counts_continuations, terminal_member_tree_type, + zero_contribution_wrapper, +}; +use crate::drive::document::primary_key_tree_type::DocumentTypePrimaryKeyTreeType; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::{ + is_flat_level_key, DocumentTypeRef, IndexLevel, IndexLevelTypeInfo, IndexType, +}; +use dpp::platform_value::{Value, ValueMap}; +use dpp::version::PlatformVersion; +use grovedb::element::IndexAxis; +use grovedb_merk::tree_type::TreeType; + +/// Every tree and element Drive writes for one document type. +#[derive(Clone, Debug, PartialEq)] +pub struct DocumentTypeLayout { + /// The document type name. + pub document_type: String, + /// The document type tree and everything below it. + pub root: LayoutNode, +} + +/// One layer of the layout: a key (fixed, or standing for a family of keys +/// such as every document id), the element Drive writes there, and the +/// layers below it. +#[derive(Clone, Debug, PartialEq)] +pub struct LayoutNode { + /// The key, or what the keys at this position are. + pub key: LayoutKey, + /// What this layer is. + pub role: LayoutRole, + /// The element Drive writes at this key. + pub element: LayoutElement, + /// What Drive writes at this key instead in some cases, and when. + pub alternative: Option>, + /// The indexes of the document type that use this layer, in name order. + pub indexes: Vec, + /// Conditions and details a reader needs besides the element. + pub notes: Vec, + /// The layers below. + pub children: Vec, +} + +/// What Drive writes at a key instead of the node's own element, and when. +#[derive(Clone, Debug, PartialEq)] +pub struct LayoutAlternative { + /// When the alternative applies. + pub when: &'static str, + /// The alternative element and the layers below it. + pub node: LayoutNode, +} + +/// A key, or the family of keys a node stands for. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LayoutKey { + /// One fixed key. + Fixed { + /// The key bytes. + bytes: Vec, + /// A readable name for the key. + label: String, + }, + /// One key per document: the 32 byte document id. + DocumentId, + /// One key per stored revision: the block time of the revision in + /// milliseconds, a u64 big endian with the sign bit flipped. + RevisionTime, + /// One key per distinct value of `property` among the documents, + /// serialized for ordering; an absent or null value is the empty key. + PropertyValue { + /// The indexed property. + property: String, + }, + /// One key per time window start of `property` that holds a document: + /// the window start, encoded like the timestamp. + TimeRangeBucket { + /// The timestamp the windows bucket. + property: String, + /// The length of a window in seconds. + range_seconds: u64, + /// The time between window starts in seconds. + step_seconds: u64, + /// The shift of the window boundaries in seconds. + phase_seconds: u64, + }, + /// One key per entry of an indexOnly document type: the values of the + /// index's terminal components concatenated (32 bytes for `$ownerId`). + MemberKey { + /// The terminal components, in order. + components: Vec, + }, +} + +/// What a layer is, and the node of the structure description it is an +/// instance of. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LayoutRole { + /// The document type tree. + DocumentType, + /// The documents by id (`[0]`). + PrimaryKey, + /// One document. + Document, + /// The pointer to the newest revision of a document with history. + LatestRevision, + /// One stored revision of a document with history. + Revision, + /// The first property of an index, or the level of a flat indexOnly + /// index. + IndexProperty, + /// The values of one indexed property. + IndexValue, + /// The next property of a compound index. + NextIndexProperty, + /// Where an index ends at a value (`[0]`). + Terminal, + /// One document (or indexOnly entry) under a terminal. + Member, +} + +impl LayoutRole { + /// The id of the node of the structure description this layer is an + /// instance of (`drive::document::structure`). + pub fn structure_node(&self) -> &'static str { + match self { + LayoutRole::DocumentType => "contracts.contract.documents.document_type", + LayoutRole::PrimaryKey => "contracts.contract.documents.document_type.primary_key", + LayoutRole::Document => { + "contracts.contract.documents.document_type.primary_key.document" + } + LayoutRole::LatestRevision => { + "contracts.contract.documents.document_type.primary_key.document.latest" + } + LayoutRole::Revision => { + "contracts.contract.documents.document_type.primary_key.document.revision" + } + LayoutRole::IndexProperty => { + "contracts.contract.documents.document_type.index_property" + } + LayoutRole::IndexValue => { + "contracts.contract.documents.document_type.index_property.value" + } + LayoutRole::NextIndexProperty => { + "contracts.contract.documents.document_type.index_property.value.next_property" + } + LayoutRole::Terminal => { + "contracts.contract.documents.document_type.index_property.value.members" + } + LayoutRole::Member => { + "contracts.contract.documents.document_type.index_property.value.members.member" + } + } + } + + /// The role's name, as `to_value` spells it. + pub fn name(&self) -> &'static str { + match self { + LayoutRole::DocumentType => "documentType", + LayoutRole::PrimaryKey => "primaryKey", + LayoutRole::Document => "document", + LayoutRole::LatestRevision => "latestRevision", + LayoutRole::Revision => "revision", + LayoutRole::IndexProperty => "indexProperty", + LayoutRole::IndexValue => "indexValue", + LayoutRole::NextIndexProperty => "nextIndexProperty", + LayoutRole::Terminal => "terminal", + LayoutRole::Member => "member", + } + } +} + +/// The element Drive writes at a key. +#[derive(Clone, Debug, PartialEq)] +pub struct LayoutElement { + /// The element kind. + pub kind: LayoutElementKind, + /// The wrapper that makes the tree contribute nothing to the aggregating + /// tree above it, if any. + pub wrapper: Option, + /// The ranking axes of an indexed tree, empty otherwise. + pub ranked_axes: Vec, +} + +/// The kind of an element. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LayoutElementKind { + /// A tree of this type. + Tree(TreeType), + /// A value. + Item, + /// A value that also carries a sum. + ItemWithSumItem, + /// A reference to another element. + Reference, + /// A reference that also carries a sum. + ReferenceWithSumItem, +} + +impl LayoutElementKind { + /// The kind's name, as the structure description spells it. + pub fn name(&self) -> &'static str { + match self { + LayoutElementKind::Tree(tree_type) => tree_kind_name(*tree_type), + LayoutElementKind::Item => "Item", + LayoutElementKind::ItemWithSumItem => "ItemWithSumItem", + LayoutElementKind::Reference => "Reference", + LayoutElementKind::ReferenceWithSumItem => "ReferenceWithSumItem", + } + } +} + +fn tree_kind_name(tree_type: TreeType) -> &'static str { + match tree_type { + TreeType::NormalTree => "Tree", + TreeType::SumTree => "SumTree", + TreeType::BigSumTree => "BigSumTree", + TreeType::CountTree => "CountTree", + TreeType::CountSumTree => "CountSumTree", + TreeType::ProvableCountTree => "ProvableCountTree", + TreeType::ProvableCountSumTree => "ProvableCountSumTree", + TreeType::CommitmentTree(_) => "CommitmentTree", + TreeType::MmrTree => "MmrTree", + TreeType::BulkAppendTree(_) => "BulkAppendTree", + TreeType::DenseAppendOnlyFixedSizeTree(_) => "DenseAppendOnlyFixedSizeTree", + TreeType::ProvableSumTree => "ProvableSumTree", + TreeType::ProvableCountProvableSumTree => "ProvableCountProvableSumTree", + TreeType::ProvableSumIndexedTree => "ProvableSumIndexedTree", + TreeType::ProvableCountIndexedTree => "ProvableCountIndexedTree", + TreeType::ProvableCountProvableSumIndexedTree => "ProvableCountProvableSumIndexedTree", + TreeType::PrivateDocumentStore(_) => "PrivateDocumentStore", + } +} + +fn wrapper_name(wrapper: ZeroContributionWrapper) -> &'static str { + match wrapper { + ZeroContributionWrapper::NonCounted => "NonCounted", + ZeroContributionWrapper::NotSummed => "NotSummed", + ZeroContributionWrapper::NotCountedOrSummed => "NotCountedOrSummed", + } +} + +fn axis_name(axis: IndexAxis) -> &'static str { + match axis { + IndexAxis::Count => "count", + IndexAxis::Sum => "sum", + IndexAxis::Avg => "avg", + } +} + +/// A condition or detail of a layer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LayoutNote { + /// The document type is indexOnly: no documents are stored whole. + IndexOnly, + /// A terminal of an index with `nullSearchable: false`: a document whose + /// indexed values are all null writes no entry here (the value trees + /// above are still written, with empty keys). + NotNullSearchable, + /// A contested index is laid out as a unique one here; the contest lives + /// in the votes tree. + Contested, + /// A value of an indexOnly type's first property that documents may + /// leave out: a document without it writes nothing in this index. + SkipIfAbsent { + /// The property. + property: String, + }, + /// A preallocated indexOnly index: its value trees and empty terminal + /// are created when the referenced document is inserted. + Preallocated, + /// A time window level: a document lands in every window that contains + /// its time, up to this many. + TimeRangeOverlap { + /// The most windows one time falls in. + windows: u64, + }, + /// A time window level whose entries expire after the window: written + /// without storage flags and removed by a later cleanup. + TimeRangeTtl { + /// Seconds an entry is kept after its window. + ttl_seconds: u64, + }, +} + +impl LayoutNote { + /// The note's code, as `to_value` spells it. + pub fn code(&self) -> &'static str { + match self { + LayoutNote::IndexOnly => "indexOnly", + LayoutNote::NotNullSearchable => "notNullSearchable", + LayoutNote::Contested => "contested", + LayoutNote::SkipIfAbsent { .. } => "skipIfAbsent", + LayoutNote::Preallocated => "preallocated", + LayoutNote::TimeRangeOverlap { .. } => "timeRangeOverlap", + LayoutNote::TimeRangeTtl { .. } => "timeRangeTtl", + } + } + + /// The note in words. + pub fn text(&self) -> String { + match self { + LayoutNote::IndexOnly => { + "indexOnly: documents are not stored whole; the index entries are the rows" + .to_string() + } + LayoutNote::NotNullSearchable => "nullSearchable is false: a document whose indexed \ + values are all null writes no entry here, though the value trees above are \ + written with empty keys" + .to_string(), + LayoutNote::Contested => { + "contested: laid out as a unique index here; the contest itself is kept in the \ + votes tree" + .to_string() + } + LayoutNote::SkipIfAbsent { property } => { + format!("a document that leaves out {property} writes nothing in this index") + } + LayoutNote::Preallocated => { + "preallocated: the value trees and the empty terminal of this index are created \ + when the referenced document is inserted" + .to_string() + } + LayoutNote::TimeRangeOverlap { windows } => { + format!("a document lands in every window containing its time: up to {windows}") + } + LayoutNote::TimeRangeTtl { ttl_seconds } => format!( + "entries expire {ttl_seconds} seconds after their window: written without \ + storage flags and removed by a later cleanup" + ), + } + } +} + +/// The GroveDB layout of `document_type` at `platform_version`. +/// +/// The layout follows the v2 index walkers (protocol version 14 on); a +/// version that selects other walkers is refused, since they pick other +/// trees and wrappers for some shapes. +pub fn document_type_layout( + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, +) -> Result { + match platform_version + .drive + .methods + .document + .insert + .add_indices_for_index_level_for_contract_operations + { + 2 => {} + version => { + return Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "document_type_layout".to_string(), + known_versions: vec![2], + received: version, + })) + } + } + let index_paths = index_paths(document_type); + let mut children = Vec::new(); + + if !document_type.index_only() { + children.push(primary_key_node(document_type, platform_version)?); + } + + for (level_key, level) in document_type.index_structure().sub_levels() { + children.push(top_index_node( + document_type, + level_key, + level, + &index_paths, + )?); + } + + let mut notes = Vec::new(); + if document_type.index_only() { + notes.push(LayoutNote::IndexOnly); + } + + Ok(DocumentTypeLayout { + document_type: document_type.name().clone(), + root: LayoutNode { + key: fixed( + document_type.name().as_bytes().to_vec(), + document_type.name(), + ), + role: LayoutRole::DocumentType, + element: tree(TreeType::NormalTree), + alternative: None, + indexes: index_paths.iter().map(|(name, _)| name.clone()).collect(), + notes, + children, + }, + }) +} + +/// Each index's name and the level keys its path takes through the index +/// structure, as `IndexLevel::try_from_indices` keys them. +fn index_paths(document_type: DocumentTypeRef) -> Vec<(String, Vec)> { + document_type + .indexes() + .values() + .map(|index| { + let path = match index.flat_level_key() { + Some(flat_key) => vec![flat_key], + None => index + .properties + .iter() + .enumerate() + .map(|(position, property)| index.level_key(position, &property.name)) + .collect(), + }; + (index.name.clone(), path) + }) + .collect() +} + +/// The indexes whose path passes through `path`. +fn indexes_through(index_paths: &[(String, Vec)], path: &[String]) -> Vec { + index_paths + .iter() + .filter(|(_, index_path)| index_path.starts_with(path)) + .map(|(name, _)| name.clone()) + .collect() +} + +/// The index whose path ends at `path`. +fn index_ending_at(index_paths: &[(String, Vec)], path: &[String]) -> Vec { + index_paths + .iter() + .filter(|(_, index_path)| index_path.as_slice() == path) + .map(|(name, _)| name.clone()) + .collect() +} + +fn primary_key_node( + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, +) -> Result { + let summable = document_type.documents_summable().is_some(); + let document = if document_type.documents_keep_history() { + LayoutNode { + key: LayoutKey::DocumentId, + role: LayoutRole::Document, + element: tree(if summable { + TreeType::SumTree + } else { + TreeType::NormalTree + }), + alternative: None, + indexes: vec![], + notes: vec![], + children: vec![ + leaf( + fixed(vec![0], "Latest"), + LayoutRole::LatestRevision, + reference(summable), + ), + leaf( + LayoutKey::RevisionTime, + LayoutRole::Revision, + element(LayoutElementKind::Item), + ), + ], + } + } else { + leaf( + LayoutKey::DocumentId, + LayoutRole::Document, + element(if summable { + LayoutElementKind::ItemWithSumItem + } else { + LayoutElementKind::Item + }), + ) + }; + + Ok(LayoutNode { + key: fixed(vec![0], "PrimaryKey"), + role: LayoutRole::PrimaryKey, + element: tree(document_type.primary_key_tree_type(platform_version)?), + alternative: None, + indexes: vec![], + notes: vec![], + children: vec![document], + }) +} + +/// A first-level index tree, created with the contract. +fn top_index_node( + document_type: DocumentTypeRef, + level_key: &str, + level: &IndexLevel, + index_paths: &[(String, Vec)], +) -> Result { + let path = vec![level_key.to_string()]; + let tree_types = index_level_tree_types_with_continuation_demotion(level)?; + + if is_flat_level_key(level_key) { + // A flat indexOnly index: its entries live straight under the level, + // with no value level. + let info = level.has_index_with_type().ok_or_else(|| { + Error::Drive(DriveError::CorruptedContractIndexes(format!( + "flat index level {level_key:?} of document type {} ends no index", + document_type.name() + ))) + })?; + let components = info.terminal.clone().unwrap_or_default(); + return Ok(LayoutNode { + key: fixed( + level_key.as_bytes().to_vec(), + format!("(flat) {}", components.join(", ")), + ), + role: LayoutRole::IndexProperty, + element: LayoutElement { + kind: LayoutElementKind::Tree(tree_types.property_name_tree_type), + wrapper: None, + ranked_axes: tree_types.ranked_axes, + }, + alternative: None, + indexes: indexes_through(index_paths, &path), + notes: vec![], + children: vec![terminal_node(info, index_ending_at(index_paths, &path))], + }); + } + + let mut notes = Vec::new(); + let property = match level.time_range() { + Some(transform) => transform.source.clone(), + None => level_key.to_string(), + }; + // The walkers' skip rule, for a property that can be absent: a system + // property never is where it is indexed ($id and $ownerId always exist; + // an indexed time or height must be required). + if !property.starts_with('$') && index_only_level_skips_when_absent(document_type, &property) { + notes.push(LayoutNote::SkipIfAbsent { + property: property.clone(), + }); + } + + Ok(LayoutNode { + key: fixed(level_key.as_bytes().to_vec(), level_key), + role: LayoutRole::IndexProperty, + element: LayoutElement { + kind: LayoutElementKind::Tree(tree_types.property_name_tree_type), + wrapper: None, + ranked_axes: tree_types.ranked_axes, + }, + alternative: None, + indexes: indexes_through(index_paths, &path), + notes, + children: vec![value_node( + level, + tree_types.value_tree_type, + level_key, + &path, + true, + index_paths, + )?], + }) +} + +/// The values of one indexed property, with what hangs under each. +fn value_node( + level: &IndexLevel, + value_tree_type: TreeType, + level_key: &str, + path: &[String], + top: bool, + index_paths: &[(String, Vec)], +) -> Result { + let mut notes = Vec::new(); + let key = match level.time_range().filter(|_| top) { + Some(transform) => { + notes.push(LayoutNote::TimeRangeOverlap { + windows: transform.overlap_factor(), + }); + if let Some(ttl_seconds) = transform.ttl_seconds { + notes.push(LayoutNote::TimeRangeTtl { ttl_seconds }); + } + LayoutKey::TimeRangeBucket { + property: transform.source.clone(), + range_seconds: transform.range_seconds, + step_seconds: transform.step_seconds, + phase_seconds: transform.phase_seconds, + } + } + None => LayoutKey::PropertyValue { + property: level_key.to_string(), + }, + }; + + let mut children = Vec::new(); + if let Some(info) = level.has_index_with_type() { + children.push(terminal_node(info, index_ending_at(index_paths, path))); + } + + let parent_counts_continuations = level_counts_continuations(level); + for (sub_key, sub_level) in level.sub_levels() { + let sub_tree_types = index_level_tree_types_with_continuation_demotion(sub_level)?; + let wrapper = if continuation_contributes_zero( + value_tree_type, + parent_counts_continuations, + sub_level, + ) { + zero_contribution_wrapper(value_tree_type, sub_tree_types.property_name_tree_type) + .map_err(|refusal| { + Error::Drive(DriveError::CorruptedContractIndexes(format!( + "index level {sub_key:?} cannot hang under a {} value tree: {refusal:?}", + tree_kind_name(value_tree_type) + ))) + })? + } else { + None + }; + let mut sub_path = path.to_vec(); + sub_path.push(sub_key.clone()); + children.push(LayoutNode { + key: fixed(sub_key.as_bytes().to_vec(), sub_key), + role: LayoutRole::NextIndexProperty, + element: LayoutElement { + kind: LayoutElementKind::Tree(sub_tree_types.property_name_tree_type), + wrapper, + ranked_axes: sub_tree_types.ranked_axes, + }, + alternative: None, + indexes: indexes_through(index_paths, &sub_path), + notes: vec![], + children: vec![value_node( + sub_level, + sub_tree_types.value_tree_type, + sub_key, + &sub_path, + false, + index_paths, + )?], + }); + } + + Ok(LayoutNode { + key, + role: LayoutRole::IndexValue, + element: tree(value_tree_type), + alternative: None, + indexes: indexes_through(index_paths, path), + notes, + children, + }) +} + +/// Where an index ends at a value: the `[0]` key. +fn terminal_node(info: &IndexLevelTypeInfo, indexes: Vec) -> LayoutNode { + let summable = info.summable.is_some(); + let mut notes = Vec::new(); + if !info.should_insert_with_all_null { + notes.push(LayoutNote::NotNullSearchable); + } + if info.index_type == IndexType::ContestedResourceIndex { + notes.push(LayoutNote::Contested); + } + if info.preallocated { + notes.push(LayoutNote::Preallocated); + } + + let members = |member: LayoutNode, indexes: Vec, notes: Vec| LayoutNode { + key: fixed(vec![0], "Members"), + role: LayoutRole::Terminal, + element: tree(terminal_member_tree_type(info)), + alternative: None, + indexes, + notes, + children: vec![member], + }; + + if let Some(components) = &info.terminal { + // indexOnly: each entry is an item keyed by its terminal components. + return members( + leaf( + LayoutKey::MemberKey { + components: components.clone(), + }, + LayoutRole::Member, + element(if summable { + LayoutElementKind::ItemWithSumItem + } else { + LayoutElementKind::Item + }), + ), + indexes, + notes, + ); + } + + let by_document = leaf( + LayoutKey::DocumentId, + LayoutRole::Member, + reference(summable), + ); + if !info.index_type.is_unique() { + return members(by_document, indexes, notes); + } + + // A unique index writes the reference straight at `[0]`, unless a + // value is null: then several documents can share the key, and they + // go in a tree by id like a non-unique index. The alternative does not + // repeat the indexes and notes of the `[0]` it stands in for. + LayoutNode { + key: fixed(vec![0], "Unique reference"), + role: LayoutRole::Terminal, + element: reference(summable), + alternative: Some(Box::new(LayoutAlternative { + when: "an indexed value of the document is null", + node: members(by_document, vec![], vec![]), + })), + indexes, + notes, + children: vec![], + } +} + +fn fixed(bytes: Vec, label: impl Into) -> LayoutKey { + LayoutKey::Fixed { + bytes, + label: label.into(), + } +} + +fn element(kind: LayoutElementKind) -> LayoutElement { + LayoutElement { + kind, + wrapper: None, + ranked_axes: vec![], + } +} + +fn tree(tree_type: TreeType) -> LayoutElement { + element(LayoutElementKind::Tree(tree_type)) +} + +fn reference(summable: bool) -> LayoutElement { + element(if summable { + LayoutElementKind::ReferenceWithSumItem + } else { + LayoutElementKind::Reference + }) +} + +fn leaf(key: LayoutKey, role: LayoutRole, element: LayoutElement) -> LayoutNode { + LayoutNode { + key, + role, + element, + alternative: None, + indexes: vec![], + notes: vec![], + children: vec![], + } +} + +fn text(value: &str) -> Value { + Value::Text(value.to_string()) +} + +fn map(entries: Vec<(&str, Value)>) -> Value { + Value::Map( + entries + .into_iter() + .map(|(key, value)| (text(key), value)) + .collect::(), + ) +} + +/// Seconds as a value JavaScript reads as a number: a u32 (every valid +/// window fits), else a float, rather than a u64, which becomes a BigInt. +fn seconds(value: u64) -> Value { + u32::try_from(value) + .map(Value::U32) + .unwrap_or(Value::Float(value as f64)) +} + +fn texts(values: &[String]) -> Value { + Value::Array(values.iter().map(|value| text(value)).collect()) +} + +impl LayoutKey { + fn to_value(&self) -> Value { + match self { + LayoutKey::Fixed { bytes, label } => map(vec![ + ("kind", text("fixed")), + ("hex", text(&hex::encode(bytes))), + ("label", text(label)), + ]), + LayoutKey::DocumentId => map(vec![("kind", text("documentId"))]), + LayoutKey::RevisionTime => map(vec![("kind", text("revisionTime"))]), + LayoutKey::PropertyValue { property } => map(vec![ + ("kind", text("propertyValue")), + ("property", text(property)), + ]), + LayoutKey::TimeRangeBucket { + property, + range_seconds, + step_seconds, + phase_seconds, + } => map(vec![ + ("kind", text("timeRangeBucket")), + ("property", text(property)), + ("rangeSeconds", seconds(*range_seconds)), + ("stepSeconds", seconds(*step_seconds)), + ("phaseSeconds", seconds(*phase_seconds)), + ]), + LayoutKey::MemberKey { components } => map(vec![ + ("kind", text("memberKey")), + ("components", texts(components)), + ]), + } + } +} + +impl LayoutNode { + /// The node as a plain value: `{ key, role, structureNode, element, + /// wrapper?, rankedAxes, indexes, notes, alternative?, children }`, with + /// `wrapper` and `alternative` left out when there is none. + pub fn to_value(&self) -> Value { + let mut entries = vec![ + ("key", self.key.to_value()), + ("role", text(self.role.name())), + ("structureNode", text(self.role.structure_node())), + ("element", text(self.element.kind.name())), + ]; + if let Some(wrapper) = self.element.wrapper { + entries.push(("wrapper", text(wrapper_name(wrapper)))); + } + entries.push(( + "rankedAxes", + Value::Array( + self.element + .ranked_axes + .iter() + .map(|axis| text(axis_name(*axis))) + .collect(), + ), + )); + entries.push(("indexes", texts(&self.indexes))); + entries.push(( + "notes", + Value::Array( + self.notes + .iter() + .map(|note| { + map(vec![ + ("code", text(note.code())), + ("text", text(¬e.text())), + ]) + }) + .collect(), + ), + )); + if let Some(alternative) = &self.alternative { + entries.push(( + "alternative", + map(vec![ + ("when", text(alternative.when)), + ("node", alternative.node.to_value()), + ]), + )); + } + entries.push(( + "children", + Value::Array(self.children.iter().map(LayoutNode::to_value).collect()), + )); + map(entries) + } +} + +impl DocumentTypeLayout { + /// The layout as a plain value: `{ documentType, root }`. + pub fn to_value(&self) -> Value { + map(vec![ + ("documentType", text(&self.document_type)), + ("root", self.root.to_value()), + ]) + } +} + +#[cfg(all(test, feature = "server"))] +mod tests { + use super::*; + use crate::drive::{Drive, RootTree}; + use crate::structure::{drive_structure, ElementKind, StructureNode}; + use crate::util::test_helpers::setup::{ + setup_document, setup_drive_with_initial_state_structure, + }; + use crate::util::test_helpers::setup_contract; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::document_type::random_document::CreateRandomDocument; + use dpp::data_contract::DataContract; + use dpp::document::{Document, DocumentV0Getters}; + use grovedb::query_result_type::QueryResultType::QueryKeyElementPairResultType; + use grovedb::{Element, PathQuery, Query, SizedQuery}; + use std::collections::BTreeSet; + + /// Contracts covering the index shapes Drive lays out: plain, unique and + /// compound indexes, history, countable and summable types and indexes, + /// ranked and chained indexes, time windows, indexOnly types with + /// terminals, flat and preallocated indexes. + const CONTRACTS: [&str; 19] = [ + "tests/supporting_files/contract/family/family-contract.json", + "tests/supporting_files/contract/family/family-contract-fields-optional.json", + "tests/supporting_files/contract/family/family-contract-countable.json", + "tests/supporting_files/contract/family/family-contract-with-history.json", + "tests/supporting_files/contract/dashpay/dashpay-contract.json", + "tests/supporting_files/contract/references/references_with_contract_history.json", + "tests/supporting_files/contract/restaurants/restaurants-contract.json", + "tests/supporting_files/contract/trending/trending-contract.json", + "tests/supporting_files/contract/trending/trending-sibling-contract.json", + "tests/supporting_files/contract/yappr-likes/yappr-likes-contract.json", + "tests/supporting_files/contract/yappr-likes/yappr-likes-preallocated-contract.json", + "tests/supporting_files/contract/yappr-likes/yappr-likes-author-preallocated-contract.json", + "tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json", + "tests/supporting_files/contract/index-only-scalar-terminal/index-only-scalar-terminal-contract.json", + "tests/supporting_files/contract/tally/tally-contract.json", + "tests/supporting_files/contract/tip-jar/tip-jar-contract.json", + "tests/supporting_files/contract/grades/grades-contract.json", + "tests/supporting_files/contract/grades/grades-ranked-contract.json", + "tests/supporting_files/contract/grades/grades-compound-ranked-contract.json", + ]; + + fn layer(drive: &Drive, path: &[Vec]) -> Vec<(Vec, Element)> { + let mut query = Query::new(); + query.insert_all(); + let path_query = PathQuery::new(path.to_vec(), SizedQuery::new(query, None, None)); + let (elements, _) = drive + .grove_get_raw_path_query( + &path_query, + None, + QueryKeyElementPairResultType, + &mut vec![], + &PlatformVersion::latest().drive, + ) + .expect("expected to read a layer"); + elements.to_key_elements() + } + + fn wrapper_of(element: &Element) -> Option { + match element { + Element::NonCounted(_) => Some(ZeroContributionWrapper::NonCounted), + Element::NotSummed(_) => Some(ZeroContributionWrapper::NotSummed), + Element::NotCountedOrSummed(_) => Some(ZeroContributionWrapper::NotCountedOrSummed), + _ => None, + } + } + + /// Whether Drive may write nothing for `child` in a tree `parent` stands + /// for: the terminal a document with only null values skips + /// (`nullSearchable: false`), the entries of a preallocated terminal + /// (created empty), and the values of a level every document may leave + /// out. + fn may_be_absent(parent: &LayoutNode, child: &LayoutNode) -> bool { + child.notes.contains(&LayoutNote::NotNullSearchable) + || parent.notes.iter().any(|note| { + matches!( + note, + LayoutNote::Preallocated | LayoutNote::SkipIfAbsent { .. } + ) + }) + } + + fn describe(node: &LayoutNode) -> String { + match &node.key { + LayoutKey::Fixed { label, .. } => format!("{:?} {label:?}", node.role), + other => format!("{:?} {other:?}", node.role), + } + } + + /// What the walk saw, across every fixture. + #[derive(Default)] + struct Walk { + mismatches: Vec, + roles: BTreeSet<&'static str>, + wrappers: usize, + ranked: usize, + buckets: usize, + alternatives: usize, + members: usize, + } + + impl Walk { + /// Checks every element under `path`, whose element `node` describes, + /// and that every layer the layout names under it was written. + fn layer(&mut self, drive: &Drive, path: Vec>, node: &LayoutNode) { + let mut reached = vec![false; node.children.len()]; + for (key, element) in layer(drive, &path) { + let position = node + .children + .iter() + .position( + |child| matches!(&child.key, LayoutKey::Fixed { bytes, .. } if bytes == &key), + ) + .or_else(|| { + node.children + .iter() + .position(|child| !matches!(child.key, LayoutKey::Fixed { .. })) + }); + let Some(position) = position else { + self.mismatches.push(format!( + "under {} at {}: key {} is not in the layout", + describe(node), + hex::encode(path.concat()), + hex::encode(&key) + )); + continue; + }; + reached[position] = true; + let described = &node.children[position]; + + let kind = format!("{:?}", ElementKind::of(&element)); + let wrapper = wrapper_of(&element); + let matches = |candidate: &LayoutNode| { + candidate.element.kind.name() == kind && candidate.element.wrapper == wrapper + }; + let chosen = if matches(described) { + described + } else if let Some(alternative) = described + .alternative + .as_ref() + .map(|alternative| &alternative.node) + .filter(|alternative| matches(alternative)) + { + self.alternatives += 1; + alternative + } else { + self.mismatches.push(format!( + "{} at {} key {}: Drive wrote {kind}{} where the layout says {}{}", + describe(described), + hex::encode(path.concat()), + hex::encode(&key), + wrapper.map(|w| format!(" in {w:?}")).unwrap_or_default(), + described.element.kind.name(), + described + .element + .wrapper + .map(|w| format!(" in {w:?}")) + .unwrap_or_default(), + )); + continue; + }; + + self.roles.insert(chosen.role.name()); + self.wrappers += usize::from(chosen.element.wrapper.is_some()); + self.ranked += usize::from(!chosen.element.ranked_axes.is_empty()); + self.buckets += + usize::from(matches!(chosen.key, LayoutKey::TimeRangeBucket { .. })); + self.members += usize::from(matches!(chosen.role, LayoutRole::Member)); + if matches!(chosen.element.kind, LayoutElementKind::Tree(_)) { + let mut below = path.clone(); + below.push(key); + self.layer(drive, below, chosen); + } + } + + for (child, reached) in node.children.iter().zip(reached) { + if !reached && !may_be_absent(node, child) { + self.mismatches.push(format!( + "under {} at {}: the layout has {} but Drive wrote nothing there", + describe(node), + hex::encode(path.concat()), + describe(child) + )); + } + } + } + } + + fn apply(drive: &Drive, index: usize, path: &str) -> DataContract { + let platform_version = PlatformVersion::latest(); + let contract = setup_contract( + drive, + path, + Some([index as u8 + 1; 32]), + None, + None::, + None, + Some(platform_version), + ); + for document_type in contract.document_types().values() { + for seed in 1..8 { + let mut document = document_type + .random_document(Some(seed), platform_version) + .expect("expected a random document"); + small_sums(&mut document, document_type.as_ref(), seed); + if seed <= 2 { + leave_out_optional_unique_values(&mut document, document_type.as_ref()); + } + setup_document(drive, &document, &contract, document_type.as_ref(), None); + } + } + contract + } + + /// Leaves out the optional properties of the type's unique indexes, so a + /// unique index gets entries with null values (two such documents share + /// a key, so they go in a tree by id). + fn leave_out_optional_unique_values(document: &mut Document, document_type: DocumentTypeRef) { + let required = document_type.required_fields(); + for index in document_type + .indexes() + .values() + .filter(|index| index.unique) + { + for property in &index.properties { + if !property.name.starts_with('$') && !required.contains(&property.name) { + document.properties_mut().remove(&property.name); + } + } + } + } + + /// Random integers ignore the schema's bounds, and a few of them overflow + /// a sum tree; give each summed property a small value instead (within + /// the bounds of every fixture: 1 to 7). + fn small_sums(document: &mut Document, document_type: DocumentTypeRef, seed: u64) { + let summed = document_type + .documents_summable() + .map(str::to_string) + .into_iter() + .chain( + document_type + .indexes() + .values() + .filter_map(|index| index.summable.clone()), + ) + .collect::>(); + for property in summed { + if let Some(value) = document.properties_mut().get_mut(&property) { + let small = match &*value { + Value::U8(_) => Value::U8(seed as u8), + Value::I8(_) => Value::I8(seed as i8), + Value::U16(_) => Value::U16(seed as u16), + Value::I16(_) => Value::I16(seed as i16), + Value::U32(_) => Value::U32(seed as u32), + Value::I32(_) => Value::I32(seed as i32), + Value::U64(_) => Value::U64(seed), + Value::I64(_) => Value::I64(seed as i64), + other => other.clone(), + }; + *value = small; + } + } + } + + #[test] + fn should_lay_out_what_drive_writes() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let mut walk = Walk::default(); + + for (index, path) in CONTRACTS.into_iter().enumerate() { + let contract = apply(&drive, index, path); + let documents_path = vec![ + vec![RootTree::DataContractDocuments as u8], + contract.id().to_vec(), + vec![1], + ]; + for (name, document_type) in contract.document_types() { + let layout = document_type_layout(document_type.as_ref(), platform_version) + .expect("expected a layout"); + let type_path = [documents_path.clone(), vec![name.as_bytes().to_vec()]].concat(); + walk.layer(&drive, type_path, &layout.root); + } + } + + assert!( + walk.mismatches.is_empty(), + "the layout disagrees with what Drive wrote:\n{}", + walk.mismatches.join("\n") + ); + for role in [ + "primaryKey", + "document", + "latestRevision", + "revision", + "indexProperty", + "indexValue", + "nextIndexProperty", + "terminal", + "member", + ] { + assert!( + walk.roles.contains(role), + "no fixture reached a {role} layer" + ); + } + assert!(walk.members > 0, "no fixture wrote an index entry"); + assert!( + walk.wrappers > 0, + "no fixture wrote a zero-contribution wrapper" + ); + assert!(walk.ranked > 0, "no fixture wrote a ranked (indexed) tree"); + assert!(walk.buckets > 0, "no fixture wrote a time window"); + assert!( + walk.alternatives > 0, + "no fixture wrote a unique index entry with a null value" + ); + } + + #[test] + fn should_name_a_node_of_the_structure_description_for_every_layer() { + fn ids(node: &StructureNode, out: &mut BTreeSet) { + out.insert(node.id.to_string()); + for child in &node.children { + ids(child, out); + } + } + let mut known = BTreeSet::new(); + ids(&drive_structure(), &mut known); + for role in [ + LayoutRole::DocumentType, + LayoutRole::PrimaryKey, + LayoutRole::Document, + LayoutRole::LatestRevision, + LayoutRole::Revision, + LayoutRole::IndexProperty, + LayoutRole::IndexValue, + LayoutRole::NextIndexProperty, + LayoutRole::Terminal, + LayoutRole::Member, + ] { + assert!( + known.contains(role.structure_node()), + "{} is not a node of the structure description", + role.structure_node() + ); + } + } + + #[test] + fn should_skip_only_an_optional_property_of_an_index_only_type_when_absent() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = apply(&drive, 0, CONTRACTS[9]); + let mark = contract.document_type_for_name("mark").expect("mark"); + let layout = document_type_layout(mark, platform_version).expect("layout"); + let skipped = |label: &str| { + layout + .root + .children + .iter() + .find(|c| matches!(&c.key, LayoutKey::Fixed { label: l, .. } if l == label)) + .map(|c| { + c.notes + .iter() + .any(|n| matches!(n, LayoutNote::SkipIfAbsent { .. })) + }) + .expect("level") + }; + // `a` is required, `b` is not + assert!(!skipped("a")); + assert!(skipped("b")); + + let tip = contract.document_type_for_name("tip").expect("tip"); + let layout = document_type_layout(tip, platform_version).expect("layout"); + assert!(layout.root.children.iter().all(|c| c + .notes + .iter() + .all(|n| !matches!(n, LayoutNote::SkipIfAbsent { .. })))); + } + + #[test] + fn should_store_each_document_of_a_type_with_history_as_a_tree_of_revisions() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = apply(&drive, 0, CONTRACTS[3]); + let person = contract.document_type_for_name("person").expect("person"); + let layout = document_type_layout(person, platform_version).expect("layout"); + + let primary = &layout.root.children[0]; + assert_eq!(primary.role, LayoutRole::PrimaryKey); + let document = &primary.children[0]; + assert_eq!( + document.element.kind, + LayoutElementKind::Tree(TreeType::NormalTree) + ); + assert_eq!( + document.children.iter().map(|c| c.role).collect::>(), + vec![LayoutRole::LatestRevision, LayoutRole::Revision] + ); + } + + #[test] + fn should_refuse_a_version_that_selects_other_index_walkers() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = apply(&drive, 0, CONTRACTS[0]); + let person = contract.document_type_for_name("person").expect("person"); + let version_13 = PlatformVersion::get(13).expect("protocol version 13"); + assert!(matches!( + document_type_layout(person, version_13), + Err(Error::Drive(DriveError::UnknownVersionMismatch { + received: 1, + .. + })) + )); + assert!(document_type_layout(person, platform_version).is_ok()); + } +} diff --git a/packages/rs-drive/src/drive/document/mod.rs b/packages/rs-drive/src/drive/document/mod.rs index 658d0166d88..3d4dc2ddebf 100644 --- a/packages/rs-drive/src/drive/document/mod.rs +++ b/packages/rs-drive/src/drive/document/mod.rs @@ -59,18 +59,25 @@ mod verify_fetch_document_history_query; pub mod paths; /// Primary key tree type resolution -#[cfg(feature = "server")] +#[cfg(any(feature = "server", feature = "verify"))] pub mod primary_key_tree_type; #[cfg(feature = "server")] pub(crate) mod prove; /// Terminal property-name tree resolution for ranked (indexed-tree) indexes -#[cfg(feature = "server")] +#[cfg(any(feature = "server", feature = "verify"))] +#[cfg_attr(not(feature = "server"), allow(dead_code))] pub(crate) mod ranked_index_tree_type; /// Shared index-walker tree-type derivation for the v2 walkers -#[cfg(feature = "server")] +#[cfg(any(feature = "server", feature = "verify"))] +#[cfg_attr(not(feature = "server"), allow(dead_code))] pub(crate) mod index_level_tree_types; +/// The GroveDB layout of one document type, computed from the type with the +/// index walkers' own tree-type rules +#[cfg(any(feature = "server", feature = "verify"))] +pub mod layout; + /// Shared TTL semantics for time-range indexes — see /// `book/src/drive/time-range-ttl.md`. #[cfg(feature = "server")] diff --git a/packages/rs-drive/src/drive/document/primary_key_tree_type.rs b/packages/rs-drive/src/drive/document/primary_key_tree_type.rs index 09587deefe3..f629359ea01 100644 --- a/packages/rs-drive/src/drive/document/primary_key_tree_type.rs +++ b/packages/rs-drive/src/drive/document/primary_key_tree_type.rs @@ -1,7 +1,7 @@ use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::version::PlatformVersion; -use grovedb::TreeType; +use grovedb_merk::tree_type::TreeType; use crate::error::drive::DriveError; use crate::error::Error; diff --git a/packages/rs-drive/src/drive/document/ranked_index_tree_type.rs b/packages/rs-drive/src/drive/document/ranked_index_tree_type.rs index c7b1f630c47..b1fab31b4bc 100644 --- a/packages/rs-drive/src/drive/document/ranked_index_tree_type.rs +++ b/packages/rs-drive/src/drive/document/ranked_index_tree_type.rs @@ -48,7 +48,7 @@ use crate::error::drive::DriveError; use crate::error::Error; use dpp::data_contract::document_type::{IndexLevel, IndexLevelTypeInfo}; use grovedb::element::IndexAxis; -use grovedb::TreeType; +use grovedb_merk::tree_type::TreeType; /// The ranking axes an index level declares, in grovedb's canonical TLV order /// (Count < Sum < Avg, no duplicates). diff --git a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs index 21c15e17e83..e3187a3e945 100644 --- a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs @@ -3,7 +3,8 @@ use crate::drive::document::expiration::pricing::{ document_expiration_cleanup_fee_for_bytes, document_remaining_lifetime_ms, document_ttl_pricing, }; use crate::drive::document::index_level_tree_types::{ - index_level_tree_types_with_continuation_demotion, IndexLevelTreeTypes, + continuation_contributes_zero, index_level_tree_types_with_continuation_demotion, + level_counts_continuations, IndexLevelTreeTypes, }; use crate::drive::document::time_range_ttl::{entry_key_bucket_start, live_time_range_entry_keys}; use crate::drive::document::{ @@ -487,8 +488,7 @@ impl Drive { // count exactly their single continuation, so the continuation // is inserted unwrapped and contributes its subtree count — // matching the v2 insert walker's dispatch. - let mut parent_counts_continuations = current_index_level.ranked_count_grouping() - || current_index_level.count_propagating(); + let mut parent_counts_continuations = level_counts_continuations(current_index_level); if change_occurred_on_index { // here we are inserting an empty tree that will have a subtree of all other index properties @@ -637,10 +637,11 @@ impl Drive { // branch tree must be zero-wrapped so its entries // never pollute the subtree totals — matching the v2 // insert walker's dispatch. - let inserted = if matches!(parent_value_tree_type, TreeType::NormalTree) - || (parent_counts_continuations - && !current_index_level.count_exempt_branch()) - { + let inserted = if !continuation_contributes_zero( + parent_value_tree_type, + parent_counts_continuations, + current_index_level, + ) { self.batch_insert_empty_index_tree_if_not_exists( PathKeyInfo::PathKeyRef::<0>(( index_path.clone(), @@ -726,8 +727,7 @@ impl Drive { // The next-deeper continuation (if any) hangs inside // this level's value tree. parent_value_tree_type = sub_level_tree_types.value_tree_type; - parent_counts_continuations = current_index_level.ranked_count_grouping() - || current_index_level.count_propagating(); + parent_counts_continuations = level_counts_continuations(current_index_level); // we push the actual value of the index path, both for the new and the old index_path.push(document_index_field); @@ -1208,8 +1208,7 @@ impl Drive { // grouping level (validation rejects `at` naming the transform // source), but the stamps are read rather than assumed so the // three walkers share one rule. - let mut parent_counts_continuations = - top_index_level.ranked_count_grouping() || top_index_level.count_propagating(); + let mut parent_counts_continuations = level_counts_continuations(top_index_level); for (i, (level, sub_level_tree_types)) in levels.iter().enumerate() { let property_name = &new_suffix[i * 2]; let value = &new_suffix[i * 2 + 1]; @@ -1226,9 +1225,11 @@ impl Drive { // dispatch above). let property_name_tree_type = sub_level_tree_types.property_name_tree_type; let ranked_axes = sub_level_tree_types.ranked_axes.as_slice(); - let inserted = if matches!(parent_value_tree_type, TreeType::NormalTree) - || (parent_counts_continuations && !level.count_exempt_branch()) - { + let inserted = if !continuation_contributes_zero( + parent_value_tree_type, + parent_counts_continuations, + level, + ) { self.batch_insert_empty_index_tree_if_not_exists( PathKeyInfo::PathKeyRef::<0>((path.clone(), property_name.as_slice())), property_name_tree_type, @@ -1280,8 +1281,7 @@ impl Drive { path.push(value.clone()); parent_value_tree_type = sub_level_tree_types.value_tree_type; - parent_counts_continuations = - level.ranked_count_grouping() || level.count_propagating(); + parent_counts_continuations = level_counts_continuations(level); } if new_terminator_is_unique { diff --git a/packages/rs-drive/src/fees/op.rs b/packages/rs-drive/src/fees/op.rs index e8434cdc4f7..5550da343be 100644 --- a/packages/rs-drive/src/fees/op.rs +++ b/packages/rs-drive/src/fees/op.rs @@ -15,6 +15,9 @@ use grovedb::element::MaxReferenceHop; use grovedb::{batch::QualifiedGroveDbOp, Element, ElementFlags, TreeType}; use grovedb_costs::OperationCost; +use crate::drive::document::index_level_tree_types::{ + zero_contribution_wrapper, ZeroContributionRefusal, ZeroContributionWrapper, +}; use crate::error::drive::DriveError; use crate::error::fee::FeeError; use crate::error::Error; @@ -1110,73 +1113,48 @@ impl LowLevelDriveOperation { inner_tree_type: TreeType, storage_flags: Option<&StorageFlags>, ) -> Result { - // An indexed inner is rejected under every aggregating parent, - // including the sum-only ones whose non-sum fallback below is - // unwrapped: a ranked index's terminal property-name tree must - // not live inside an aggregating value tree at all (see - // `INDEXED_INNER_UNWRAPPABLE`), and letting the unwrapped - // fallback quietly accept one would create the exact shape - // rs-dpp's single-property ranked rule and the ranked query - // picker both refuse to serve. - if matches!( - inner_tree_type, - TreeType::ProvableSumIndexedTree - | TreeType::ProvableCountIndexedTree - | TreeType::ProvableCountProvableSumIndexedTree - ) { - return Err(Error::Drive(DriveError::NotSupported( - INDEXED_INNER_UNWRAPPABLE, - ))); - } - let inner_is_sum_bearing = matches!( - inner_tree_type, - TreeType::SumTree - | TreeType::BigSumTree - | TreeType::ProvableSumTree - | TreeType::CountSumTree - | TreeType::ProvableCountSumTree - | TreeType::ProvableCountProvableSumTree - ); - match aggregating_parent_tree_type { - TreeType::CountTree => Self::for_known_path_key_empty_non_counted_any_tree( - path, - key, - inner_tree_type, - storage_flags, - ), - TreeType::CountSumTree => { - if inner_is_sum_bearing { - Self::for_known_path_key_empty_not_counted_or_summed_tree( - path, - key, - inner_tree_type, - storage_flags, - ) - } else { - Self::for_known_path_key_empty_non_counted_any_tree( - path, - key, - inner_tree_type, - storage_flags, - ) - } + // The decision is shared with the index walkers and + // `drive::document::layout` (`zero_contribution_wrapper`); the + // errors below keep their wording. + match zero_contribution_wrapper(aggregating_parent_tree_type, inner_tree_type) { + Ok(Some(ZeroContributionWrapper::NonCounted)) => { + Self::for_known_path_key_empty_non_counted_any_tree( + path, + key, + inner_tree_type, + storage_flags, + ) } - TreeType::SumTree | TreeType::BigSumTree | TreeType::ProvableSumTree => { - if inner_is_sum_bearing { - Self::for_known_path_key_empty_not_summed_tree( - path, - key, - inner_tree_type, - storage_flags, - ) - } else { - inner_tree_type.empty_tree_operation_for_known_path_key( - path, - key, - storage_flags, - ) - } + Ok(Some(ZeroContributionWrapper::NotCountedOrSummed)) => { + Self::for_known_path_key_empty_not_counted_or_summed_tree( + path, + key, + inner_tree_type, + storage_flags, + ) + } + Ok(Some(ZeroContributionWrapper::NotSummed)) => { + Self::for_known_path_key_empty_not_summed_tree( + path, + key, + inner_tree_type, + storage_flags, + ) + } + Ok(None) => { + inner_tree_type.empty_tree_operation_for_known_path_key(path, key, storage_flags) } + // An indexed inner is rejected under every aggregating parent, + // including the sum-only ones whose non-sum fallback above is + // unwrapped: a ranked index's terminal property-name tree must + // not live inside an aggregating value tree at all (see + // `INDEXED_INNER_UNWRAPPABLE`), and letting the unwrapped + // fallback quietly accept one would create the exact shape + // rs-dpp's single-property ranked rule and the ranked query + // picker both refuse to serve. + Err(ZeroContributionRefusal::IndexedInner) => Err(Error::Drive( + DriveError::NotSupported(INDEXED_INNER_UNWRAPPABLE), + )), // Indexed parents are structurally impossible here: the // ranked upgrade applies to *property-name* trees, and this // dispatcher is only ever called with a **value** tree as the @@ -1186,18 +1164,14 @@ impl LowLevelDriveOperation { // reason (the indexed primary's secondaries are keyed by its // children's aggregates, which a zero-contributing child would // silently fall out of). - TreeType::ProvableCountIndexedTree - | TreeType::ProvableSumIndexedTree - | TreeType::ProvableCountProvableSumIndexedTree => { + Err(ZeroContributionRefusal::IndexedParent) => { Err(Error::Drive(DriveError::NotSupported( "indexed trees are property-name trees, never value trees, so they cannot \ host zero-contributing continuation children — see \ crate::drive::document::ranked_index_tree_type.", ))) } - TreeType::ProvableCountTree - | TreeType::ProvableCountSumTree - | TreeType::ProvableCountProvableSumTree => { + Err(ZeroContributionRefusal::ProvableCountParent) => { Err(Error::Drive(DriveError::NotSupported( "provable count-bearing parents cannot host zero-contributing children — \ grovedb commits their count into every node hash and rejects NonCounted / \ @@ -1206,11 +1180,13 @@ impl LowLevelDriveOperation { index_level_tree_types_with_continuation_demotion).", ))) } - _ => Err(Error::Drive(DriveError::NotSupported( - "for_known_path_key_empty_tree_contributing_zero_to_parent called with a \ + Err(ZeroContributionRefusal::NonAggregatingParent) => { + Err(Error::Drive(DriveError::NotSupported( + "for_known_path_key_empty_tree_contributing_zero_to_parent called with a \ non-aggregating parent tree type — caller should use the unwrapped \ `empty_tree_operation_for_known_path_key` path instead.", - ))), + ))) + } } } diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index 0cc36207e25..ddee7e64399 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -263,6 +263,12 @@ impl From for DataContract { } } +impl AsRef for DataContractWasm { + fn as_ref(&self) -> &DataContract { + &self.0 + } +} + pub fn tokens_configuration_from_js_value( configuration: &JsValue, ) -> WasmDppResult> { diff --git a/packages/wasm-sdk/src/document_type_layout.rs b/packages/wasm-sdk/src/document_type_layout.rs new file mode 100644 index 00000000000..7ace613fe88 --- /dev/null +++ b/packages/wasm-sdk/src/document_type_layout.rs @@ -0,0 +1,89 @@ +//! The GroveDB layout of a document type, computed by Drive +//! (`drive::drive::document::layout`) with the rules its index walkers use. + +use crate::error::WasmSdkError; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::version::PlatformVersion; +use drive::drive::document::layout::document_type_layout as drive_document_type_layout; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_dpp2::data_contract::model::DataContractWasm; +use wasm_dpp2::serialization::conversions::platform_value_to_object; +use wasm_dpp2::version::{PlatformVersionLikeJs, PlatformVersionWasm}; + +#[wasm_bindgen(typescript_custom_section)] +const DOCUMENT_TYPE_LAYOUT_TS: &'static str = r#" +/** + * The GroveDB layout of one document type: every tree and element Drive + * writes under `[64, contract id, 1, document type name]`. + */ +export interface DocumentTypeLayout { + documentType: string; + root: DocumentTypeLayoutNode; +} + +/** One layer of a document type's layout. */ +export interface DocumentTypeLayoutNode { + /** The key, or the family of keys this node stands for. */ + key: + | { kind: 'fixed'; hex: string; label: string } + | { kind: 'documentId' } + | { kind: 'revisionTime' } + | { kind: 'propertyValue'; property: string } + | { kind: 'timeRangeBucket'; property: string; rangeSeconds: number; stepSeconds: number; phaseSeconds: number } + | { kind: 'memberKey'; components: string[] }; + role: + | 'documentType' | 'primaryKey' | 'document' | 'latestRevision' | 'revision' + | 'indexProperty' | 'indexValue' | 'nextIndexProperty' | 'terminal' | 'member'; + /** The id of the node of Drive's structure description (grovedb-structure.json) this layer is an instance of. */ + structureNode: string; + /** The element kind: 'Tree', 'CountTree', 'ProvableCountTree', …, 'Item', 'Reference', …. */ + element: string; + /** The wrapper that makes the tree contribute nothing to the aggregating tree above it, when there is one. */ + wrapper?: 'NonCounted' | 'NotSummed' | 'NotCountedOrSummed'; + /** The ranking axes of an indexed tree. */ + rankedAxes: Array<'count' | 'sum' | 'avg'>; + /** The indexes of the document type that use this layer. */ + indexes: string[]; + notes: Array<{ code: string; text: string }>; + /** What Drive writes at this key instead in some cases, and when. */ + alternative?: { when: string; node: DocumentTypeLayoutNode }; + children: DocumentTypeLayoutNode[]; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "DocumentTypeLayout")] + pub type DocumentTypeLayoutJs; +} + +/// The GroveDB layout of the document type `documentTypeName` of `contract`: +/// the document type tree, the documents by id and, for each index, the +/// property and value trees down to where the index ends, with the element +/// or tree type Drive writes at each (a count or sum tree, a ranked indexed +/// tree, a zero-contribution wrapper, a reference or an indexOnly item). +/// +/// Computed from the contract alone, with the functions Drive's index walkers +/// use, so it needs no network. Those are the walkers of protocol version 14 +/// on; an earlier `platformVersion` is refused. Each node names the node of Drive's structure +/// description it is an instance of (`structureNode`), for linking to the +/// GroveDB structure viewer. +#[wasm_bindgen(js_name = "documentTypeLayout")] +pub fn document_type_layout( + contract: &DataContractWasm, + #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, + #[wasm_bindgen(js_name = "platformVersion")] platform_version: PlatformVersionLikeJs, +) -> Result { + let platform_version: PlatformVersion = PlatformVersionWasm::try_from(platform_version)?.into(); + let document_type = contract + .as_ref() + .document_type_for_name(&document_type_name) + .map_err(|_| { + WasmSdkError::invalid_argument(format!( + "document type '{document_type_name}' not found in contract" + )) + })?; + let layout = drive_document_type_layout(document_type, &platform_version) + .map_err(|error| WasmSdkError::generic(format!("document type layout: {error}")))?; + Ok(platform_value_to_object(&layout.to_value())?.into()) +} diff --git a/packages/wasm-sdk/src/lib.rs b/packages/wasm-sdk/src/lib.rs index b0b6583324b..1e6ca0501d8 100644 --- a/packages/wasm-sdk/src/lib.rs +++ b/packages/wasm-sdk/src/lib.rs @@ -3,6 +3,7 @@ use wasm_bindgen::prelude::wasm_bindgen; mod browser_storage; pub mod context_provider; mod contract_store; +pub mod document_type_layout; pub mod dpns; pub mod encrypted_for; pub mod error; diff --git a/packages/wasm-sdk/tests/unit/document-type-layout.spec.ts b/packages/wasm-sdk/tests/unit/document-type-layout.spec.ts new file mode 100644 index 00000000000..c94b3fda963 --- /dev/null +++ b/packages/wasm-sdk/tests/unit/document-type-layout.spec.ts @@ -0,0 +1,172 @@ +/** + * `documentTypeLayout`: the GroveDB layout of a document type, computed by + * Drive from the contract alone (no connection). The Drive test + * `should_lay_out_what_drive_writes` holds the layout to what Drive writes; + * this checks the binding and the shape JS receives. + */ +import { expect } from './helpers/chai.ts'; +import init, * as sdk from '../../dist/sdk.compressed.js'; + +const ownerId = '11111111111111111111111111111111'; + +type LayoutNode = { + key: { kind: string; hex?: string; label?: string; property?: string; components?: string[] }; + role: string; + structureNode: string; + element: string; + wrapper?: string; + rankedAxes: string[]; + indexes: string[]; + notes: Array<{ code: string; text: string }>; + alternative?: { when: string; node: LayoutNode }; + children: LayoutNode[]; +}; + +const schemas = { + /** Plain, countable, unique, compound and ranked indexes over one type. */ + review: { + type: 'object', + properties: { + shop: { type: 'string', maxLength: 32, position: 0 }, + rating: { type: 'integer', minimum: 1, maximum: 5, position: 1 }, + code: { type: 'string', maxLength: 16, position: 2 }, + }, + indices: [ + { name: 'byShop', properties: [{ shop: 'asc' }], countable: 'countable' }, + { name: 'byShopRating', properties: [{ shop: 'asc' }, { rating: 'asc' }] }, + { name: 'byCode', properties: [{ code: 'asc' }], unique: true }, + { name: 'topRated', properties: [{ rating: 'asc' }], countable: 'countable', rangeCountable: true, rankedCountable: true }, + { + name: 'recent', + properties: [{ $createdAt: 'asc' }], + timeRange: { on: '$createdAt', range: 86400, step: 3600 }, + countable: 'countable', + }, + ], + required: ['shop', 'rating', '$createdAt'], + additionalProperties: false, + }, + /** An indexOnly type keyed by its owner. */ + like: { + type: 'object', + indexOnly: true, + documentsMutable: false, + canBeDeleted: true, + properties: { + shop: { type: 'string', maxLength: 32, position: 0 }, + }, + indices: [ + { name: 'byShop', properties: [{ shop: 'asc' }], terminal: '$ownerId' }, + ], + required: ['shop'], + additionalProperties: false, + }, +}; + +function child(node: LayoutNode, label: string): LayoutNode { + const found = node.children.find((c) => c.key.label === label); + if (!found) { + throw new Error(`no child ${label} under ${node.key.label ?? node.key.kind}`); + } + return found; +} + +describe('documentTypeLayout()', () => { + let contract: sdk.DataContract; + + before(async () => { + await init(); + contract = new sdk.DataContract({ + ownerId, + identityNonce: BigInt(1), + schemas, + fullValidation: true, + platformVersion: new sdk.PlatformVersion(14), + }); + }); + + function layout(name: string) { + return sdk.documentTypeLayout(contract, name, new sdk.PlatformVersion(14)) as unknown as { + documentType: string; + root: LayoutNode; + }; + } + + it('should describe the document type tree, the documents and one tree per first index property', () => { + const { documentType, root } = layout('review'); + + expect(documentType).to.equal('review'); + expect(root).to.include({ role: 'documentType', element: 'Tree' }); + expect(root.structureNode).to.equal('contracts.contract.documents.document_type'); + expect(root.indexes).to.have.members(['byShop', 'byShopRating', 'byCode', 'topRated', 'recent']); + + const primary = child(root, 'PrimaryKey'); + expect(primary.role).to.equal('primaryKey'); + expect(primary.children[0]).to.include({ role: 'document', element: 'Item' }); + expect(primary.children[0].key.kind).to.equal('documentId'); + + expect(root.children.map((c) => c.key.label)).to.have.members([ + 'PrimaryKey', 'shop', 'code', 'rating', '$createdAt#86400#3600', + ]); + }); + + it('should key a time window level by its grid and give the grid as numbers', () => { + const windows = child(layout('review').root, '$createdAt#86400#3600').children[0]; + + expect(windows.key).to.deep.equal({ + kind: 'timeRangeBucket', property: '$createdAt', rangeSeconds: 86400, stepSeconds: 3600, phaseSeconds: 0, + }); + expect(windows.notes.map((n) => n.code)).to.include('timeRangeOverlap'); + }); + + it('should count at a countable terminal and share the prefix of a compound index', () => { + const shop = child(layout('review').root, 'shop'); + expect(shop.indexes).to.have.members(['byShop', 'byShopRating']); + + const value = shop.children[0]; + expect(value).to.include({ role: 'indexValue' }); + expect(value.key).to.deep.equal({ kind: 'propertyValue', property: 'shop' }); + + const terminal = child(value, 'Members'); + expect(terminal).to.include({ role: 'terminal', element: 'CountTree' }); + expect(terminal.indexes).to.deep.equal(['byShop']); + expect(terminal.children[0]).to.include({ role: 'member', element: 'Reference' }); + + // The compound index continues under the counted value tree, so its + // property tree contributes nothing to that count. + const rating = child(value, 'rating'); + expect(rating).to.include({ role: 'nextIndexProperty', wrapper: 'NonCounted' }); + expect(rating.indexes).to.deep.equal(['byShopRating']); + }); + + it('should write a unique index reference straight at [0], or a tree when a value is null', () => { + const value = child(layout('review').root, 'code').children[0]; + const terminal = value.children[0]; + + expect(terminal).to.include({ role: 'terminal', element: 'Reference' }); + expect(terminal.alternative?.when).to.match(/null/); + expect(terminal.alternative?.node.children[0].key.kind).to.equal('documentId'); + }); + + it('should rank a ranked index in an indexed tree', () => { + const rating = child(layout('review').root, 'rating'); + + expect(rating.element).to.equal('ProvableCountIndexedTree'); + expect(rating.rankedAxes).to.deep.equal(['count']); + }); + + it('should key an indexOnly entry by its terminal and store no documents', () => { + const { root } = layout('like'); + + expect(root.notes.map((n) => n.code)).to.include('indexOnly'); + expect(root.children.map((c) => c.key.label)).to.deep.equal(['shop']); + + const member = child(child(root, 'shop').children[0], 'Members').children[0]; + expect(member).to.include({ role: 'member', element: 'Item' }); + expect(member.key).to.deep.equal({ kind: 'memberKey', components: ['$ownerId'] }); + }); + + it('should refuse a document type the contract lacks', () => { + expect(() => sdk.documentTypeLayout(contract, 'missing', new sdk.PlatformVersion(14))).to.throw(/missing/); + }); +}); From 217167da909c919d735b9e2b08763b5bfe5579c1 Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:46:16 +0000 Subject: [PATCH 110/113] fix(ci): validate runner candidates using full commit statuses --- .github/scripts/runner-image.py | 24 +- .../fixtures/candidate-status-pr5151.json | 40 ++++ .github/scripts/tests/test_runner_image.py | 215 +++++++++++++++--- 3 files changed, 240 insertions(+), 39 deletions(-) create mode 100644 .github/scripts/tests/fixtures/candidate-status-pr5151.json diff --git a/.github/scripts/runner-image.py b/.github/scripts/runner-image.py index 34bffbdf8bb..6f55c45b158 100644 --- a/.github/scripts/runner-image.py +++ b/.github/scripts/runner-image.py @@ -68,6 +68,22 @@ def changed_requirements(pr, manifest_path=MANIFEST): return False +def latest_status(head, context): + # The combined /status endpoint omits creator. Full statuses are newest + # first: select before validating, never fall back to an older success. + page = 1 + while True: + statuses = api(f"commits/{head}/statuses?per_page=100&page={page}") + # GitHub contexts are case-insensitive; a case variant must shadow + # older canonical statuses even though it cannot be trusted below. + candidate = next((s for s in statuses if s["context"].casefold() == context.casefold()), None) + if candidate is not None: + return candidate + if len(statuses) < 100: + return None + page += 1 + + def export_environment(manifest, output): lock = manifest["requirements"] versions = lock["versions"] @@ -140,11 +156,13 @@ def select(manifest, kind, output, wait_seconds, arch=None, validation=False): require(fingerprint(expected) == fingerprint(manifest), "Merge-tree requirements differ from PR head; rebase before building a candidate") deadline = time.monotonic() + wait_seconds + context = f"Runner image candidate / PR {pr['number']}" while True: - statuses = api(f"commits/{head}/status")["statuses"] - candidate = next((s for s in statuses if s["context"] == f"Runner image candidate / PR {pr['number']}"), None) + candidate = latest_status(head, context) if candidate and candidate["state"] == "success": - require(candidate.get("creator", {}).get("login") == "github-actions[bot]", + require(candidate["context"] == context, "Candidate status must use the exact publisher context") + creator = candidate.get("creator") + require(isinstance(creator, dict) and creator.get("login") == "github-actions[bot]", "Candidate status must come from the trusted publisher") require(re.fullmatch(r"sha256:[0-9a-f]{64}", candidate.get("description", "")), "Publisher did not record an immutable digest") diff --git a/.github/scripts/tests/fixtures/candidate-status-pr5151.json b/.github/scripts/tests/fixtures/candidate-status-pr5151.json new file mode 100644 index 00000000000..7cb66d5a787 --- /dev/null +++ b/.github/scripts/tests/fixtures/candidate-status-pr5151.json @@ -0,0 +1,40 @@ +{ + "combined": { + "state": "pending", + "sha": "a02b1460736e18b6345bb4722c622e55e787371d", + "total_count": 4, + "statuses": [ + { + "id": 55116170875, + "context": "Runner image candidate / PR 5151", + "state": "success", + "description": "sha256:e5ebd957d28d15976320023b76cffa8b982e1d131c572d92eb9c91814dbf807b", + "target_url": "https://github.com/dashpay/platform/actions/runs/36474975257", + "created_at": "2026-09-28T20:13:10Z", + "updated_at": "2026-09-28T20:13:10Z" + } + ] + }, + "statuses": [ + { + "id": 55116170875, + "context": "Runner image candidate / PR 5151", + "state": "success", + "description": "sha256:e5ebd957d28d15976320023b76cffa8b982e1d131c572d92eb9c91814dbf807b", + "target_url": "https://github.com/dashpay/platform/actions/runs/36474975257", + "created_at": "2026-09-28T20:13:10Z", + "updated_at": "2026-09-28T20:13:10Z", + "creator": { + "login": "github-actions[bot]", + "id": 41898282 + } + } + ], + "publisher_run": { + "id": 36474975257, + "path": ".github/workflows/runner-image-candidate.yml", + "event": "pull_request_target", + "status": "completed", + "conclusion": "success" + } +} diff --git a/.github/scripts/tests/test_runner_image.py b/.github/scripts/tests/test_runner_image.py index f0f9773654f..c811e406d35 100644 --- a/.github/scripts/tests/test_runner_image.py +++ b/.github/scripts/tests/test_runner_image.py @@ -8,14 +8,20 @@ from pathlib import Path import tempfile import unittest +from urllib.error import URLError from unittest.mock import patch ROOT = Path(__file__).resolve().parents[3] spec = importlib.util.spec_from_file_location("runner_image", ROOT / ".github/scripts/runner-image.py") runner = importlib.util.module_from_spec(spec) spec.loader.exec_module(runner) -HEAD = "a" * 40 -DIGEST = "sha256:" + "d" * 64 +# Minimal projections of public REST responses captured 2026-09-28 for PR 5151. +# /status has Simple Commit Status objects (no creator); /statuses has creator. +FIXTURE = json.loads((Path(__file__).parent / "fixtures/candidate-status-pr5151.json").read_text()) +HEAD = FIXTURE["combined"]["sha"] +DIGEST = FIXTURE["statuses"][0]["description"] +STATUS_PATH = f"commits/{HEAD}/statuses?per_page=100&page=1" +RUN_PATH = f"actions/runs/{FIXTURE['publisher_run']['id']}" class SelectorTests(unittest.TestCase): @@ -25,43 +31,52 @@ def setUp(self): self.addCleanup(self.temp.cleanup) self.event = Path(self.temp.name) / "event.json" self.output = Path(self.temp.name) / "output" - self.pr = {"number": 4702, "state": "open", "changed_files": 1, + self.pr = {"number": 5151, "state": "open", "changed_files": 1, "head": {"sha": HEAD}} self.responses = { - "pulls/4702": self.pr, - "pulls/4702/files?per_page=100&page=1": [{"filename": runner.MANIFEST}], + "pulls/5151": self.pr, + "pulls/5151/files?per_page=100&page=1": [{"filename": runner.MANIFEST}], f"contents/{runner.MANIFEST}?ref={HEAD}": { "content": base64.b64encode(json.dumps(self.manifest).encode()).decode()}, - f"commits/{HEAD}/status": {"statuses": [{ - "context": "Runner image candidate / PR 4702", "state": "success", - "creator": {"login": "github-actions[bot]"}, "description": DIGEST, - "target_url": "https://github.com/dashpay/platform/actions/runs/7", - }]}, - "actions/runs/7": {"path": ".github/workflows/runner-image-candidate.yml", - "event": "pull_request_target", "conclusion": "success"}, + f"commits/{HEAD}/status": copy.deepcopy(FIXTURE["combined"]), + STATUS_PATH: copy.deepcopy(FIXTURE["statuses"]), + RUN_PATH: copy.deepcopy(FIXTURE["publisher_run"]), } - def select(self, event=None, kind="rust", arch=None, validation=False): + def api_response(self, path): + response = self.responses[path] + if isinstance(response, Exception): + raise response + return response + + def status_calls(self): + return [call.args[0] for call in self.api.call_args_list + if call.args[0].startswith("commits/")] + + def select(self, event=None, kind="rust", arch=None, validation=False, wait_seconds=0): self.event.write_text(json.dumps(event if event is not None else {"pull_request": self.pr})) with patch.dict(os.environ, {"GITHUB_EVENT_PATH": str(self.event)}), \ - patch.object(runner, "api", side_effect=lambda path: self.responses[path]): - runner.select(self.manifest, kind, self.output, 0, arch, validation) + patch.object(runner, "api", side_effect=self.api_response) as api: + self.api = api + runner.select(self.manifest, kind, self.output, wait_seconds, arch, validation) return dict(line.split("=", 1) for line in self.output.read_text().splitlines()) def test_non_pr_and_unchanged_pr_use_existing_pool(self): self.assertEqual(json.loads(self.select({})["labels"]), ["self-hosted", "Linux", "rust-ci"]) - self.responses["pulls/4702/files?per_page=100&page=1"] = [{"filename": "Cargo.lock"}] + self.responses["pulls/5151/files?per_page=100&page=1"] = [{"filename": "Cargo.lock"}] self.assertEqual(self.select()["image_changed"], "false") def test_exact_candidate_includes_head_digest_and_kind(self): - output = self.select() - labels = json.loads(output["labels"]) - self.assertEqual(labels[-1], f"platform-image-pr-4702-{HEAD}-{DIGEST[7:]}-rust") - self.assertEqual(output["image_changed"], "true") + self.assertNotIn("creator", FIXTURE["combined"]["statuses"][0]) + for kind in ("kotlin", "rust", "npm"): + with self.subTest(kind=kind): + output = self.select(kind=kind) + self.assertEqual(json.loads(output["labels"]), ["self-hosted", "Linux", "X64", + f"platform-image-pr-5151-{HEAD}-{DIGEST[7:]}-{kind}"]) + self.assertEqual(output["image_changed"], "true") + self.assertEqual(self.status_calls(), [STATUS_PATH]) - def test_npm_candidates_and_ordinary_pool_have_distinct_labels(self): - labels = json.loads(self.select(kind="npm")["labels"]) - self.assertEqual(labels[-1], f"platform-image-pr-4702-{HEAD}-{DIGEST[7:]}-npm") + def test_should_keep_npm_ordinary_pool_labels(self): self.assertEqual(json.loads(self.select({}, kind="npm")["labels"]), ["self-hosted", "npm-pr"]) def test_new_head_or_closed_pr_rejects_stale_run(self): @@ -80,17 +95,145 @@ def test_merge_tree_cannot_mix_requirements_from_both_branches(self): self.select() def test_missing_or_incomplete_publisher_cannot_select_image(self): - self.responses["actions/runs/7"]["conclusion"] = None - with self.assertRaisesRegex(ValueError, "not published"): - self.select() - self.responses[f"commits/{HEAD}/status"]["statuses"] = [] + for conclusion in (None, "failure", "cancelled"): + with self.subTest(conclusion=conclusion): + self.responses[RUN_PATH]["conclusion"] = conclusion + with self.assertRaisesRegex(ValueError, "not published"): + self.select() + self.assertFalse(self.output.exists()) + self.responses[STATUS_PATH] = [] with self.assertRaisesRegex(ValueError, "not published"): self.select() def test_other_workflow_cannot_supply_candidate_status(self): - self.responses["actions/runs/7"]["path"] = ".github/workflows/tests.yml" - with self.assertRaisesRegex(ValueError, "Unexpected candidate"): - self.select() + for field, value in (("path", ".github/workflows/tests.yml"), ("event", "pull_request")): + with self.subTest(field=field): + self.responses[RUN_PATH] = dict(FIXTURE["publisher_run"], **{field: value}) + with self.assertRaisesRegex(ValueError, "Unexpected candidate"): + self.select() + self.assertFalse(self.output.exists()) + + def test_should_reject_missing_null_malformed_or_wrong_creator_without_fallback(self): + # The real combined response is also a regression case: no creator. + missing = FIXTURE["combined"]["statuses"][0] + good = FIXTURE["statuses"][0] + candidates = [missing] + [dict(good, creator=creator) for creator in ( + None, {}, "github-actions[bot]", [], 42, + {"login": None}, {"login": "untrusted-user"}, + )] + for candidate in candidates: + with self.subTest(creator=candidate.get("creator", "absent")): + self.responses[STATUS_PATH] = [candidate, good] + with self.assertRaisesRegex(ValueError, "trusted publisher"): + self.select() + self.assertEqual(self.status_calls(), [STATUS_PATH]) + self.assertNotIn(RUN_PATH, [call.args[0] for call in self.api.call_args_list]) + self.assertFalse(self.output.exists()) + + def test_should_reject_invalid_digest_or_publisher_url_without_fallback(self): + good = FIXTURE["statuses"][0] + for field, value, error in ( + ("description", "sha256:abc", "immutable digest"), + ("description", "latest", "immutable digest"), + ("target_url", "https://github.com/other/platform/actions/runs/7", "publishing workflow"), + ("target_url", good["target_url"] + "/jobs/1", "publishing workflow"), + ): + with self.subTest(field=field, value=value): + self.responses[STATUS_PATH] = [dict(good, **{field: value}), good] + with self.assertRaisesRegex(ValueError, error): + self.select() + self.assertFalse(self.output.exists()) + + def test_should_block_older_success_when_newest_is_pending_failure_or_error(self): + good = FIXTURE["statuses"][0] + for state in ("pending", "failure", "error"): + with self.subTest(state=state): + self.responses[STATUS_PATH] = [dict(good, state=state), good] + with self.assertRaisesRegex(ValueError, "not published"): + self.select() + self.assertEqual(self.status_calls(), [STATUS_PATH]) + self.assertNotIn(RUN_PATH, [call.args[0] for call in self.api.call_args_list]) + self.assertFalse(self.output.exists()) + + def test_should_use_first_success_without_unnecessary_pagination(self): + good = FIXTURE["statuses"][0] + older = dict(good, description="sha256:" + "e" * 64) + self.responses[STATUS_PATH] = [good] + [older] * 99 + labels = json.loads(self.select()["labels"]) + self.assertEqual(labels[-1], f"platform-image-pr-5151-{HEAD}-{DIGEST[7:]}-rust") + self.assertEqual(self.status_calls(), [STATUS_PATH]) + + def test_should_shadow_canonical_success_with_newer_case_variant(self): + good = FIXTURE["statuses"][0] + for state in ("success", "pending", "failure", "error"): + with self.subTest(state=state): + newer = dict(good, context=good["context"].lower(), state=state) + self.responses[STATUS_PATH] = [newer] + [good] * 99 + error = "exact publisher context" if state == "success" else "not published" + with self.assertRaisesRegex(ValueError, error): + self.select() + self.assertEqual(self.status_calls(), [STATUS_PATH]) + self.assertNotIn(RUN_PATH, [call.args[0] for call in self.api.call_args_list]) + self.assertFalse(self.output.exists()) + + def test_should_select_first_match_on_later_page_before_validation(self): + good = FIXTURE["statuses"][0] + self.responses[STATUS_PATH] = [dict(good, context="unrelated")] * 100 + page2 = STATUS_PATH.replace("&page=1", "&page=2") + for state in ("pending", "success"): + with self.subTest(state=state): + self.responses[page2] = [dict(good, state=state)] + [good] * 99 + if state == "pending": + with self.assertRaisesRegex(ValueError, "not published"): + self.select() + self.assertFalse(self.output.exists()) + else: + self.assertEqual(self.select()["image_changed"], "true") + self.assertEqual(self.status_calls(), [STATUS_PATH, page2]) + + def test_should_require_exact_context_and_stop_at_page_exhaustion(self): + good = FIXTURE["statuses"][0] + unrelated = [dict(good, context=context) for context in ( + "Runner image candidate / PR 51510", "Runner image candidate / PR 5151 suffix", + "Runner image candidate / PR 5151 ", "Runner image candidate / PR 515", + )] + page2 = STATUS_PATH.replace("&page=1", "&page=2") + for last_page in ([], unrelated): + with self.subTest(last_page_size=len(last_page)): + self.responses[STATUS_PATH] = unrelated * 25 + self.responses[page2] = last_page + with self.assertRaisesRegex(ValueError, "not published"): + self.select() + self.assertEqual(self.status_calls(), [STATUS_PATH, page2]) + self.assertFalse(self.output.exists()) + + def test_should_fail_closed_on_status_api_failure(self): + page2 = STATUS_PATH.replace("&page=1", "&page=2") + for failed_path in (STATUS_PATH, page2): + with self.subTest(failed_path=failed_path): + self.responses[STATUS_PATH] = [dict(FIXTURE["statuses"][0], context="other")] * 100 + self.responses[failed_path] = URLError("status API unavailable") + with self.assertRaisesRegex(URLError, "status API unavailable"): + self.select() + self.assertFalse(self.output.exists()) + + def test_should_retry_pending_status_and_publisher_run(self): + good = FIXTURE["statuses"][0] + for pending in ("status", "run"): + with self.subTest(pending=pending): + self.responses[STATUS_PATH] = [dict(good, state="pending"), good] if pending == "status" else [good] + self.responses[RUN_PATH]["conclusion"] = None if pending == "run" else "success" + + def publish(_seconds): + self.responses[STATUS_PATH] = [good] + self.responses[RUN_PATH]["conclusion"] = "success" + + with patch.object(runner.time, "monotonic", return_value=0), \ + patch.object(runner.time, "sleep", side_effect=publish) as sleep: + self.assertEqual(self.select(wait_seconds=60)["image_changed"], "true") + sleep.assert_called_once_with(20) + self.assertEqual(self.status_calls(), [STATUS_PATH, STATUS_PATH]) + self.assertEqual([call.args[0] for call in self.api.call_args_list].count("pulls/5151"), 2) def test_environment_export_rejects_multiline_values_before_writing(self): self.manifest["requirements"]["versions"]["protoc"] = "32.0\nINJECTED=yes" @@ -99,30 +242,30 @@ def test_environment_export_rejects_multiline_values_before_writing(self): self.assertFalse(self.output.exists()) def test_arm64_validation_never_consumes_amd64_candidate_status(self): - self.responses[f"commits/{HEAD}/status"]["statuses"] = [] + self.responses[STATUS_PATH] = [] output = self.select(arch="ARM64") self.assertEqual(json.loads(output["labels"]), ["self-hosted", "Linux", "ARM64", "rust-ci"]) def test_arm64_manifest_change_does_not_use_stale_ordinary_arm64_runners(self): - self.responses["pulls/4702/files?per_page=100&page=1"] = [{"filename": runner.ARM64_MANIFEST}] + self.responses["pulls/5151/files?per_page=100&page=1"] = [{"filename": runner.ARM64_MANIFEST}] output = self.select() self.assertEqual(json.loads(output["labels"]), ["self-hosted", "Linux", "X64", "rust-ci"]) self.assertEqual(output["image_changed"], "false") # Mac-backed ARM64 capacity remains available to ordinary, unrelated PRs. - self.responses["pulls/4702/files?per_page=100&page=1"] = [{"filename": "Cargo.lock"}] + self.responses["pulls/5151/files?per_page=100&page=1"] = [{"filename": "Cargo.lock"}] self.assertEqual(json.loads(self.select()["labels"]), ["self-hosted", "Linux", "rust-ci"]) def test_both_manifest_changes_still_require_exact_amd64_candidate(self): - self.responses["pulls/4702/files?per_page=100&page=1"] = [ + self.responses["pulls/5151/files?per_page=100&page=1"] = [ {"filename": runner.MANIFEST}, {"filename": runner.ARM64_MANIFEST}] output = self.select() self.assertEqual(json.loads(output["labels"]), ["self-hosted", "Linux", "X64", - f"platform-image-pr-4702-{HEAD}-{DIGEST[7:]}-rust"]) + f"platform-image-pr-5151-{HEAD}-{DIGEST[7:]}-rust"]) self.assertEqual(output["image_changed"], "true") def test_arm64_validation_rejects_merge_tree_drift(self): arm = runner.read_manifest(ROOT / runner.ARM64_MANIFEST) - self.responses["pulls/4702/files?per_page=100&page=1"] = [{"filename": runner.ARM64_MANIFEST}] + self.responses["pulls/5151/files?per_page=100&page=1"] = [{"filename": runner.ARM64_MANIFEST}] remote = copy.deepcopy(arm) remote["recipe_revision"] = "e" * 40 self.responses[f"contents/{runner.ARM64_MANIFEST}?ref={HEAD}"] = { From c4493d561dc274b4cfd0c1b923fee82ccdb518e4 Mon Sep 17 00:00:00 2001 From: infraclaw <283232465+infraclaw-dash@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:01:21 +0000 Subject: [PATCH 111/113] ci: pin integrated runner controller revision --- .github/workflows/runner-image-candidate.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/runner-image-candidate.yml b/.github/workflows/runner-image-candidate.yml index 4188f707971..5e4708466fc 100644 --- a/.github/workflows/runner-image-candidate.yml +++ b/.github/workflows/runner-image-candidate.yml @@ -23,10 +23,10 @@ jobs: if: >- (github.event.action != 'closed' && !github.event.pull_request.draft) || (github.event.action == 'closed' && github.event.pull_request.merged) - uses: dashpay/dash-selfhosted-image/.github/workflows/platform-candidate.yml@aaea7df12716c386db223ea67a853b6b0efbd45a + uses: dashpay/dash-selfhosted-image/.github/workflows/platform-candidate.yml@7d901150bd3d0789d50c46f365b058f2f5f1f52d with: pull_request: ${{ github.event.pull_request.number }} - control_revision: aaea7df12716c386db223ea67a853b6b0efbd45a + control_revision: 7d901150bd3d0789d50c46f365b058f2f5f1f52d mode: ${{ github.event.action == 'closed' && 'promote' || 'candidate' }} secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} From fcacc64541a87dccb9d0912e17e6efee62aff042 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 29 Sep 2026 06:33:28 +0700 Subject: [PATCH 112/113] feat(drive): compute what creating a document costs for the SDKs (#5159) Co-authored-by: Claude Opus 5.5 --- book/src/SUMMARY.md | 1 + book/src/fees/document-cost.md | 111 ++ packages/js-evo-sdk/README.md | 26 + .../src/drive/document/cost/document.rs | 281 ++++ .../src/drive/document/cost/grove_costs.rs | 367 +++++ .../rs-drive/src/drive/document/cost/mod.rs | 1201 +++++++++++++++++ .../rs-drive/src/drive/document/cost/tests.rs | 758 +++++++++++ .../src/drive/document/cost/writes.rs | 982 ++++++++++++++ .../src/drive/document/expiration/mod.rs | 8 + .../src/drive/document/expiration/pricing.rs | 47 +- .../src/drive/document/fixture_contracts.rs | 90 ++ .../rs-drive/src/drive/document/index_only.rs | 26 +- .../mod.rs | 40 +- .../rs-drive/src/drive/document/layout.rs | 127 +- packages/rs-drive/src/drive/document/mod.rs | 106 +- .../rs-drive/src/drive/document/sdk_value.rs | 34 + packages/wasm-sdk/Cargo.toml | 2 + packages/wasm-sdk/src/document_create_cost.rs | 284 ++++ packages/wasm-sdk/src/lib.rs | 1 + .../tests/unit/document-create-cost.spec.ts | 148 ++ 20 files changed, 4440 insertions(+), 200 deletions(-) create mode 100644 book/src/fees/document-cost.md create mode 100644 packages/rs-drive/src/drive/document/cost/document.rs create mode 100644 packages/rs-drive/src/drive/document/cost/grove_costs.rs create mode 100644 packages/rs-drive/src/drive/document/cost/mod.rs create mode 100644 packages/rs-drive/src/drive/document/cost/tests.rs create mode 100644 packages/rs-drive/src/drive/document/cost/writes.rs create mode 100644 packages/rs-drive/src/drive/document/fixture_contracts.rs create mode 100644 packages/rs-drive/src/drive/document/sdk_value.rs create mode 100644 packages/wasm-sdk/src/document_create_cost.rs create mode 100644 packages/wasm-sdk/tests/unit/document-create-cost.spec.ts diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 8e83c3ffb7f..abc3286f5e0 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -34,6 +34,7 @@ - [Fee System Overview](fees/overview.md) - [Platform Address Fees](fees/platform-address-fees.md) - [Shielded Transaction Fees](fees/shielded-fees.md) +- [What a Document Costs](fees/document-cost.md) # Error Handling diff --git a/book/src/fees/document-cost.md b/book/src/fees/document-cost.md new file mode 100644 index 00000000000..55be9961488 --- /dev/null +++ b/book/src/fees/document-cost.md @@ -0,0 +1,111 @@ +# What a Document Costs + +`drive::document::cost` computes what creating one document costs, from the +contract alone. The JavaScript SDKs expose it as +`documentCreateCost(contract, documentTypeName, options, platformVersion)`, +which is what the contract visualizer shows per document type. + +Amounts are in credits: 1 Dash is 100,000,000,000 credits. + +## Storage + +The storage fee is the bytes the insert adds times +`storage_disk_usage_credit_per_byte` (27,000 credits from protocol version 14). +It is usually almost all of what a document costs. + +The estimate is exact. It lists every element the insert writes (see +[GroveDB Structure](../drive/grovedb-structure.md) for the layout), built with +the functions the insert walkers use: + +- the document by id, or with `documentsKeepHistory` a tree of revisions and a + pointer to the newest; +- for each index, the value trees along its properties (created only when no + earlier document has the value), the `[0]` terminal and the document's + reference in it, or an indexOnly type's entry; +- the rows a ranked index keeps for the value in its secondary trees, one per + ranking axis; +- the trees preallocated for other document types' `preallocated` indexes + whose entries will reference the document, charged to its creator. + +Each element is priced with GroveDB's byte formulas: the key with the 32-byte +subtree prefix, the serialized element (or a fixed size standing for a tree), +the value and node hashes, the aggregate feature of the tree it sits in (8 or +16 bytes in a count or sum tree), and the link its parent keeps to it. A +document create's elements carry 35 bytes of storage flags (the owner and the +epoch); index trees carry them only when the documents are mutable, the +contract can be deleted, or the type is indexOnly and its documents can be +deleted. + +The storage depends on what is already stored, so it comes in two scenarios: + +- **every value new**: the first document with these index values creates + their trees; +- **every value known**: a later document with the same values adds only its + own entries. A unique index still adds its value, which no earlier document + can hold, as does every tree keyed by the document's own id (a preallocated + index's, say) and a `ttl` document's expiration entry; a ranked index's row + for an existing value only moves, which GroveDB bills as replaced bytes. + +The estimate also splits the storage by index: the layers an index shares with +other indexes (a common prefix of properties, paid once for the document) and +the layers only it uses, so an index's cost on its own is its shared layers +plus its own. + +The test `should_price_what_drive_charges` inserts documents into 19 contracts +covering every index shape and requires the estimate to equal the storage fee +Drive charged, byte for byte, with the trees already stored read from GroveDB +before each insert. + +## Processing + +- **Exact:** verifying the signature (15,000 credits for an ECDSA key, 300,000 + for BLS) and fetching the signing key and the identity's balance (18,000). +- **Estimated:** the work of the writes (seeks, hashing and rewriting the path + to each new element in its tree and above), for an assumed number of stored + documents, each with its own values (1,000 by default); and the small reads + and writes around the insert (the document id check and the identity's + contract nonce). The test + `should_estimate_the_processing_of_the_writes_within_a_factor_of_two` holds + the write estimate to Drive's processing fee. Processing is a few percent of + a document's cost. +- A `userFeeIncrease` raises the processing fee only. + +The per-document "minimum fee" of a batch (`document_batch_sub_transition`) is +a balance check before processing, not a charge, and the unique index checks +and the fetch of the batch's own contract are not billed. + +## What the contract adds + +- **Action fees** (`actionFees`): the create's fee, in credits, scaled by the + epoch's fee multiplier unless priced as fixed, paid into the contract's fee + pots. +- **Token cost** (`tokenCost`): tokens transferred to the contract owner or + burned. +- **Contest fund**: a contested index's vote fund (0.1 Dash, doubling past 250 + contenders), paid when the value is contested. + +## Refunds + +Deleting a document refunds the storage fee of its flagged elements, less what +the epochs already passed were paid: about 99.9% in the epoch it was created, +about 95% a year later, then less each year for fifty years. + +## Documents with a `ttl` + +A document whose type declares a [`ttl`](../contract-keywords/ttl.md) is +stored without flags and pays for its bytes by the lifetime it has left, all of +its `ttl` when it is created, at the schedule's tier for that lifetime (from 1 +credit per byte for an hour to 26 for a week, then 34 per 788,400 seconds) +instead of the 27,000 of storage kept for good. It also adds its entry to the +documents expirations tree, and prepays its deletion as processing (a base +cost, a cost per index level and one per document byte). Nothing of it is +refunded. + +## Not covered + +- A create whose value starts a contest (a DPNS name matching the contest + rule, say) is stored in the contest's vote poll until the contest ends, not + in the index. The estimate prices an uncontested create and lists the contest + fund; the vote poll's storage is not priced. +- A platform version whose insert methods differ from protocol version 14's is + refused: other versions write other elements for some shapes. diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index a3e1b0764a5..bdadaf359cb 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -20,6 +20,7 @@ Evo SDK provides a high-level, strongly-typed interface for interacting with [Da - [Immutable properties (`immutable`)](#immutable-properties-immutable) - [Property constraints (`propertyConstraints`)](#property-constraints-propertyconstraints) - [How a document type is stored (`documentTypeLayout`)](#how-a-document-type-is-stored-documenttypelayout) +- [What a document costs (`documentCreateCost`)](#what-a-document-costs-documentcreatecost) - [Chained queries (provable semi-join)](#chained-queries-provable-semi-join) - [Composite queries (a page plus its sub-queries)](#composite-queries-a-page-plus-its-sub-queries) - [Contributing](#contributing) @@ -473,6 +474,31 @@ const { root } = documentTypeLayout(contract, 'review', new PlatformVersion(14)) `structureNode` names the layer of Drive's GroveDB structure description it is an instance of, as the [GroveDB structure viewer](https://dashpay.github.io/grovedb-structure-viewer/) shows it (`#/`). +## What a document costs (`documentCreateCost`) + +`documentCreateCost(contract, documentTypeName, options, platformVersion)` returns what creating a document of a type costs, in credits (`creditsPerDash` of them make one Dash), computed locally by Drive from the contract: + +- `storage`: the bytes the insert writes and their fee, exact, under two scenarios: `newValues` (the first document with these index values creates their trees) and `knownValues` (a later document with the same values adds only its own entries); +- `indexes`: per index, the bytes of the layers it shares with other indexes and of its own, so its cost on its own is `sharedBytes + ownBytes`; +- `processing`: the signature and identity fetch (exact) and the work of the writes (estimated for `existingDocuments` stored documents); +- `contractCharges`: the create's action fee, token cost and contest fund, when the type has them (a contested create is stored in the vote poll until the contest ends; that storage is not priced); +- `refund`: what a delete refunds, in the same epoch and a year later; +- `fields`: how the priced document was filled. + +The document is built from sizes, not values: by default each variable-size field is at the middle of its bounds and each optional field is present. Pass `fields` to change that: + +```ts +import { documentCreateCost, PlatformVersion } from '@dashevo/evo-sdk'; + +const cost = documentCreateCost(contract, 'note', { + fields: { text: { length: 200 }, mood: { present: false } }, + existingDocuments: 10_000, +}, PlatformVersion.latest()); +const dash = cost.totalCredits.newValues / cost.creditsPerDash; +``` + +It follows protocol version 14 on; an earlier version is refused. A type whose documents have a `ttl` is priced by lifetime, with no refund. + ## Chained queries (provable semi-join) A `refersTo: permanentDocument` declaration also lights up the read side: a **chained query** answers `SELECT * FROM post WHERE $id IN (SELECT postId FROM like WHERE $ownerId = me)` in one verified round trip. The node returns the inner indexOnly page and the referenced documents under ONE merged proof — a single quorum-signed state root by construction — and the SDK re-derives the outer query itself and checks it against the *proven* inner values — the node cannot substitute, omit, or inject joined documents. For a `permanentDocument` join property a missing referenced document fails verification outright, since such a reference cannot dangle. For a `deletableDocument` join property a referenced document that was deleted since is proven absent: it has no entry in `outerDocuments` (so match the two halves by id, not by position) and its id is listed in `missingOuterIds`, in first-appearance order. The node still cannot pass an existing document off as deleted. diff --git a/packages/rs-drive/src/drive/document/cost/document.rs b/packages/rs-drive/src/drive/document/cost/document.rs new file mode 100644 index 00000000000..f2855458fd4 --- /dev/null +++ b/packages/rs-drive/src/drive/document/cost/document.rs @@ -0,0 +1,281 @@ +//! A document of chosen sizes, for pricing a document type without real +//! values: what a document costs depends on the length of each value and on +//! which optional values it carries, not on the values themselves. Each +//! variable-size value defaults to its middle size, the size Drive's own fee +//! estimates assume (`DocumentTypeV0Methods::estimated_size`), and each +//! optional value to present. + +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; +use dpp::data_contract::document_type::{DocumentProperty, DocumentPropertyType, DocumentTypeRef}; +use dpp::data_contract::DataContract; +use dpp::document::{Document, DocumentV0}; +use dpp::platform_value::{Identifier, Value}; +use dpp::version::PlatformVersion; +use indexmap::IndexMap; +use std::collections::{BTreeMap, BTreeSet}; + +/// The block time the document is created at: its timestamps are 8 bytes +/// whatever the time. +const CREATED_AT_MS: u64 = 1_750_000_000_000; + +/// What to put in one field. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FieldChoice { + /// Whether an optional field is present; required fields always are. + pub present: Option, + /// The length of a variable-size value: characters of a string, bytes + /// of a byte array, elements of an array. + pub length: Option, +} + +/// How a field of the priced document was filled. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FieldSize { + /// The field's path (`a.b` for a property of an object). + pub path: String, + /// The field's type, as a word: `string`, `byteArray`, `array`, + /// `identifier`, `integer`, `number`, `boolean`, `date`, `object`. + pub kind: &'static str, + /// Whether the field is optional. + pub optional: bool, + /// Whether the document carries it. + pub present: bool, + /// The length used, for a variable-size field (not one of a single size). + pub length: Option, + /// The least length the schema allows, for a variable-size field. + pub min_length: Option, + /// The most length the schema allows, for a variable-size field. + pub max_length: Option, +} + +fn kind(property_type: &DocumentPropertyType) -> &'static str { + match property_type { + DocumentPropertyType::String(_) => "string", + DocumentPropertyType::ByteArray(_) => "byteArray", + DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) => "array", + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { + "identifier" + } + DocumentPropertyType::F64 => "number", + DocumentPropertyType::Boolean => "boolean", + DocumentPropertyType::Date => "date", + DocumentPropertyType::Object(_) => "object", + _ => "integer", + } +} + +/// The length bounds of a variable-size type, and the middle between them; +/// `None` for a type of one size. +fn bounds( + property_type: &DocumentPropertyType, + platform_version: &PlatformVersion, +) -> Option<(u32, Option, u32)> { + let variable = |(min, max, middle): (u32, Option, u32)| { + (max != Some(min)).then_some((min, max, middle)) + }; + match property_type { + DocumentPropertyType::String(_) | DocumentPropertyType::ByteArray(_) => { + let min = u32::from(property_type.min_size().unwrap_or(0)); + let max = property_type.max_size().map(u32::from); + let middle = property_type + .middle_size(platform_version) + .map(u32::from) + .unwrap_or_else(|| min.max(16)); + variable((min, max, middle)) + } + DocumentPropertyType::TypedArray(array) => { + let min = u32::from(array.min_items.unwrap_or(0)); + let max = u32::from(array.max_items); + variable((min, Some(max), (min + max) / 2)) + } + _ => None, + } +} + +/// A value of `property_type` of `length` (for a variable-size type). +fn value_of( + property_type: &DocumentPropertyType, + length: u32, + platform_version: &PlatformVersion, +) -> Value { + match property_type { + DocumentPropertyType::U128 => Value::U128(1), + DocumentPropertyType::I128 => Value::I128(1), + DocumentPropertyType::U64 => Value::U64(1), + DocumentPropertyType::I64 => Value::I64(1), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => Value::U32(1), + DocumentPropertyType::I32 => Value::I32(1), + DocumentPropertyType::U16 => Value::U16(1), + DocumentPropertyType::I16 => Value::I16(1), + DocumentPropertyType::U8 => Value::U8(1), + DocumentPropertyType::I8 => Value::I8(1), + DocumentPropertyType::F64 => Value::Float(1.0), + DocumentPropertyType::Boolean => Value::Bool(true), + DocumentPropertyType::Date => Value::Float((CREATED_AT_MS / 1000) as f64), + DocumentPropertyType::String(_) => Value::Text("a".repeat(length as usize)), + DocumentPropertyType::ByteArray(_) => { + let bytes = vec![1u8; length as usize]; + if property_type.min_size() == property_type.max_size() { + match bytes.len() { + 20 => Value::Bytes20([1; 20]), + 32 => Value::Bytes32([1; 32]), + 36 => Value::Bytes36([1; 36]), + _ => Value::Bytes(bytes), + } + } else { + Value::Bytes(bytes) + } + } + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { + Value::Identifier([1; 32]) + } + DocumentPropertyType::TypedArray(array) => { + let item_length = bounds(&array.item_type, platform_version) + .map(|(_, _, middle)| middle) + .unwrap_or_else(|| u32::from(array.item_type.min_size().unwrap_or_default())); + Value::Array( + (0..length) + .map(|_| value_of(&array.item_type, item_length, platform_version)) + .collect(), + ) + } + DocumentPropertyType::Array(_) | DocumentPropertyType::VariableTypeArray(_) => { + Value::Array(vec![]) + } + DocumentPropertyType::Object(_) => Value::Map(vec![]), + } +} + +/// Fills `properties` into `data`, recording each field in `fields`. A +/// transient property, by top-level name, is judged on the transition and +/// never stored (`drop_transient_values`): it is neither filled nor listed. +fn fill( + properties: &IndexMap, + prefix: &str, + choices: &BTreeMap, + transient: &BTreeSet, + data: &mut BTreeMap, + fields: &mut Vec, + platform_version: &PlatformVersion, +) { + for (name, property) in properties { + if prefix.is_empty() && transient.contains(name) { + continue; + } + let path = format!("{prefix}{name}"); + let choice = choices.get(&path).copied().unwrap_or_default(); + let optional = !property.required; + let present = !optional || choice.present.unwrap_or(true); + let bounds = bounds(&property.property_type, platform_version); + let length = bounds.map(|(min, max, middle)| { + let chosen = choice.length.unwrap_or(middle).max(min); + max.map_or(chosen, |max| chosen.min(max)) + }); + fields.push(FieldSize { + path: path.clone(), + kind: kind(&property.property_type), + optional, + present, + length, + min_length: bounds.map(|(min, _, _)| min), + max_length: bounds.and_then(|(_, max, _)| max), + }); + if !present { + continue; + } + let value = match &property.property_type { + DocumentPropertyType::Object(sub_properties) => { + let mut sub_data = BTreeMap::new(); + fill( + sub_properties, + &format!("{path}."), + choices, + transient, + &mut sub_data, + fields, + platform_version, + ); + Value::Map( + sub_data + .into_iter() + .map(|(key, value)| (Value::Text(key), value)) + .collect(), + ) + } + property_type => value_of( + property_type, + length.unwrap_or_else(|| u32::from(property_type.min_size().unwrap_or_default())), + platform_version, + ), + }; + data.insert(name.clone(), value); + } +} + +/// A document of `document_type` of the sizes `choices` name, owned by a +/// placeholder identity, as a create stores it, with the fields it was +/// filled with. +pub fn sized_document( + contract: &DataContract, + document_type: DocumentTypeRef, + choices: &BTreeMap, + platform_version: &PlatformVersion, +) -> Result<(Document, Vec), Error> { + if let Some(unknown) = choices + .keys() + .find(|path| !document_type.flattened_properties().contains_key(*path)) + { + return Err(Error::Drive(DriveError::InvalidInput(format!( + "document type {} has no field {unknown}", + document_type.name() + )))); + } + let mut data = BTreeMap::new(); + let mut fields = Vec::new(); + fill( + document_type.properties(), + "", + choices, + document_type.transient_fields(), + &mut data, + &mut fields, + platform_version, + ); + + let owner_id = Identifier::from([1; 32]); + let required = document_type.required_fields(); + let time = |field: &str| required.contains(field).then_some(CREATED_AT_MS); + let height = |field: &str| required.contains(field).then_some(1u64); + let core_height = |field: &str| required.contains(field).then_some(1u32); + let creator_id = document_type + .should_use_creator_id( + contract.system_version_type(), + contract.config().version(), + platform_version, + )? + .then_some(owner_id); + let document = DocumentV0 { + contract_version: None, + id: Identifier::from([2; 32]), + owner_id, + properties: data, + revision: document_type.initial_revision(), + created_at: time("$createdAt"), + updated_at: time("$updatedAt"), + transferred_at: time("$transferredAt"), + created_at_block_height: height("$createdAtBlockHeight"), + updated_at_block_height: height("$updatedAtBlockHeight"), + transferred_at_block_height: height("$transferredAtBlockHeight"), + created_at_core_block_height: core_height("$createdAtCoreBlockHeight"), + updated_at_core_block_height: core_height("$updatedAtCoreBlockHeight"), + transferred_at_core_block_height: core_height("$transferredAtCoreBlockHeight"), + creator_id, + }; + Ok((document.into(), fields)) +} diff --git a/packages/rs-drive/src/drive/document/cost/grove_costs.rs b/packages/rs-drive/src/drive/document/cost/grove_costs.rs new file mode 100644 index 00000000000..22227cfa261 --- /dev/null +++ b/packages/rs-drive/src/drive/document/cost/grove_costs.rs @@ -0,0 +1,367 @@ +//! The storage bytes GroveDB charges for one new element, restated from +//! grovedb-merk, whose formulas are compiled only with its RocksDB storage +//! (`minimal`), so the `verify` build can price an insert too. The tests pin +//! every restated number to grovedb's own functions. +//! +//! A new element costs its key (the 32-byte subtree prefix plus the key, with +//! a length varint) and its value: the serialized element (or, for trees and +//! sum items, a fixed size standing for it), the value and node hashes, the +//! node's aggregate feature, and the link its parent keeps to it. + +use grovedb_merk::tree_type::TreeType; +use integer_encoding::VarInt; + +const HASH_LENGTH: u32 = 32; + +/// The aggregate a Merk node carries, set by the tree the node lives in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum NodeKind { + Normal, + Sum, + BigSum, + Count, + CountSum, + ProvableCount, + ProvableCountSum, + ProvableSum, + ProvableCountProvableSum, +} + +impl NodeKind { + /// The kind of the nodes of a tree of `tree_type` (`TreeType::inner_node_type`). + pub(crate) fn of_tree(tree_type: TreeType) -> Self { + match tree_type { + TreeType::NormalTree + | TreeType::CommitmentTree(_) + | TreeType::MmrTree + | TreeType::BulkAppendTree(_) + | TreeType::DenseAppendOnlyFixedSizeTree(_) + | TreeType::PrivateDocumentStore(_) => NodeKind::Normal, + TreeType::SumTree => NodeKind::Sum, + TreeType::BigSumTree => NodeKind::BigSum, + TreeType::CountTree => NodeKind::Count, + TreeType::CountSumTree => NodeKind::CountSum, + TreeType::ProvableCountTree | TreeType::ProvableCountIndexedTree => { + NodeKind::ProvableCount + } + TreeType::ProvableCountSumTree => NodeKind::ProvableCountSum, + TreeType::ProvableSumTree | TreeType::ProvableSumIndexedTree => NodeKind::ProvableSum, + TreeType::ProvableCountProvableSumTree + | TreeType::ProvableCountProvableSumIndexedTree => NodeKind::ProvableCountProvableSum, + } + } + + /// The feature bytes a node stores (`NodeType::feature_len`). + fn feature_len(self) -> u32 { + match self { + NodeKind::Normal => 1, + NodeKind::Sum | NodeKind::Count | NodeKind::ProvableCount | NodeKind::ProvableSum => 9, + NodeKind::BigSum + | NodeKind::CountSum + | NodeKind::ProvableCountSum + | NodeKind::ProvableCountProvableSum => 17, + } + } + + /// The aggregate bytes a parent's link to the node carries (`NodeType::cost`). + fn link_aggregate_len(self) -> u32 { + self.feature_len() - 1 + } +} + +fn varint_len(value: u32) -> u32 { + value.required_space() as u32 +} + +/// The link a parent node keeps to a child keyed by `key_len` bytes +/// (`Link::encoded_link_size`). +fn link_len(key_len: u32, node: NodeKind) -> u32 { + key_len + HASH_LENGTH + 4 + node.link_aggregate_len() +} + +/// The key bytes of a new node (`KV::node_key_byte_cost_size`). +pub(crate) fn key_bytes(key_len: u32) -> u32 { + HASH_LENGTH + key_len + varint_len(key_len + HASH_LENGTH) +} + +/// The value bytes of a node whose value hash it pays for itself: an item, +/// a reference or a sum item (`KV::node_value_byte_cost_size`). +fn node_value_bytes(key_len: u32, raw_value_len: u32, node: NodeKind) -> u32 { + let value_size = raw_value_len + 2 * HASH_LENGTH + node.feature_len(); + value_size + varint_len(value_size) + link_len(key_len, node) +} + +/// The value bytes of a tree element, whose value hash the root of its own +/// Merk pays for (`KV::layered_value_byte_cost_size_for_key_and_value_lengths`). +fn layered_value_bytes(key_len: u32, value_len: u32, node: NodeKind) -> u32 { + value_len + node.feature_len() + HASH_LENGTH + 2 + link_len(key_len, node) +} + +/// The fixed size standing for a tree element of `tree_type` +/// (grovedb-merk `tree_type::costs`). +fn tree_cost_size(tree_type: TreeType) -> u32 { + match tree_type { + TreeType::NormalTree => 3, + TreeType::SumTree + | TreeType::CountTree + | TreeType::ProvableCountTree + | TreeType::ProvableSumTree => 12, + TreeType::BigSumTree => 19, + TreeType::CountSumTree + | TreeType::ProvableCountSumTree + | TreeType::ProvableCountProvableSumTree => 21, + TreeType::ProvableCountIndexedTree | TreeType::ProvableSumIndexedTree => 13, + TreeType::ProvableCountProvableSumIndexedTree => 28, + TreeType::CommitmentTree(_) | TreeType::BulkAppendTree(_) => 12, + TreeType::MmrTree => 11, + TreeType::DenseAppendOnlyFixedSizeTree(_) => 6, + TreeType::PrivateDocumentStore(_) => 17, + } +} + +/// The bytes a flags field of `flags_len` bytes adds to a tree or sum item. +fn flags_bytes(flags_len: Option) -> u32 { + flags_len.map_or(0, |len| len + varint_len(len)) +} + +/// The element GroveDB prices, as far as its storage cost goes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PricedElement { + /// An empty tree of `tree_type`, `wrapped` in a zero-contribution + /// wrapper or not, with flags of `flags_len` bytes. + Tree { + tree_type: TreeType, + wrapped: bool, + flags_len: Option, + }, + /// An item or reference (with or without a sum) whose serialized element + /// is `serialized_len` bytes. + Serialized { serialized_len: u32 }, + /// An item that also carries a sum, holding `item_len` bytes, with flags + /// of `flags_len` bytes. + ItemWithSumItem { + item_len: u32, + flags_len: Option, + }, +} + +/// The storage bytes GroveDB adds for a new `element` under a key of +/// `key_len` bytes in a tree whose nodes are `node` nodes. +pub(crate) fn new_element_bytes(key_len: u32, element: PricedElement, node: NodeKind) -> u32 { + let value = match element { + PricedElement::Tree { + tree_type, + wrapped, + flags_len, + } => layered_value_bytes( + key_len, + tree_cost_size(tree_type) + flags_bytes(flags_len) + u32::from(wrapped), + node, + ), + PricedElement::Serialized { serialized_len } => { + node_value_bytes(key_len, serialized_len, node) + } + PricedElement::ItemWithSumItem { + item_len, + flags_len, + } => node_value_bytes( + key_len, + item_len + varint_len(item_len) + 11 + flags_bytes(flags_len), + node, + ), + }; + key_bytes(key_len) + value +} + +#[cfg(all(test, feature = "server"))] +mod tests { + use super::*; + use grovedb::Element; + use grovedb_merk::element::costs::ElementCostExtensions; + use grovedb_merk::tree::kv::KV; + use grovedb_version::version::GroveVersion; + + const TREE_TYPES: [TreeType; 12] = [ + TreeType::NormalTree, + TreeType::SumTree, + TreeType::BigSumTree, + TreeType::CountTree, + TreeType::CountSumTree, + TreeType::ProvableCountTree, + TreeType::ProvableCountSumTree, + TreeType::ProvableSumTree, + TreeType::ProvableCountProvableSumTree, + TreeType::ProvableSumIndexedTree, + TreeType::ProvableCountIndexedTree, + TreeType::ProvableCountProvableSumIndexedTree, + ]; + + #[test] + fn should_give_each_tree_the_node_kind_grovedb_gives_it() { + for tree_type in TREE_TYPES { + let grovedb = tree_type.inner_node_type(); + let ours = NodeKind::of_tree(tree_type); + assert_eq!(ours.feature_len(), grovedb.feature_len(), "{tree_type:?}"); + assert_eq!(ours.link_aggregate_len(), grovedb.cost(), "{tree_type:?}"); + } + } + + #[test] + fn should_price_keys_and_values_as_grovedb_does() { + for tree_type in TREE_TYPES { + let node = NodeKind::of_tree(tree_type); + let grovedb_node = tree_type.inner_node_type(); + for key_len in [0u32, 1, 8, 32, 95, 96, 200, 255] { + assert_eq!(key_bytes(key_len), KV::node_key_byte_cost_size(key_len)); + for raw in [0u32, 10, 63, 64, 100, 127, 128, 1000, 16_500] { + assert_eq!( + node_value_bytes(key_len, raw, node), + KV::node_value_byte_cost_size(key_len, raw, grovedb_node), + "{tree_type:?} key {key_len} raw {raw}" + ); + assert_eq!( + layered_value_bytes(key_len, raw, node), + KV::layered_value_byte_cost_size_for_key_and_value_lengths( + key_len, + raw, + grovedb_node + ), + "{tree_type:?} key {key_len} raw {raw}" + ); + } + } + } + } + + #[test] + fn should_price_tree_elements_as_grovedb_does() { + let grove_version = GroveVersion::latest(); + let flags = Some(vec![7u8; 35]); + for parent in TREE_TYPES { + for key in [vec![1u8; 1], vec![2u8; 32], vec![3u8; 60]] { + for (tree_type, element) in [ + ( + TreeType::NormalTree, + Element::empty_tree_with_flags(flags.clone()), + ), + ( + TreeType::SumTree, + Element::empty_sum_tree_with_flags(flags.clone()), + ), + ( + TreeType::CountTree, + Element::empty_count_tree_with_flags(flags.clone()), + ), + ( + TreeType::CountSumTree, + Element::empty_count_sum_tree_with_flags(flags.clone()), + ), + ( + TreeType::ProvableCountTree, + Element::empty_provable_count_tree_with_flags(flags.clone()), + ), + (TreeType::NormalTree, Element::empty_tree_with_flags(None)), + ] { + let serialized = element.serialize(grove_version).expect("serialize"); + let grovedb = Element::specialized_costs_for_key_value( + &key, + &serialized, + parent.inner_node_type(), + grove_version, + ) + .expect("cost"); + let flags_len = match &element { + Element::Tree(_, f) + | Element::SumTree(_, _, f) + | Element::CountTree(_, _, f) + | Element::CountSumTree(_, _, _, f) + | Element::ProvableCountTree(_, _, f) => f.as_ref().map(|f| f.len() as u32), + _ => unreachable!(), + }; + let ours = new_element_bytes( + key.len() as u32, + PricedElement::Tree { + tree_type, + wrapped: false, + flags_len, + }, + NodeKind::of_tree(parent), + ) - key_bytes(key.len() as u32); + assert_eq!(ours, grovedb, "{tree_type:?} in {parent:?}"); + + let wrapped = Element::NonCounted(Box::new(element.clone())) + .serialize(grove_version) + .expect("serialize"); + let grovedb_wrapped = Element::specialized_costs_for_key_value( + &key, + &wrapped, + parent.inner_node_type(), + grove_version, + ) + .expect("cost"); + let ours_wrapped = new_element_bytes( + key.len() as u32, + PricedElement::Tree { + tree_type, + wrapped: true, + flags_len, + }, + NodeKind::of_tree(parent), + ) - key_bytes(key.len() as u32); + assert_eq!(ours_wrapped, grovedb_wrapped, "wrapped {tree_type:?}"); + } + } + } + } + + #[test] + fn should_price_items_and_sum_items_as_grovedb_does() { + let grove_version = GroveVersion::latest(); + for parent in TREE_TYPES { + let node = NodeKind::of_tree(parent); + for len in [0usize, 5, 100, 300, 5000] { + let item = Element::new_item_with_flags(vec![1; len], Some(vec![2; 35])); + let serialized = item.serialize(grove_version).expect("serialize"); + let grovedb = Element::specialized_costs_for_key_value( + &[9; 32], + &serialized, + parent.inner_node_type(), + grove_version, + ) + .expect("cost"); + assert_eq!( + new_element_bytes( + 32, + PricedElement::Serialized { + serialized_len: serialized.len() as u32 + }, + node + ) - key_bytes(32), + grovedb + ); + + let with_sum = + Element::new_item_with_sum_item_with_flags(vec![1; len], 42, Some(vec![2; 35])); + let serialized = with_sum.serialize(grove_version).expect("serialize"); + let grovedb = Element::specialized_costs_for_key_value( + &[9; 32], + &serialized, + parent.inner_node_type(), + grove_version, + ) + .expect("cost"); + assert_eq!( + new_element_bytes( + 32, + PricedElement::ItemWithSumItem { + item_len: len as u32, + flags_len: Some(35) + }, + node + ) - key_bytes(32), + grovedb + ); + } + } + } +} diff --git a/packages/rs-drive/src/drive/document/cost/mod.rs b/packages/rs-drive/src/drive/document/cost/mod.rs new file mode 100644 index 00000000000..c0ab55876fa --- /dev/null +++ b/packages/rs-drive/src/drive/document/cost/mod.rs @@ -0,0 +1,1201 @@ +//! What creating one document costs: the storage every element the insert +//! writes adds (exact, from the elements Drive builds and GroveDB's byte +//! formulas), split by index into the layers an index shares with others +//! and the layers only it uses; the processing (the fixed charges of a +//! signed batch, exact, and the work of the writes, estimated for a given +//! number of stored documents); what the contract adds (action fees, a +//! token cost, a contest's vote fund); and what a delete refunds. +//! +//! Two scenarios bound the storage: every index value new (the first +//! document with these values creates their trees) and every value already +//! stored (a later document with the same values adds only its own entries; +//! a unique index still adds its value, which no earlier document can hold). +//! +//! The storage is held to real inserts by `tests::should_price_what_drive_charges`. + +mod document; +mod grove_costs; +mod writes; + +pub use document::{sized_document, FieldChoice, FieldSize}; + +use crate::drive::document::cost::grove_costs::{new_element_bytes, NodeKind, PricedElement}; +use crate::drive::document::cost::writes::{document_writes, Write}; +use crate::drive::document::expiration::pricing::{ + document_expiration_cleanup_fee, document_ttl_credit_per_byte, +}; +use crate::drive::document::layout::LayoutRole; +use crate::drive::document::sdk_value::{map, number, text, texts}; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV1Getters, DocumentTypeV2Getters, +}; +use dpp::data_contract::document_type::action_fees::{ActionFeePricing, DocumentActionFee}; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::DataContract; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::{Document, DocumentV0Getters}; +#[cfg(feature = "fee-distribution")] +use dpp::fee::epoch::distribution::calculate_storage_fee_refund_amount_and_leftovers; +use dpp::fee::epoch::DEFAULT_EPOCHS_PER_ERA; +use dpp::fee::Credits; +use dpp::identity::KeyType; +use dpp::platform_value::Value; +use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; +use dpp::tokens::token_amount_on_contract_token::{ + DocumentActionTokenCost, DocumentActionTokenEffect, +}; +use dpp::version::PlatformVersion; +use dpp::voting::vote_polls::contested_document_resource_vote_poll::required_vote_resolution_fund_to_join; +use grovedb::element::IndexAxis; + +/// Credits in one Dash. +pub const CREDITS_PER_DASH: Credits = 100_000_000_000; + +/// What the estimate assumes about the network and the transition. +#[derive(Clone, Debug, PartialEq)] +pub struct CostAssumptions { + /// Documents of the type already stored, each with its own values: the + /// trees an insert walks are as deep as that many entries make them. + pub existing_documents: u64, + /// The type of the key the transition is signed with. + pub signature_key_type: KeyType, + /// The fee increase the transition asks for, in percent of the + /// processing fee. + pub user_fee_increase: u16, + /// The epoch's fee multiplier in permille, for action fees priced by it. + pub fee_multiplier_permille: u64, + /// Contenders already in the contest, for a contested create. + pub contenders: u16, +} + +impl CostAssumptions { + /// A thousand stored documents, an ECDSA key, no fee increase, the + /// version's fee multiplier and an empty contest. + pub fn new(platform_version: &PlatformVersion) -> Self { + CostAssumptions { + existing_documents: 1_000, + signature_key_type: KeyType::ECDSA_SECP256K1, + user_fee_increase: 0, + fee_multiplier_permille: platform_version + .fee_version + .uses_version_fee_multiplier_permille + .unwrap_or(1_000), + contenders: 0, + } + } +} + +/// A byte count or an amount under both scenarios. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Scenarios { + /// Every index value new. + pub new_values: u64, + /// Every value an earlier document can hold already stored. + pub known_values: u64, +} + +impl Scenarios { + fn add(&mut self, other: Scenarios) { + self.new_values = self.new_values.saturating_add(other.new_values); + self.known_values = self.known_values.saturating_add(other.known_values); + } + + fn scale(self, factor: u64) -> Scenarios { + Scenarios { + new_values: self.new_values.saturating_mul(factor), + known_values: self.known_values.saturating_mul(factor), + } + } +} + +/// One element the insert writes, and the storage it adds. +#[derive(Clone, Debug, PartialEq)] +pub struct ElementCost { + /// What the element is. + pub role: LayoutRole, + /// The element's path below the document type tree, and its key, as + /// readable text. + pub path: Vec, + /// The storage bytes the element adds when it is written. + pub bytes: u64, + /// The indexes that use the element; empty for primary storage. + pub indexes: Vec, + /// Written only when absent: a tree an earlier document with the same + /// values created. + pub if_absent: bool, + /// Written even when every value is already stored. + pub written_when_values_known: bool, + /// On a time window with a `ttl`: priced as processing, not storage. + pub ephemeral: bool, + /// The document type whose tree the element is in, when it is not the + /// created document's: a preallocated index of a type referring to it. + pub referring_type: Option, + /// In the documents expirations tree: a document with a `ttl`'s entry. + pub expiration: bool, + /// The axis, when the element is a ranked tree's row for the value. + pub ranking_axis: Option, +} + +/// The storage one index adds. +#[derive(Clone, Debug, PartialEq)] +pub struct IndexCost { + /// The index. + pub name: String, + /// The indexes it shares layers with (a common prefix of properties). + pub shared_with: Vec, + /// The bytes of the layers it shares; counted once for the document. + pub shared_bytes: Scenarios, + /// The bytes of the layers only it uses. + pub own_bytes: Scenarios, +} + +/// A part of the processing fee. +#[derive(Clone, Debug, PartialEq)] +pub struct ProcessingCost { + /// A stable code. + pub code: &'static str, + /// What it is. + pub text: String, + /// The credits. + pub credits: Scenarios, + /// Whether the amount is exact, or estimated from the assumptions. + pub exact: bool, +} + +/// A charge the contract adds to the create. +#[derive(Clone, Debug, PartialEq)] +pub enum ContractCharge { + /// The create's action fee, paid into the contract's fee pots. + ActionFee { + /// The amounts the contract declares. + declared: DocumentActionFee, + /// How they are priced. + pricing: ActionFeePricing, + /// The amounts charged at the assumed fee multiplier. + charged: DocumentActionFee, + }, + /// The create's token cost, in tokens. + TokenCost(DocumentActionTokenCost), + /// The vote fund a contender pays when the value is contested. Such a + /// create is stored in the contest's vote poll until the contest ends, + /// not in the index: the storage priced here is an uncontested create's. + ContestFund { + /// The contested index. + index: String, + /// The credits, at the assumed number of contenders. + credits: Credits, + }, +} + +/// What creating one document of a type costs. +#[derive(Clone, Debug, PartialEq)] +pub struct DocumentCreateCost { + /// The document type name. + pub document_type: String, + /// What the estimate assumes. + pub assumptions: CostAssumptions, + /// The serialized document, in bytes. + pub document_bytes: u64, + /// Credits per stored byte. + pub credits_per_byte: Credits, + /// Every element the insert writes. + pub elements: Vec, + /// The bytes of primary storage (the document by id). + pub primary_bytes: Scenarios, + /// The bytes of the trees preallocated for entries of other types that + /// will reference the document. + pub preallocated_bytes: Scenarios, + /// The bytes of a document with a `ttl`'s entry in the documents + /// expirations tree. + pub expiration_bytes: Scenarios, + /// The bytes each index adds. + pub indexes: Vec, + /// All the storage bytes. + pub storage_bytes: Scenarios, + /// The storage fee. + pub storage_credits: Scenarios, + /// The processing fee, part by part (the fee increase included). + pub processing: Vec, + /// What the contract adds. + pub contract_charges: Vec, + /// The storage fee a delete in the same epoch refunds (with the + /// `fee-distribution` feature). + pub refund_same_epoch: Option, + /// The storage fee a delete a year (an era of epochs) later refunds + /// (with the `fee-distribution` feature). + pub refund_after_one_year: Option, + /// How each field of the priced document was filled, when it was built + /// from sizes ([`document_type_create_cost`]). + pub fields: Vec, +} + +impl DocumentCreateCost { + /// The processing fee. + pub fn processing_credits(&self) -> Scenarios { + let mut total = Scenarios::default(); + for part in &self.processing { + total.add(part.credits); + } + total + } + + /// The credits the create costs in fees and action fees, without a + /// token cost or a contest fund. + pub fn total_credits(&self) -> Scenarios { + let mut total = self.storage_credits; + total.add(self.processing_credits()); + for charge in &self.contract_charges { + if let ContractCharge::ActionFee { charged, .. } = charge { + let credits = charged.owner.saturating_add(charged.moderators); + total.add(Scenarios { + new_values: credits, + known_values: credits, + }); + } + } + total + } +} + +/// What creating `document` as a document of `document_type` costs under +/// `assumptions`. +/// +/// Follows the insert methods of protocol version 14, like +/// `drive::document::layout`; a version with other ones is refused. A +/// document with a `ttl` pays for its bytes by its lifetime, carries no flags +/// (so nothing is refunded) and prepays its deletion. The storage is an +/// uncontested create's: a value that starts a contest is stored in the +/// contest's vote poll until it ends, which this does not price. +pub fn document_create_cost( + contract: &DataContract, + document_type: DocumentTypeRef, + document: &Document, + assumptions: &CostAssumptions, + platform_version: &PlatformVersion, +) -> Result { + check_mirrored_method_versions(platform_version)?; + let fee_version = &platform_version.fee_version; + // A document with a `ttl` pays for its bytes by the lifetime it has + // left, all of its `ttl` when it is created. + let credits_per_byte = match document_type.documents_ttl_seconds() { + Some(ttl_seconds) => { + document_ttl_credit_per_byte(u64::from(ttl_seconds) * 1000, fee_version)? + } + None => fee_version.storage.storage_disk_usage_credit_per_byte, + }; + let serialized = document.serialize(document_type, contract, platform_version)?; + let document_bytes = serialized.len() as u64; + let writes = document_writes( + contract, + document_type, + document, + &serialized, + platform_version, + )?; + let known = written_when_values_known(&writes, document.id().as_slice()); + + let mut elements = Vec::with_capacity(writes.len()); + let mut primary_bytes = Scenarios::default(); + let mut preallocated_bytes = Scenarios::default(); + let mut expiration_bytes = Scenarios::default(); + let mut storage_bytes = Scenarios::default(); + let mut ephemeral_bytes = Scenarios::default(); + for (write, known) in writes.iter().zip(known.iter().copied()) { + let bytes = u64::from(new_element_bytes( + write.key.len() as u32, + write.element, + NodeKind::of_tree(write.parent), + )); + let scenarios = Scenarios { + new_values: bytes, + known_values: if known { bytes } else { 0 }, + }; + if write.ephemeral { + ephemeral_bytes.add(scenarios); + } else { + storage_bytes.add(scenarios); + if write.referring_type.is_some() { + preallocated_bytes.add(scenarios); + } else if write.expiration { + expiration_bytes.add(scenarios); + } else if write.indexes.is_empty() { + primary_bytes.add(scenarios); + } + } + elements.push(ElementCost { + role: write.role, + path: readable_path(write), + bytes, + indexes: write.indexes.clone(), + if_absent: write.if_absent, + written_when_values_known: known, + ephemeral: write.ephemeral, + referring_type: write.referring_type.clone(), + expiration: write.expiration, + ranking_axis: write.ranking.as_ref().map(|row| row.axis), + }); + } + + let indexes = index_costs(document_type, &elements); + let storage_credits = storage_bytes.scale(credits_per_byte); + let processing = processing_costs( + document_type, + &writes, + &known, + ephemeral_bytes, + document_bytes, + assumptions, + platform_version, + )?; + let contract_charges = + contract_charges(contract, document_type, assumptions, platform_version)?; + + // A document with a `ttl` carries no flags: nothing of it is refunded. + let refundable = if document_type.documents_ttl_seconds().is_some() { + Scenarios::default() + } else { + refundable_bytes(&writes, &known, &elements) + }; + let refund_same_epoch = refund(document_type, refundable, credits_per_byte, 0)?; + let refund_after_one_year = refund( + document_type, + refundable, + credits_per_byte, + DEFAULT_EPOCHS_PER_ERA, + )?; + + Ok(DocumentCreateCost { + document_type: document_type.name().clone(), + assumptions: assumptions.clone(), + document_bytes, + credits_per_byte, + elements, + primary_bytes, + preallocated_bytes, + expiration_bytes, + indexes, + storage_bytes, + storage_credits, + processing, + contract_charges, + refund_same_epoch, + refund_after_one_year, + fields: Vec::new(), + }) +} + +/// Refuses a platform version whose insert methods are not the ones the +/// estimate mirrors (those of protocol version 14): another version of any +/// of them may write other elements. +fn check_mirrored_method_versions(platform_version: &PlatformVersion) -> Result<(), Error> { + let document = &platform_version.drive.methods.document; + for (method, known, received) in [ + ( + "add_document_for_contract_operations", + 1, + document.insert.add_document_for_contract_operations, + ), + ( + "add_document_to_primary_storage", + 0, + document.insert.add_document_to_primary_storage, + ), + ( + "add_indices_for_top_index_level_for_contract_operations", + 2, + document + .insert + .add_indices_for_top_index_level_for_contract_operations, + ), + ( + "add_indices_for_index_level_for_contract_operations", + 2, + document + .insert + .add_indices_for_index_level_for_contract_operations, + ), + ( + "add_reference_for_index_level_for_contract_operations", + 0, + document + .insert + .add_reference_for_index_level_for_contract_operations, + ), + ( + "add_document_expiration_operations", + 0, + document.expiration.add_document_expiration_operations, + ), + ] { + if received != known { + return Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: format!("document_create_cost ({method})"), + known_versions: vec![known], + received, + })); + } + } + Ok(()) +} + +/// What creating a document of `document_type` costs, for a document of +/// the sizes `choices` name (every other variable-size value at its middle +/// size, every optional value present). +pub fn document_type_create_cost( + contract: &DataContract, + document_type: DocumentTypeRef, + choices: &std::collections::BTreeMap, + assumptions: &CostAssumptions, + platform_version: &PlatformVersion, +) -> Result { + let (document, fields) = sized_document(contract, document_type, choices, platform_version)?; + let mut cost = document_create_cost( + contract, + document_type, + &document, + assumptions, + platform_version, + )?; + cost.fields = fields; + Ok(cost) +} + +/// The storage fee of `bytes` a delete `epochs_later` epochs after the +/// create refunds: what the epochs still to come would have been paid. +#[cfg(feature = "fee-distribution")] +fn refund( + document_type: DocumentTypeRef, + bytes: Scenarios, + credits_per_byte: Credits, + epochs_later: u16, +) -> Result, Error> { + if !document_type.documents_can_be_deleted() { + return Ok(Some(Scenarios::default())); + } + let refund = |bytes: u64| -> Result { + let (refund, _) = calculate_storage_fee_refund_amount_and_leftovers( + bytes.saturating_mul(credits_per_byte), + 0, + epochs_later, + DEFAULT_EPOCHS_PER_ERA, + )?; + Ok(refund) + }; + Ok(Some(Scenarios { + new_values: refund(bytes.new_values)?, + known_values: refund(bytes.known_values)?, + })) +} + +#[cfg(not(feature = "fee-distribution"))] +fn refund( + _document_type: DocumentTypeRef, + _bytes: Scenarios, + _credits_per_byte: Credits, + _epochs_later: u16, +) -> Result, Error> { + Ok(None) +} + +/// Which writes happen even when every value an earlier document can hold +/// is stored: the ones every insert makes; a document with a `ttl`'s entry +/// in the expirations tree (a later document expires at another time); and +/// a tree no earlier document can have made, with everything under it: the +/// value tree of a unique index with no null value, and a tree keyed by the +/// document's own id (`document_id`), such as a preallocated index's. +fn written_when_values_known(writes: &[Write], document_id: &[u8]) -> Vec { + // A tree, named by where its path starts and its full path. + let tree = + |write: &Write, path: Vec>| (write.referring_type.clone(), write.expiration, path); + let full_path = |write: &Write| { + let mut full = write.path.clone(); + full.push(write.key.clone()); + full + }; + let always_new: Vec<_> = writes + .iter() + .filter_map(|write| { + let unique_value = write.role == LayoutRole::Terminal + && !write.if_absent + && matches!(write.element, PricedElement::Serialized { .. }) + && !write.indexes.is_empty(); + if unique_value { + Some(tree(write, write.path.clone())) + } else if write.if_absent && write.key == document_id { + Some(tree(write, full_path(write))) + } else { + None + } + }) + .collect(); + writes + .iter() + .map(|write| { + if !write.if_absent || write.expiration { + return true; + } + let (area, expiration, full) = tree(write, full_path(write)); + always_new + .iter() + .any(|(new_area, new_expiration, new_path)| { + *new_area == area && *new_expiration == expiration && full.starts_with(new_path) + }) + }) + .collect() +} + +/// The storage bytes a delete refunds: those of the elements it removes that +/// carry the owner's flags. Not refunded: elements without flags (a ranked +/// tree's rows, trees an index writes without flags), ephemeral elements, +/// and preallocated trees, which a delete keeps. +fn refundable_bytes(writes: &[Write], known: &[bool], elements: &[ElementCost]) -> Scenarios { + let mut total = Scenarios::default(); + for ((write, known), element) in writes.iter().zip(known).zip(elements) { + if write.flagged && !write.ephemeral && write.referring_type.is_none() { + total.add(Scenarios { + new_values: element.bytes, + known_values: if *known { element.bytes } else { 0 }, + }); + } + } + total +} + +fn readable_key(key: &[u8]) -> String { + match std::str::from_utf8(key) { + Ok(text) if !text.is_empty() && text.chars().all(|c| !c.is_control()) => text.to_string(), + _ => format!("0x{}", hex::encode(key)), + } +} + +fn readable_path(write: &Write) -> Vec { + write + .referring_type + .iter() + .cloned() + .chain( + write + .path + .iter() + .chain(std::iter::once(&write.key)) + .map(|key| readable_key(key)), + ) + .collect() +} + +/// Each index's shared and own bytes. A layer used by several indexes is +/// shared by them; the document pays for it once. +fn index_costs(document_type: DocumentTypeRef, elements: &[ElementCost]) -> Vec { + document_type + .indexes() + .keys() + .map(|name| { + let mut shared_with = Vec::new(); + let mut shared_bytes = Scenarios::default(); + let mut own_bytes = Scenarios::default(); + for element in elements.iter().filter(|element| { + !element.ephemeral + && element.referring_type.is_none() + && element.indexes.contains(name) + }) { + let scenarios = Scenarios { + new_values: element.bytes, + known_values: if element.written_when_values_known { + element.bytes + } else { + 0 + }, + }; + if element.indexes.len() > 1 { + shared_bytes.add(scenarios); + for other in element.indexes.iter().filter(|other| *other != name) { + if !shared_with.contains(other) { + shared_with.push(other.clone()); + } + } + } else { + own_bytes.add(scenarios); + } + } + shared_with.sort(); + IndexCost { + name: name.clone(), + shared_with, + shared_bytes, + own_bytes, + } + }) + .collect() +} + +/// The nodes above a new node in a balanced tree of `entries` entries: +/// about the base-2 logarithm of the count. +fn path_height(entries: u64) -> u64 { + u64::from(64 - entries.leading_zeros()) + .saturating_sub(1) + .max(u64::from(entries > 0)) +} + +/// The processing fee, part by part. +#[allow(clippy::too_many_arguments)] +fn processing_costs( + document_type: DocumentTypeRef, + writes: &[Write], + known: &[bool], + ephemeral_bytes: Scenarios, + document_bytes: u64, + assumptions: &CostAssumptions, + platform_version: &PlatformVersion, +) -> Result, Error> { + let fee_version = &platform_version.fee_version; + // The primary key tree and one tree per first index level. + let type_entries = u64::from(!document_type.index_only()) + + document_type.index_structure().sub_levels().len() as u64; + let exact = |credits: Credits| Scenarios { + new_values: credits, + known_values: credits, + }; + // A known value's ranked row adds no storage, but it still moves in its + // secondary tree (a delete and an insert), which costs processing. + let known_with_row_moves: Vec = writes + .iter() + .zip(known) + .map(|(write, known)| *known || write.ranking.is_some()) + .collect(); + let mut parts = vec![ + ProcessingCost { + code: "signature", + text: format!( + "verifying the transition's {:?} signature", + assumptions.signature_key_type + ), + credits: exact( + assumptions + .signature_key_type + .signature_verify_cost(platform_version)?, + ), + exact: true, + }, + ProcessingCost { + code: "identity", + text: "fetching the signing key and the balance of the identity".to_string(), + credits: exact( + fee_version + .processing + .fetch_identity_revision_processing_cost + .saturating_add( + fee_version + .processing + .fetch_identity_cost_per_look_up_key_by_id, + ), + ), + exact: true, + }, + ProcessingCost { + code: "writes", + text: format!( + "writing the elements: seeks, hashing and rewriting the paths to them in trees \ + of about {} entries", + assumptions.existing_documents + ), + credits: Scenarios { + new_values: write_processing( + writes, + &[], + type_entries, + assumptions, + platform_version, + ), + known_values: write_processing( + writes, + &known_with_row_moves, + type_entries, + assumptions, + platform_version, + ), + }, + exact: false, + }, + ProcessingCost { + code: "checks", + text: "checking the document does not exist yet and bumping the identity's nonce \ + for the contract" + .to_string(), + credits: exact(checks_processing(assumptions, platform_version)), + exact: false, + }, + ]; + if document_type.documents_ttl_seconds().is_some() { + parts.push(ProcessingCost { + code: "ttlCleanup", + text: "the deletion of the document when its ttl runs out, prepaid".to_string(), + credits: exact(document_expiration_cleanup_fee( + document_type, + document_bytes, + fee_version, + )?), + exact: true, + }); + } + if ephemeral_bytes.new_values > 0 { + parts.push(ProcessingCost { + code: "timeWindowTtl", + text: "time window entries that expire, priced as processing".to_string(), + credits: ephemeral_bytes + .scale(fee_version.storage.ttl_ephemeral_disk_usage_credit_per_byte), + exact: true, + }); + } + if assumptions.user_fee_increase > 0 { + let mut subtotal = Scenarios::default(); + for part in &parts { + subtotal.add(part.credits); + } + let increase = u64::from(assumptions.user_fee_increase); + parts.push(ProcessingCost { + code: "feeIncrease", + text: format!("the {increase}% fee increase the transition asks for"), + credits: Scenarios { + new_values: subtotal.new_values.saturating_mul(increase) / 100, + known_values: subtotal.known_values.saturating_mul(increase) / 100, + }, + exact: false, + }); + } + Ok(parts) +} + +/// The heights of the trees above a document type tree: the contracts' +/// documents (keyed by contract, about a thousand), the contract's own tree +/// and its document types. +const TREES_ABOVE_THE_TYPE: [u64; 3] = [10, 1, 2]; + +/// The processing of the writes: every tree that gets a new element is +/// walked from its root to the new node and that path is rewritten and +/// rehashed, and so is the element of every tree above it, up to the +/// root; every insert-if-absent first reads the element. `known` (when not +/// empty) leaves out the writes a stored value already made. +/// +/// How deep those paths are depends on how many entries each tree holds, +/// assumed from `existing_documents` stored documents with values of their +/// own: the document type tree holds its `type_entries` trees; its documents +/// tree, its first index levels, a ranked tree's rows and the expirations +/// tree hold one entry per document; a tree this insert creates holds none, +/// and any other tree (under one value) the one earlier document with it. +fn write_processing( + writes: &[Write], + known: &[bool], + type_entries: u64, + assumptions: &CostAssumptions, + platform_version: &PlatformVersion, +) -> Credits { + let fee = &platform_version.fee_version; + let seek = fee.storage.storage_seek_cost; + let per_written_byte = fee.storage.storage_processing_credit_per_byte; + let per_loaded_byte = fee.storage.storage_load_credit_per_byte; + let per_hash = fee + .hashing + .blake3_base + .saturating_add(fee.hashing.blake3_per_block); + + // A tree, named by where its path starts (the created document's type, + // a referring type, the expirations tree) and its path from there. + type Tree = (Option, bool, Vec>); + let tree_of = |write: &Write, path: Vec>| -> Tree { + (write.referring_type.clone(), write.expiration, path) + }; + + let written: Vec<&Write> = writes + .iter() + .enumerate() + .filter(|(i, _)| known.is_empty() || known[*i]) + .map(|(_, write)| write) + .collect(); + let created: Vec = written + .iter() + .filter(|write| matches!(write.element, PricedElement::Tree { .. })) + .map(|write| { + let mut full = write.path.clone(); + full.push(write.key.clone()); + tree_of(write, full) + }) + .collect(); + let entries_before = |tree: &Tree| -> u64 { + let (_, expiration, path) = tree; + let is_ranking_rows = path + .last() + .is_some_and(|key| key.len() == 2 && key[0] == 0xff); + if created.contains(tree) { + 0 + } else if path.is_empty() { + if *expiration { + assumptions.existing_documents + } else { + type_entries + } + } else if (path.len() == 1 && !*expiration) || is_ranking_rows { + assumptions.existing_documents + } else { + 1 + } + }; + + let mut credits: Credits = 0; + // Every insert-if-absent reads the element first, written or not. + for write in writes.iter().filter(|write| write.if_absent) { + let bytes = u64::from(new_element_bytes( + write.key.len() as u32, + write.element, + NodeKind::of_tree(write.parent), + )); + credits = credits + .saturating_add(seek) + .saturating_add(bytes.saturating_mul(per_loaded_byte)); + } + // Each new element: its put, its bytes and its hashes (value, key-value + // and node hash). + let mut touched: Vec<(Tree, u64)> = Vec::new(); + for write in &written { + let bytes = u64::from(new_element_bytes( + write.key.len() as u32, + write.element, + NodeKind::of_tree(write.parent), + )); + credits = credits + .saturating_add(seek) + .saturating_add(bytes.saturating_mul(per_written_byte)) + .saturating_add(4 * per_hash); + // Every tree from the element's tree up to where its path starts + // has its path rewritten once. + for depth in (0..=write.path.len()).rev() { + let tree = tree_of(write, write.path[..depth].to_vec()); + if !touched.iter().any(|(seen, _)| *seen == tree) { + touched.push((tree, bytes)); + } + } + } + let rewrite = |height: u64, node_bytes: u64| -> Credits { + height.saturating_mul( + seek.saturating_add(node_bytes.saturating_mul(per_written_byte + per_loaded_byte)) + .saturating_add(2 * per_hash), + ) + }; + for (tree, node_bytes) in &touched { + credits = credits.saturating_add(rewrite(path_height(entries_before(tree)), *node_bytes)); + } + if !written.is_empty() { + for height in TREES_ABOVE_THE_TYPE { + credits = credits.saturating_add(rewrite(height, 150)); + } + } + credits +} + +/// The small reads and writes around the insert: the query that checks the +/// document id is free and the nonce the identity keeps for the contract. +fn checks_processing(assumptions: &CostAssumptions, platform_version: &PlatformVersion) -> Credits { + let fee = &platform_version.fee_version; + let height = path_height(assumptions.existing_documents).max(1); + let per_hash = fee.hashing.blake3_base + fee.hashing.blake3_per_block; + let lookup = + height * (fee.storage.storage_seek_cost + 100 * fee.storage.storage_load_credit_per_byte); + let nonce = fee.storage.storage_seek_cost * 4 + + 60 * fee.storage.storage_processing_credit_per_byte + + 6 * per_hash; + lookup + nonce +} + +/// The charges the contract adds to a create. +fn contract_charges( + contract: &DataContract, + document_type: DocumentTypeRef, + assumptions: &CostAssumptions, + platform_version: &PlatformVersion, +) -> Result, Error> { + let mut charges = Vec::new(); + if let Some(fees) = document_type.action_fees() { + if let Some(declared) = fees.document_creation_action_fee() { + let pricing = fees.pricing(); + charges.push(ContractCharge::ActionFee { + declared, + pricing, + charged: declared.charged(pricing, assumptions.fee_multiplier_permille)?, + }); + } + } + if let Some(token_cost) = document_type.document_creation_token_cost() { + charges.push(ContractCharge::TokenCost(token_cost)); + } + if let Some(index) = document_type.find_contested_index() { + charges.push(ContractCharge::ContestFund { + index: index.name.clone(), + credits: required_vote_resolution_fund_to_join( + contract.id_ref(), + document_type.name(), + assumptions.contenders, + platform_version, + ), + }); + } + Ok(charges) +} + +fn scenarios(value: Scenarios) -> Value { + map(vec![ + ("newValues", number(value.new_values)), + ("knownValues", number(value.known_values)), + ]) +} + +fn action_fee(fee: DocumentActionFee) -> Value { + map(vec![ + ("owner", number(fee.owner)), + ("moderators", number(fee.moderators)), + ]) +} + +impl ContractCharge { + fn to_value(&self) -> Value { + match self { + ContractCharge::ActionFee { + declared, + pricing, + charged, + } => map(vec![ + ("kind", text("actionFee")), + ( + "pricing", + text(match pricing { + ActionFeePricing::FeeMultiplier => "feeMultiplier", + ActionFeePricing::Fixed => "fixed", + }), + ), + ("declared", action_fee(*declared)), + ("charged", action_fee(*charged)), + ]), + ContractCharge::TokenCost(cost) => { + let mut entries = vec![ + ("kind", text("tokenCost")), + ( + "tokenPosition", + number(u64::from(cost.token_contract_position)), + ), + ("amount", number(cost.token_amount)), + ( + "effect", + text(match cost.effect { + DocumentActionTokenEffect::TransferTokenToContractOwner => { + "transferToContractOwner" + } + DocumentActionTokenEffect::BurnToken => "burn", + }), + ), + ( + "gasFeesPaidBy", + text(match cost.gas_fees_paid_by { + GasFeesPaidBy::DocumentOwner => "documentOwner", + GasFeesPaidBy::ContractOwner => "contractOwner", + GasFeesPaidBy::PreferContractOwner => "preferContractOwner", + }), + ), + ("optional", Value::Bool(cost.optional)), + ]; + if let Some(contract_id) = cost.contract_id { + entries.push(( + "tokenContractId", + text( + &contract_id + .to_string(dpp::platform_value::string_encoding::Encoding::Base58), + ), + )); + } + map(entries) + } + ContractCharge::ContestFund { index, credits } => map(vec![ + ("kind", text("contestFund")), + ("index", text(index)), + ("credits", number(*credits)), + ]), + } + } +} + +impl DocumentCreateCost { + /// The estimate as a plain value, amounts in credits, a key left out + /// when it has no value: + /// `{ documentType, assumptions, documentBytes, creditsPerByte, + /// creditsPerDash, storage: { bytes, credits, primaryBytes }, indexes, + /// elements, processing, processingCredits, contractCharges, + /// refund: { sameEpoch, afterOneYear }, totalCredits }`; every amount + /// that depends on the scenario is `{ newValues, knownValues }`. + pub fn to_value(&self) -> Value { + map(vec![ + ("documentType", text(&self.document_type)), + ( + "assumptions", + map(vec![ + ( + "existingDocuments", + number(self.assumptions.existing_documents), + ), + ( + "signatureKeyType", + text(&format!("{:?}", self.assumptions.signature_key_type)), + ), + ( + "userFeeIncrease", + number(u64::from(self.assumptions.user_fee_increase)), + ), + ( + "feeMultiplierPermille", + number(self.assumptions.fee_multiplier_permille), + ), + ("contenders", number(u64::from(self.assumptions.contenders))), + ]), + ), + ("documentBytes", number(self.document_bytes)), + ("creditsPerByte", number(self.credits_per_byte)), + ("creditsPerDash", number(CREDITS_PER_DASH)), + ( + "storage", + map(vec![ + ("bytes", scenarios(self.storage_bytes)), + ("credits", scenarios(self.storage_credits)), + ("primaryBytes", scenarios(self.primary_bytes)), + ("preallocatedBytes", scenarios(self.preallocated_bytes)), + ("expirationBytes", scenarios(self.expiration_bytes)), + ]), + ), + ( + "indexes", + Value::Array( + self.indexes + .iter() + .map(|index| { + map(vec![ + ("name", text(&index.name)), + ("sharedWith", texts(&index.shared_with)), + ("sharedBytes", scenarios(index.shared_bytes)), + ("ownBytes", scenarios(index.own_bytes)), + ]) + }) + .collect(), + ), + ), + ( + "elements", + Value::Array( + self.elements + .iter() + .map(|element| { + map(vec![ + ("role", text(element.role.name())), + ("path", texts(&element.path)), + ("bytes", number(element.bytes)), + ("indexes", texts(&element.indexes)), + ("ifAbsent", Value::Bool(element.if_absent)), + ( + "writtenWhenValuesKnown", + Value::Bool(element.written_when_values_known), + ), + ("ephemeral", Value::Bool(element.ephemeral)), + ("expiration", Value::Bool(element.expiration)), + ( + "rankingAxis", + element + .ranking_axis + .map(|axis| { + text(match axis { + IndexAxis::Count => "count", + IndexAxis::Sum => "sum", + IndexAxis::Avg => "avg", + }) + }) + .unwrap_or(Value::Null), + ), + ( + "referringType", + element + .referring_type + .as_deref() + .map(text) + .unwrap_or(Value::Null), + ), + ]) + }) + .collect(), + ), + ), + ( + "processing", + Value::Array( + self.processing + .iter() + .map(|part| { + map(vec![ + ("code", text(part.code)), + ("text", text(&part.text)), + ("credits", scenarios(part.credits)), + ("exact", Value::Bool(part.exact)), + ]) + }) + .collect(), + ), + ), + ("processingCredits", scenarios(self.processing_credits())), + ( + "contractCharges", + Value::Array( + self.contract_charges + .iter() + .map(ContractCharge::to_value) + .collect(), + ), + ), + ( + "refund", + map(vec![ + ( + "sameEpoch", + self.refund_same_epoch.map(scenarios).unwrap_or(Value::Null), + ), + ( + "afterOneYear", + self.refund_after_one_year + .map(scenarios) + .unwrap_or(Value::Null), + ), + ]), + ), + ("totalCredits", scenarios(self.total_credits())), + ( + "fields", + Value::Array( + self.fields + .iter() + .map(|field| { + let optional_number = |value: Option| { + value.map(|v| number(u64::from(v))).unwrap_or(Value::Null) + }; + map(vec![ + ("path", text(&field.path)), + ("kind", text(field.kind)), + ("optional", Value::Bool(field.optional)), + ("present", Value::Bool(field.present)), + ("length", optional_number(field.length)), + ("minLength", optional_number(field.min_length)), + ("maxLength", optional_number(field.max_length)), + ]) + }) + .collect(), + ), + ), + ]) + } +} + +#[cfg(all(test, feature = "server"))] +mod tests; diff --git a/packages/rs-drive/src/drive/document/cost/tests.rs b/packages/rs-drive/src/drive/document/cost/tests.rs new file mode 100644 index 00000000000..10ec07d017d --- /dev/null +++ b/packages/rs-drive/src/drive/document/cost/tests.rs @@ -0,0 +1,758 @@ +use super::*; +use crate::drive::document::expiration::paths::documents_expirations_path_vec; +use crate::drive::document::expiration::pricing::document_expiration_cleanup_fee; +use crate::drive::document::fixture_contracts::{ + leave_out_optional_unique_values, small_sums, CONTRACTS, +}; +use crate::drive::document::make_document_reference; +use crate::drive::{Drive, RootTree}; +use crate::util::grove_operations::DirectQueryType; +use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; +use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; +use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; +use crate::util::test_helpers::setup_contract; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::document_type::random_document::CreateRandomDocument; +use dpp::data_contract::DataContractFactory; +use dpp::document::{DocumentV0Getters, DocumentV0Setters}; +use dpp::fee::fee_result::FeeResult; +use dpp::platform_value::{platform_value, Identifier}; +use std::borrow::Cow; + +/// The elements inserting `document` writes. +fn writes_of( + contract: &DataContract, + document_type: DocumentTypeRef, + document: &Document, +) -> Vec { + let platform_version = PlatformVersion::latest(); + let serialized = document + .serialize(document_type, contract, platform_version) + .expect("expected to serialize the document"); + document_writes( + contract, + document_type, + document, + &serialized, + platform_version, + ) + .expect("expected the writes") +} + +/// Inserts `document` as a document create does: owned by its owner, in +/// one epoch. +fn insert( + drive: &Drive, + contract: &DataContract, + document_type: DocumentTypeRef, + document: &Document, +) -> Result { + insert_at(drive, contract, document_type, document, 0) +} + +/// [`insert`] in a block at `time_ms`. +fn insert_at( + drive: &Drive, + contract: &DataContract, + document_type: DocumentTypeRef, + document: &Document, + time_ms: u64, +) -> Result { + let flags = StorageFlags::new_single_epoch(0, Some(document.owner_id().to_buffer())); + drive.add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((document, Some(Cow::Owned(flags)))), + owner_id: None, + }, + contract, + document_type, + }, + false, + BlockInfo::default_with_time(time_ms), + true, + None, + PlatformVersion::latest(), + None, + ) +} + +/// The element at `path` and `key` below the document type tree, if any. +fn element( + drive: &Drive, + contract: &DataContract, + type_name: &str, + path: &[Vec], + key: &[u8], +) -> Option { + let path: Vec> = [ + vec![ + vec![RootTree::DataContractDocuments as u8], + contract.id().to_vec(), + vec![1], + type_name.as_bytes().to_vec(), + ], + path.to_vec(), + ] + .concat(); + match drive.grove_get_raw( + path.as_slice().into(), + key, + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &PlatformVersion::latest().drive, + ) { + Ok(element) => element, + // A tree above it is missing too. + Err(Error::GroveDB(error)) + if matches!( + *error, + grovedb::Error::PathNotFound(_) + | grovedb::Error::PathParentLayerNotFound(_) + | grovedb::Error::PathKeyNotFound(_) + ) => + { + None + } + Err(error) => panic!("expected to read: {error}"), + } +} + +/// Whether the element `write` names exists already. +fn exists(drive: &Drive, contract: &DataContract, type_name: &str, write: &Write) -> bool { + if write.expiration { + let path = [documents_expirations_path_vec(), write.path.clone()].concat(); + return drive + .grove_get_raw( + path.as_slice().into(), + &write.key, + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &PlatformVersion::latest().drive, + ) + .map(|element| element.is_some()) + .unwrap_or(false); + } + let type_name = write.referring_type.as_deref().unwrap_or(type_name); + element(drive, contract, type_name, &write.path, &write.key).is_some() +} + +/// The bytes a ranked entry's row adds: all of it for a new entry; for an +/// entry already stored, only what the row grew by (GroveDB bills a moved +/// row's bytes, up to the old row's size, as replaced). +fn row_bytes( + write: &Write, + before: Option<(u64, i64)>, + after: (u64, i64), + platform_version: &PlatformVersion, +) -> u64 { + let row = write.ranking.as_ref().expect("a ranking row"); + let bytes = |(count, sum): (u64, i64)| { + let (key, priced) = + writes::ranking_row(row.axis, &row.entry_key, count, sum, platform_version) + .expect("expected the row"); + u64::from(new_element_bytes( + key.len() as u32, + priced, + NodeKind::of_tree(write.parent), + )) + }; + match before { + None => bytes(after), + Some(before) => bytes(after).saturating_sub(bytes(before)), + } +} + +fn apply(drive: &Drive, index: usize, path: &str) -> DataContract { + setup_contract( + drive, + path, + Some([index as u8 + 1; 32]), + None, + None::, + None, + Some(PlatformVersion::latest()), + ) +} + +#[test] +fn should_price_what_drive_charges() { + let platform_version = PlatformVersion::latest(); + let credits_per_byte = platform_version + .fee_version + .storage + .storage_disk_usage_credit_per_byte; + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let mut mismatches = Vec::new(); + let mut inserts = 0; + let mut values_already_stored = 0; + + for (index, path) in CONTRACTS.into_iter().enumerate() { + let contract = apply(&drive, index, path); + for (name, document_type) in contract.document_types() { + let document_type = document_type.as_ref(); + for seed in 1..=11u64 { + let mut document = document_type + .random_document(Some(seed), platform_version) + .expect("expected a random document"); + // Seed 11 is the document of middle sizes the SDK prices. + if seed == 11 { + document = sized_document( + &contract, + document_type, + &Default::default(), + platform_version, + ) + .expect("expected a sized document") + .0; + if document_type.indexes().values().any(|index| index.unique) { + document.set_id([0xab; 32].into()); + } + } + small_sums(&mut document, document_type, seed); + if seed <= 2 { + leave_out_optional_unique_values(&mut document, document_type); + } + // Seed 10 repeats the values of seed 9 under a new id, where + // no unique index forbids it. + if seed == 10 { + if document_type.indexes().values().any(|index| index.unique) { + continue; + } + let mut previous = document_type + .random_document(Some(9), platform_version) + .expect("expected a random document"); + small_sums(&mut previous, document_type, 9); + previous.set_id(document.id()); + previous.set_owner_id(document.owner_id()); + document = previous; + } + + let writes = writes_of(&contract, document_type, &document); + let mut expected_bytes = 0u64; + let entry_of = |write: &Write| { + let row = write.ranking.as_ref()?; + let type_name = write.referring_type.as_deref().unwrap_or(name); + element( + &drive, + &contract, + type_name, + &row.entry_path, + &row.entry_key, + ) + .map(|entry| entry.count_sum_value_or_default()) + }; + let entries_before: Vec> = + writes.iter().map(&entry_of).collect(); + for write in writes.iter().filter(|write| write.ranking.is_none()) { + let present = write.if_absent && exists(&drive, &contract, name, write); + values_already_stored += usize::from(present); + if !present && !write.ephemeral { + expected_bytes += u64::from(new_element_bytes( + write.key.len() as u32, + write.element, + NodeKind::of_tree(write.parent), + )); + } + } + let fee = match insert(&drive, &contract, document_type, &document) { + Ok(fee) => fee, + // An indexOnly entry keyed by the repeated values collides. + Err(_) if seed == 10 && document_type.index_only() => continue, + Err(error) => panic!("{path} {name} seed {seed}: {error}"), + }; + inserts += 1; + // A ranked tree's rows carry the entry's aggregate after the + // insert. + for (write, before) in writes.iter().zip(&entries_before) { + if write.ranking.is_none() { + continue; + } + let after = entry_of(write).expect("expected the ranked entry"); + expected_bytes += row_bytes(write, *before, after, platform_version); + } + if fee.storage_fee != expected_bytes * credits_per_byte { + mismatches.push(format!( + "{path} {name} seed {seed}: Drive charged {} bytes, the model says {}", + fee.storage_fee / credits_per_byte, + expected_bytes + )); + } + } + } + } + + assert!( + mismatches.is_empty(), + "the model disagrees with Drive:\n{}", + mismatches.join("\n") + ); + assert!(inserts > 100, "only {inserts} inserts ran"); + assert!( + values_already_stored > 0, + "no insert found a value already stored" + ); +} + +#[test] +fn should_estimate_the_processing_of_the_writes_within_a_factor_of_two() { + let platform_version = PlatformVersion::latest(); + let mut ratios = Vec::new(); + for index in [0usize, 3, 4, 7, 9, 15] { + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = apply(&drive, index, CONTRACTS[index]); + for (name, document_type) in contract.document_types() { + let document_type = document_type.as_ref(); + let type_entries = u64::from(!document_type.index_only()) + + document_type.index_structure().sub_levels().len() as u64; + for seed in 0..=100u64 { + let mut document = document_type + .random_document(Some(seed), platform_version) + .expect("expected a random document"); + small_sums(&mut document, document_type, seed); + let writes = writes_of(&contract, document_type, &document); + let known: Vec = writes + .iter() + .map(|write| { + write.ranking.is_none() + && (!write.if_absent || !exists(&drive, &contract, name, write)) + }) + .collect(); + let mut assumptions = CostAssumptions::new(platform_version); + assumptions.existing_documents = seed; + let estimate = write_processing( + &writes, + &known, + type_entries, + &assumptions, + platform_version, + ); + let fee = + insert(&drive, &contract, document_type, &document).unwrap_or_else(|error| { + panic!("{} {name} seed {seed}: {error}", CONTRACTS[index]) + }); + if [1, 10, 100].contains(&seed) { + ratios.push(( + format!("{} {name} after {seed} documents", CONTRACTS[index]), + estimate as f64 / fee.processing_fee as f64, + )); + } + } + } + } + assert!(ratios.len() > 30, "only {} checkpoints", ratios.len()); + let outside: Vec = ratios + .iter() + .filter(|(_, ratio)| !(0.5..=2.0).contains(ratio)) + .map(|(at, ratio)| format!("{at}: estimate / Drive = {ratio:.2}")) + .collect(); + assert!( + outside.is_empty(), + "the processing estimate strays from Drive:\n{}", + outside.join("\n") + ); +} + +fn contract_with(schemas: Value) -> DataContract { + DataContractFactory::new(PlatformVersion::latest().protocol_version) + .expect("factory") + .create_with_value_config(Identifier::from([7; 32]), 0, schemas, None, None) + .expect("contract") + .data_contract_owned() +} + +fn note_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "tag": { "type": "string", "maxLength": 20, "position": 0 }, + "code": { "type": "string", "maxLength": 20, "position": 1 }, + "text": { "type": "string", "maxLength": 60, "position": 2 }, + }, + "indices": [ + { "name": "byTag", "properties": [{ "tag": "asc" }] }, + { "name": "byTagText", "properties": [{ "tag": "asc" }, { "text": "asc" }] }, + { "name": "byCode", "properties": [{ "code": "asc" }], "unique": true }, + ], + "required": ["tag", "code", "text"], + "additionalProperties": false, + }) +} + +fn note_document(contract: &DataContract) -> Document { + let document_type = contract.document_type_for_name("note").expect("note"); + let mut document = document_type + .random_document(Some(1), PlatformVersion::latest()) + .expect("expected a random document"); + document.set("tag", Value::Text("travel".to_string())); + document.set("code", Value::Text("n-1".to_string())); + document.set("text", Value::Text("a note about a trip".to_string())); + document +} + +#[test] +fn should_split_the_storage_into_primary_storage_and_each_index() { + let platform_version = PlatformVersion::latest(); + let contract = contract_with(platform_value!({ "note": note_schema() })); + let note = contract.document_type_for_name("note").expect("note"); + let cost = document_create_cost( + &contract, + note, + ¬e_document(&contract), + &CostAssumptions::new(platform_version), + platform_version, + ) + .expect("expected a cost"); + + // Every byte is primary storage or an index's, a shared layer once. + let mut counted = cost.primary_bytes; + let mut shared_once = std::collections::BTreeMap::new(); + for element in cost + .elements + .iter() + .filter(|element| element.indexes.len() > 1) + { + shared_once.insert(element.path.clone(), element.bytes); + } + for index in &cost.indexes { + counted.add(index.own_bytes); + } + counted.new_values += shared_once.values().sum::(); + assert_eq!(counted.new_values, cost.storage_bytes.new_values); + + let by_tag = cost + .indexes + .iter() + .find(|index| index.name == "byTag") + .expect("byTag"); + assert_eq!(by_tag.shared_with, vec!["byTagText".to_string()]); + assert!(by_tag.shared_bytes.new_values > 0); + // The tag's value tree already exists for a known tag; only the + // document's own entries are written. + assert!(by_tag.shared_bytes.known_values == 0); + assert!(by_tag.own_bytes.known_values > 0); + assert!(by_tag.own_bytes.known_values < by_tag.own_bytes.new_values); + + // A unique value is new whatever is stored. + let by_code = cost + .indexes + .iter() + .find(|index| index.name == "byCode") + .expect("byCode"); + assert!(by_code.shared_with.is_empty()); + assert_eq!(by_code.own_bytes.known_values, by_code.own_bytes.new_values); + + assert_eq!( + cost.storage_credits.new_values, + cost.storage_bytes.new_values * cost.credits_per_byte + ); + // A delete in the same epoch refunds all but the epoch's share. + let same_epoch = cost.refund_same_epoch.expect("a refund"); + let after_one_year = cost.refund_after_one_year.expect("a refund"); + assert!(same_epoch.new_values < cost.storage_credits.new_values); + assert!(same_epoch.new_values > cost.storage_credits.new_values * 99 / 100); + assert!(after_one_year.new_values < same_epoch.new_values); +} + +#[test] +fn should_add_the_contract_charges() { + let platform_version = PlatformVersion::latest(); + let mut schema = note_schema(); + schema + .insert( + "actionFees".to_string(), + platform_value!({ "pricing": "fixed", "create": { "owner": 1000u64, "moderators": 500u64 } }), + ) + .expect("expected to set the action fees"); + let contract = contract_with(platform_value!({ "note": schema })); + let note = contract.document_type_for_name("note").expect("note"); + let cost = document_create_cost( + &contract, + note, + ¬e_document(&contract), + &CostAssumptions::new(platform_version), + platform_version, + ) + .expect("expected a cost"); + assert!(cost.contract_charges.iter().any(|charge| matches!( + charge, + ContractCharge::ActionFee { charged, .. } if charged.owner == 1000 && charged.moderators == 500 + ))); + let fees_and_storage = cost + .storage_credits + .new_values + .saturating_add(cost.processing_credits().new_values); + assert_eq!(cost.total_credits().new_values, fees_and_storage + 1500); + + // A DPNS name may be contested: its create shows the vote fund. + let dpns = dpp::system_data_contracts::load_system_data_contract( + dpp::system_data_contracts::SystemDataContract::DPNS, + platform_version, + ) + .expect("dpns"); + let domain = dpns.document_type_for_name("domain").expect("domain"); + let document = domain + .random_document(Some(1), platform_version) + .expect("expected a random document"); + let cost = document_create_cost( + &dpns, + domain, + &document, + &CostAssumptions::new(platform_version), + platform_version, + ) + .expect("expected a cost"); + assert!(cost.contract_charges.iter().any(|charge| matches!( + charge, + ContractCharge::ContestFund { credits, .. } + if *credits == platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_vote_resolution_fund_required_amount + ))); +} + +#[test] +fn should_charge_a_later_document_with_known_values_no_more_than_the_first() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + for (index, path) in CONTRACTS.into_iter().enumerate() { + let contract = apply(&drive, index, path); + for (name, document_type) in contract.document_types() { + let cost = document_type_create_cost( + &contract, + document_type.as_ref(), + &Default::default(), + &CostAssumptions::new(platform_version), + platform_version, + ) + .expect("expected a cost"); + let total = cost.total_credits(); + assert!( + total.known_values <= total.new_values, + "{path} {name}: {} credits with known values, {} with new ones", + total.known_values, + total.new_values + ); + assert!(cost.storage_bytes.known_values <= cost.storage_bytes.new_values); + } + } +} + +#[test] +fn should_price_a_time_window_with_a_ttl_without_flags_as_processing() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = contract_with(platform_value!({ "ping": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { "tag": { "type": "string", "maxLength": 20, "position": 0 } }, + "indices": [{ + "name": "recent", + "properties": [{ "$createdAt": "asc" }, { "tag": "asc" }], + "timeRange": { "on": "$createdAt", "range": 3600, "step": 900, "ttl": 86400 }, + }], + "required": ["$createdAt", "tag"], + "additionalProperties": false, + }})); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + let ping = contract.document_type_for_name("ping").expect("ping"); + let (document, _) = sized_document(&contract, ping, &Default::default(), platform_version) + .expect("expected a sized document"); + + // Every entry under the window is ephemeral: its references carry no + // flags, as Drive strips them. + let writes = writes_of(&contract, ping, &document); + let flagless_reference = make_document_reference(&document, ping, None) + .serialized_size(&platform_version.drive.grove_version) + .expect("expected the reference size") as u32; + let references: Vec<&Write> = writes + .iter() + .filter(|write| write.role == LayoutRole::Member) + .collect(); + assert_eq!( + references.len(), + 4, + "a window of an hour every quarter hour" + ); + for write in references { + assert!(write.ephemeral && !write.flagged); + assert_eq!( + write.element, + PricedElement::Serialized { + serialized_len: flagless_reference + } + ); + } + + let cost = document_create_cost( + &contract, + ping, + &document, + &CostAssumptions::new(platform_version), + platform_version, + ) + .expect("expected a cost"); + assert!(cost + .processing + .iter() + .any(|part| part.code == "timeWindowTtl" && part.credits.new_values > 0)); + // What is not ephemeral is storage, exactly as Drive charges it. + let fee = insert_at( + &drive, + &contract, + ping, + &document, + document.created_at().expect("created at"), + ) + .expect("expected to insert the document"); + assert_eq!(fee.storage_fee, cost.storage_credits.new_values); +} + +#[test] +fn should_count_trees_keyed_by_the_document_id_as_new_and_unrefunded() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + // Likes' `byPost` index is preallocated for every post, keyed by its id. + let contract = apply(&drive, 10, CONTRACTS[10]); + let post = contract.document_type_for_name("post").expect("post"); + let cost = document_type_create_cost( + &contract, + post, + &Default::default(), + &CostAssumptions::new(platform_version), + platform_version, + ) + .expect("expected a cost"); + assert!(cost.preallocated_bytes.new_values > 0); + // Every tree keyed by the post's id, and what is under it, is new + // whatever else is stored; a level keyed by a value another post shares + // (its hashtag) may already exist. + let (document, _) = sized_document(&contract, post, &Default::default(), platform_version) + .expect("expected a sized document"); + let id = document.id().to_vec(); + let writes = writes_of(&contract, post, &document); + let known = written_when_values_known(&writes, &id); + let keyed_by_id: Vec = writes + .iter() + .zip(&known) + .filter(|(write, _)| { + write.referring_type.is_some() && (write.key == id || write.path.contains(&id)) + }) + .map(|(_, known)| *known) + .collect(); + assert!(!keyed_by_id.is_empty()); + assert!(keyed_by_id.iter().all(|known| *known)); + // A delete keeps preallocated trees: they are not refunded. + let kept = (cost.storage_bytes.new_values - cost.preallocated_bytes.new_values) + * cost.credits_per_byte; + assert!(cost.refund_same_epoch.expect("a refund").new_values < kept); +} + +#[test] +fn should_refuse_an_earlier_protocol_version() { + let platform_version = PlatformVersion::latest(); + let contract = contract_with(platform_value!({ "note": note_schema() })); + let note = contract.document_type_for_name("note").expect("note"); + let version_13 = PlatformVersion::get(13).expect("protocol version 13"); + assert!(matches!( + document_create_cost( + &contract, + note, + ¬e_document(&contract), + &CostAssumptions::new(platform_version), + version_13 + ), + Err(Error::Drive(DriveError::UnknownVersionMismatch { .. })) + )); +} + +#[test] +fn should_price_a_document_with_a_ttl_by_its_lifetime() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let mut schema = note_schema(); + schema + .insert("ttl".to_string(), Value::U32(86_400)) + .expect("expected to set the ttl"); + schema + .insert( + "required".to_string(), + platform_value!(["$createdAt", "tag", "code", "text"]), + ) + .expect("expected to set required"); + let contract = contract_with(platform_value!({ "note": schema })); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + let note = contract.document_type_for_name("note").expect("note"); + let (document, _) = sized_document(&contract, note, &Default::default(), platform_version) + .expect("expected a sized document"); + let cost = document_create_cost( + &contract, + note, + &document, + &CostAssumptions::new(platform_version), + platform_version, + ) + .expect("expected a cost"); + + // A day costs far less per byte than keeping the bytes for good. + assert!( + cost.credits_per_byte + < platform_version + .fee_version + .storage + .storage_disk_usage_credit_per_byte + ); + assert!(cost.expiration_bytes.new_values > 0); + // A later document expires at another time: its entry is new too. + assert_eq!( + cost.expiration_bytes.known_values, + cost.expiration_bytes.new_values + ); + assert_eq!(cost.refund_same_epoch, Some(Scenarios::default())); + let cleanup = cost + .processing + .iter() + .find(|part| part.code == "ttlCleanup") + .expect("the cleanup prepay"); + assert_eq!( + cleanup.credits.new_values, + document_expiration_cleanup_fee(note, cost.document_bytes, &platform_version.fee_version) + .expect("the cleanup fee") + ); + + // Created in the block it is written in: all of its day left to live. + let fee = insert_at( + &drive, + &contract, + note, + &document, + document.created_at().expect("created at"), + ) + .expect("expected to insert the document"); + assert_eq!(fee.storage_fee, cost.storage_credits.new_values); +} diff --git a/packages/rs-drive/src/drive/document/cost/writes.rs b/packages/rs-drive/src/drive/document/cost/writes.rs new file mode 100644 index 00000000000..64f4adbd213 --- /dev/null +++ b/packages/rs-drive/src/drive/document/cost/writes.rs @@ -0,0 +1,982 @@ +//! The elements Drive writes when it inserts one document, with the keys, +//! element sizes and parent trees the storage cost depends on. The shape +//! follows `drive::document::layout`, which takes it from the index +//! walkers' rules; the keys and elements are the document's own, built with +//! the functions the walkers use (dpp's serialization and key encoding, the +//! document reference builders, the indexOnly row commitment). + +use crate::drive::document::cost::grove_costs::PricedElement; +use crate::drive::document::expiration::paths::encode_expiration_time; +use crate::drive::document::expiration::pricing::document_expires_at; +use crate::drive::document::expiration::DocumentExpirationEntry; +use crate::drive::document::index_level_tree_types::{ + continuation_contributes_zero, index_level_tree_types_with_continuation_demotion, + index_only_level_skips_when_absent, level_counts_continuations, terminal_member_tree_type, + zero_contribution_wrapper, +}; +use crate::drive::document::layout::{index_ending_at, index_paths, indexes_through, LayoutRole}; +use crate::drive::document::primary_key_tree_type::DocumentTypePrimaryKeyTreeType; +use crate::drive::document::{ + bound_value_fits_referring_property, encode_index_only_entry_payload, index_only_member_key, + index_only_row_commitment, make_document_reference, make_document_reference_with_sum_item, + read_document_sum_contribution, +}; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::util::storage_flags::StorageFlags; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::config::v0::DataContractConfigGettersV0; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::{ + is_flat_level_key, DocumentPropertyType, DocumentTypeRef, Index, IndexLevel, + IndexLevelTypeInfo, PreallocatedKeySource, +}; +use dpp::data_contract::DataContract; +use dpp::document::document_methods::DocumentMethodsV0; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::version::PlatformVersion; +use grovedb::element::reference_path::ReferencePathType::SiblingReference; +use grovedb::element::IndexAxis; +use grovedb::Element; +use grovedb_merk::tree_type::TreeType; +use std::collections::HashSet; + +/// One element an insert writes. +#[derive(Clone, Debug)] +pub(crate) struct Write { + /// The keys from the document type tree down to the element's tree. + pub path: Vec>, + /// The element's key. + pub key: Vec, + /// The element, as its storage cost sees it. + pub element: PricedElement, + /// The tree the element is inserted into. + pub parent: TreeType, + /// What the element is in the layout. + pub role: LayoutRole, + /// The indexes that use it; empty for primary storage. + pub indexes: Vec, + /// Written only when absent: a tree an earlier document with the same + /// values created. Every insert writes the others. + pub if_absent: bool, + /// On a time window with a `ttl`: priced as processing, not storage. + pub ephemeral: bool, + /// A ranked tree's row for one of its entries, when the element is one. + pub ranking: Option, + /// The document type whose tree the element is in, when it is not the + /// inserted document's: a preallocated index of a type referring to it. + pub referring_type: Option, + /// In the documents expirations tree (`[Misc, "E"]`) rather than under + /// a document type: a document with a `ttl`'s entry there. + pub expiration: bool, + /// Whether the element carries the owner's storage flags, which route + /// its refund when it is removed. + pub flagged: bool, +} + +/// The row a ranked (indexed) tree keeps for one of its entries in the +/// secondary tree of one axis, ordered by the entry's aggregate. Every +/// insert under the entry rewrites it: the aggregate, hence the sort key, +/// changes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RankingRow { + /// The axis the secondary tree orders by. + pub axis: IndexAxis, + /// The path of the entry: the ranked tree. + pub entry_path: Vec>, + /// The entry's key: the document's value. + pub entry_key: Vec, +} + +/// The key and element of the row of the entry `entry_key` holding +/// `count` and `sum` in the secondary tree of `axis`: the sort key followed +/// by the entry key, holding a one-hop reference to the entry that carries +/// the axis's aggregate (grovedb `make_axis_secondary_key`, +/// `axis_row_reference`). +pub(crate) fn ranking_row( + axis: IndexAxis, + entry_key: &[u8], + count: u64, + sum: i64, + platform_version: &PlatformVersion, +) -> Result<(Vec, PricedElement), Error> { + let sort_key_len = match axis { + IndexAxis::Count | IndexAxis::Sum => 8, + IndexAxis::Avg => 16, + }; + let mut key = vec![0u8; sort_key_len]; + key.extend_from_slice(entry_key); + let payload = match axis { + IndexAxis::Count => i64::try_from(count).unwrap_or(i64::MAX), + IndexAxis::Sum | IndexAxis::Avg => sum, + }; + let row = Element::new_reference_with_sum_item_with_hops( + SiblingReference(entry_key.to_vec()), + Some(1), + payload, + ); + Ok(( + key, + PricedElement::Serialized { + serialized_len: serialized_len(&row, platform_version)?, + }, + )) +} + +/// The `(count, sum)` an empty tree of `tree_type` holds as an entry of a +/// ranked tree (grovedb `Element::count_sum_value_or_default`). +fn empty_tree_aggregate(tree_type: TreeType) -> (u64, i64) { + match tree_type { + TreeType::CountTree + | TreeType::CountSumTree + | TreeType::ProvableCountTree + | TreeType::ProvableCountSumTree + | TreeType::ProvableCountProvableSumTree + | TreeType::ProvableCountIndexedTree + | TreeType::ProvableCountProvableSumIndexedTree => (0, 0), + _ => (1, 0), + } +} + +/// The tree a ranked tree's secondary rows live in. +pub(crate) const RANKING_ROW_TREE: TreeType = TreeType::ProvableCountProvableSumTree; + +/// What the walk needs besides the document type. +struct Context<'a> { + document_type: DocumentTypeRef<'a>, + document: &'a Document, + platform_version: &'a PlatformVersion, + index_paths: Vec<(String, Vec)>, + /// The flags every document element carries; none for a document with + /// a `ttl` (`without_storage_flags_if_expiring`). + document_flags: Option, + /// The flags the index trees and indexOnly entries carry, when they + /// carry any (`add_indices_for_top_index_level_for_contract_operations`). + index_flags: Option, + /// The type whose tree the walk is in, when it is a referring type's. + referring_type: Option, + writes: Vec, + /// Where each recorded write is, to record it once. + recorded: HashSet, +} + +/// Where a write is: the referring type whose tree it is in (if any), +/// whether it is in the expirations tree, its path and its key. +type WriteLocation = (Option, bool, Vec>, Vec); + +fn serialized_len(element: &Element, platform_version: &PlatformVersion) -> Result { + Ok(element.serialized_size(&platform_version.drive.grove_version)? as u32) +} + +fn flags_len(flags: Option<&StorageFlags>) -> Option { + flags.map(|flags| flags.serialized_size()) +} + +fn empty_tree(tree_type: TreeType, wrapped: bool, flags: Option<&StorageFlags>) -> PricedElement { + PricedElement::Tree { + tree_type, + wrapped, + flags_len: flags_len(flags), + } +} + +/// Every element inserting `document`, serialized as `serialized`, writes, +/// owned by its owner in one epoch, as a document create stores it. +pub(crate) fn document_writes( + contract: &DataContract, + document_type: DocumentTypeRef, + document: &Document, + serialized: &[u8], + platform_version: &PlatformVersion, +) -> Result, Error> { + let document_flags = document_type + .documents_ttl_seconds() + .is_none() + .then(|| StorageFlags::new_single_epoch(0, Some(document.owner_id().to_buffer()))); + let index_flags = document_flags.clone().filter(|_| { + document_type.documents_mutable() + || contract.config().can_be_deleted() + || (document_type.index_only() && document_type.documents_can_be_deleted()) + }); + let mut context = Context { + document_type, + document, + platform_version, + index_paths: index_paths(document_type), + document_flags, + index_flags, + referring_type: None, + writes: Vec::new(), + recorded: HashSet::new(), + }; + if !document_type.index_only() { + context.primary(contract, serialized)?; + } + for (level_key, level) in document_type.index_structure().sub_levels() { + context.top_level(level_key, level)?; + } + context.preallocations(contract)?; + if let Some(ttl_seconds) = document_type.documents_ttl_seconds() { + context.expiration(contract, ttl_seconds)?; + } + Ok(context.writes) +} + +impl Context<'_> { + fn push(&mut self, mut write: Write) { + write.referring_type = self.referring_type.clone(); + // Indexes sharing a prefix reach the same tree more than once; the + // walkers insert it once. + if self.recorded.insert(( + write.referring_type.clone(), + write.expiration, + write.path.clone(), + write.key.clone(), + )) { + self.writes.push(write); + } + } + + /// The document's contribution to the sum of its value trees at `level`. + fn sum_contribution(&self, level: &IndexLevel) -> Result { + match level + .has_index_with_type() + .and_then(|info| info.summable.as_deref()) + { + Some(property) => read_document_sum_contribution(self.document, property), + None => Ok(0), + } + } + + /// The rows of the entry `key` under the ranked tree at `path`, one per + /// axis, for an entry holding `count` and `sum`. + #[allow(clippy::too_many_arguments)] + fn ranking_rows( + &mut self, + path: &[Vec], + key: &[u8], + axes: &[IndexAxis], + count: u64, + sum: i64, + indexes: &[String], + ) -> Result<(), Error> { + for axis in axes { + let (row_key, element) = ranking_row(*axis, key, count, sum, self.platform_version)?; + let mut row_path = path.to_vec(); + row_path.push(key.to_vec()); + row_path.push(vec![0xff, *axis as u8]); + self.push(Write { + path: row_path, + key: row_key, + element, + parent: RANKING_ROW_TREE, + role: LayoutRole::IndexValue, + indexes: indexes.to_vec(), + // An entry already stored only re-keys its row, which GroveDB + // bills as replaced bytes, not added ones. + if_absent: true, + ephemeral: false, + ranking: Some(RankingRow { + axis: *axis, + entry_path: path.to_vec(), + entry_key: key.to_vec(), + }), + referring_type: None, + expiration: false, + flagged: false, + }); + } + Ok(()) + } + + fn raw(&self, property: &str) -> Result>, Error> { + Ok(self.document.get_raw_for_document_type( + property, + self.document_type, + None, + self.platform_version, + )?) + } + + /// The document by id (`add_document_to_primary_storage`). + fn primary(&mut self, contract: &DataContract, serialized: &[u8]) -> Result<(), Error> { + let document_type = self.document_type; + let primary_key_tree_type = document_type.primary_key_tree_type(self.platform_version)?; + let serialized = serialized.to_vec(); + let sum_property = document_type.documents_summable(); + let document_flags = self.document_flags.clone(); + let id = self.document.id().to_vec(); + + if !document_type.documents_keep_history() { + let element = match sum_property { + Some(_) => PricedElement::ItemWithSumItem { + item_len: serialized.len() as u32, + flags_len: flags_len(document_flags.as_ref()), + }, + None => PricedElement::Serialized { + serialized_len: serialized_len( + &Element::Item( + serialized, + StorageFlags::map_to_some_element_flags(document_flags.as_ref()), + ), + self.platform_version, + )?, + }, + }; + self.push(Write { + path: vec![vec![0]], + key: id, + element, + parent: primary_key_tree_type, + role: LayoutRole::Document, + indexes: vec![], + if_absent: false, + ephemeral: false, + ranking: None, + referring_type: None, + expiration: false, + flagged: document_flags.is_some(), + }); + return Ok(()); + } + + // With history: a tree per document holding each revision by time + // and a pointer to the newest. The tree and the pointer carry flags + // only when the contract can be deleted. + let tree_flags = document_flags + .clone() + .filter(|_| contract.config().can_be_deleted()); + let document_tree_type = if sum_property.is_some() { + TreeType::SumTree + } else { + TreeType::NormalTree + }; + self.push(Write { + path: vec![vec![0]], + key: id.clone(), + element: empty_tree(document_tree_type, false, tree_flags.as_ref()), + parent: primary_key_tree_type, + role: LayoutRole::Document, + indexes: vec![], + if_absent: false, + ephemeral: false, + ranking: None, + referring_type: None, + expiration: false, + flagged: tree_flags.is_some(), + }); + // The revision key is the block time, 8 bytes whatever the time. + let encoded_time = DocumentPropertyType::encode_date_timestamp(0); + self.push(Write { + path: vec![vec![0], id.clone()], + key: encoded_time.clone(), + element: PricedElement::Serialized { + serialized_len: serialized_len( + &Element::Item( + serialized, + StorageFlags::map_to_some_element_flags(document_flags.as_ref()), + ), + self.platform_version, + )?, + }, + parent: document_tree_type, + role: LayoutRole::Revision, + indexes: vec![], + if_absent: false, + ephemeral: false, + ranking: None, + referring_type: None, + expiration: false, + flagged: document_flags.is_some(), + }); + let pointer_flags = StorageFlags::map_to_some_element_flags(tree_flags.as_ref()); + let pointer = match sum_property { + Some(property) => Element::new_reference_with_sum_item_with_max_hops_and_flags( + SiblingReference(encoded_time), + Some(1), + read_document_sum_contribution(self.document, property)?, + pointer_flags, + ), + None => Element::Reference(SiblingReference(encoded_time), Some(1), pointer_flags), + }; + self.push(Write { + path: vec![vec![0], id], + key: vec![0], + element: PricedElement::Serialized { + serialized_len: serialized_len(&pointer, self.platform_version)?, + }, + parent: document_tree_type, + role: LayoutRole::LatestRevision, + indexes: vec![], + if_absent: false, + ephemeral: false, + ranking: None, + referring_type: None, + expiration: false, + flagged: tree_flags.is_some(), + }); + Ok(()) + } + + /// A first-level index tree (created with the contract) and what the + /// document writes under it (`add_indices_for_top_index_level_for_contract_operations`). + fn top_level(&mut self, level_key: &str, level: &IndexLevel) -> Result<(), Error> { + let tree_types = index_level_tree_types_with_continuation_demotion(level)?; + let path = vec![level_key.as_bytes().to_vec()]; + let names = vec![level_key.to_string()]; + + if is_flat_level_key(level_key) { + let Some(info) = level.has_index_with_type() else { + return Ok(()); + }; + let index_flags = self.index_flags.clone(); + return self.terminal( + &path, + &names, + tree_types.property_name_tree_type, + info, + false, + false, + index_flags.as_ref(), + false, + ); + } + + let property = level + .time_range() + .map(|transform| transform.source.clone()) + .unwrap_or_else(|| level_key.to_string()); + let raw = match self.raw(&property)? { + Some(raw) => raw, + None if index_only_level_skips_when_absent(self.document_type, &property) => { + return Ok(()) + } + None => Vec::new(), + }; + let null = raw.is_empty(); + let keys = match level.time_range() { + Some(transform) => transform.entry_keys_for_raw(&raw), + None => vec![raw], + }; + // A time window with a ttl is ephemeral: no flags, priced as + // processing. + let ephemeral = level + .time_range() + .is_some_and(|transform| transform.ttl_seconds.is_some()); + let flags = if ephemeral { + None + } else { + self.index_flags.clone() + }; + for key in keys { + self.push(Write { + path: path.clone(), + key: key.clone(), + element: empty_tree(tree_types.value_tree_type, false, flags.as_ref()), + parent: tree_types.property_name_tree_type, + role: LayoutRole::IndexValue, + indexes: indexes_through(&self.index_paths, &names), + if_absent: true, + ephemeral, + ranking: None, + referring_type: None, + expiration: false, + flagged: flags.is_some(), + }); + // The first document under a value leaves it a count of one and + // its own sum. + let indexes = indexes_through(&self.index_paths, &names); + let sum = self.sum_contribution(level)?; + self.ranking_rows(&path, &key, &tree_types.ranked_axes, 1, sum, &indexes)?; + let mut value_path = path.clone(); + value_path.push(key); + self.level( + &value_path, + &names, + level, + tree_types.value_tree_type, + null, + null, + flags.as_ref(), + ephemeral, + )?; + } + Ok(()) + } + + /// What a document writes under one of its value trees: the terminal + /// of an index ending here, and the continuations of longer indexes + /// (`add_indices_for_index_level_for_contract_operations` v2). + #[allow(clippy::too_many_arguments)] + fn level( + &mut self, + path: &[Vec], + names: &[String], + level: &IndexLevel, + value_tree_type: TreeType, + any_null: bool, + all_null: bool, + flags: Option<&StorageFlags>, + ephemeral: bool, + ) -> Result<(), Error> { + if let Some(info) = level.has_index_with_type() { + self.terminal( + path, + names, + value_tree_type, + info, + any_null, + all_null, + flags, + ephemeral, + )?; + } + let parent_counts_continuations = level_counts_continuations(level); + for (sub_key, sub_level) in level.sub_levels() { + let sub_tree_types = index_level_tree_types_with_continuation_demotion(sub_level)?; + let wrapped = continuation_contributes_zero( + value_tree_type, + parent_counts_continuations, + sub_level, + ) && zero_contribution_wrapper( + value_tree_type, + sub_tree_types.property_name_tree_type, + ) + .map_err(|refusal| { + Error::Drive(DriveError::CorruptedContractIndexes(format!( + "index level {sub_key:?} cannot hang under its value tree: {refusal:?}" + ))) + })? + .is_some(); + let mut sub_names = names.to_vec(); + sub_names.push(sub_key.clone()); + let indexes = indexes_through(&self.index_paths, &sub_names); + self.push(Write { + path: path.to_vec(), + key: sub_key.as_bytes().to_vec(), + element: empty_tree(sub_tree_types.property_name_tree_type, wrapped, flags), + parent: value_tree_type, + role: LayoutRole::NextIndexProperty, + indexes: indexes.clone(), + if_absent: true, + ephemeral, + ranking: None, + referring_type: None, + expiration: false, + flagged: flags.is_some(), + }); + let raw = self.raw(sub_key)?.unwrap_or_default(); + let null = raw.is_empty(); + let mut property_path = path.to_vec(); + property_path.push(sub_key.as_bytes().to_vec()); + self.push(Write { + path: property_path.clone(), + key: raw.clone(), + element: empty_tree(sub_tree_types.value_tree_type, false, flags), + parent: sub_tree_types.property_name_tree_type, + role: LayoutRole::IndexValue, + indexes: indexes.clone(), + if_absent: true, + ephemeral, + ranking: None, + referring_type: None, + expiration: false, + flagged: flags.is_some(), + }); + let sum = self.sum_contribution(sub_level)?; + self.ranking_rows( + &property_path, + &raw, + &sub_tree_types.ranked_axes, + 1, + sum, + &indexes, + )?; + property_path.push(raw); + self.level( + &property_path, + &sub_names, + sub_level, + sub_tree_types.value_tree_type, + any_null || null, + all_null && null, + flags, + ephemeral, + )?; + } + Ok(()) + } + + /// A document with a `ttl`'s entry in the documents expirations tree: + /// the tree of the documents expiring at its expiry time, and in it the + /// document's contract and type by its id, without flags + /// (`add_document_expiration_operations`). + fn expiration(&mut self, contract: &DataContract, ttl_seconds: u32) -> Result<(), Error> { + let created_at = self.document.created_at().unwrap_or_default(); + let time_key = + encode_expiration_time(document_expires_at(created_at, ttl_seconds)?).to_vec(); + let entry = DocumentExpirationEntry { + contract_id: contract.id(), + document_type_name: self.document_type.name().clone(), + }; + self.push(Write { + path: vec![], + key: time_key.clone(), + element: empty_tree(TreeType::NormalTree, false, None), + parent: TreeType::NormalTree, + role: LayoutRole::IndexValue, + indexes: vec![], + if_absent: true, + ephemeral: false, + ranking: None, + referring_type: None, + expiration: true, + flagged: false, + }); + self.push(Write { + path: vec![time_key], + key: self.document.id().to_vec(), + element: PricedElement::Serialized { + serialized_len: serialized_len( + &Element::Item(entry.to_bytes(), None), + self.platform_version, + )?, + }, + parent: TreeType::NormalTree, + role: LayoutRole::Member, + indexes: vec![], + if_absent: false, + ephemeral: false, + ranking: None, + referring_type: None, + expiration: true, + flagged: false, + }); + Ok(()) + } + + /// The trees of every preallocated index (on an indexOnly type of the + /// contract) whose entries will reference the document, created with it + /// and charged to its creator (`add_preallocated_index_tree_operations`). + fn preallocations(&mut self, contract: &DataContract) -> Result<(), Error> { + let target_name = self.document_type.name().clone(); + // Preallocated trees are only deleted with the contract, so they + // carry flags only when it can be. + let flags = self + .document_flags + .clone() + .filter(|_| contract.config().can_be_deleted()); + for referring in contract.document_types().values() { + let referring = referring.as_ref(); + if !referring.index_only() { + continue; + } + for index in referring + .indexes() + .values() + .filter(|index| index.preallocated) + { + for binding in index.preallocation_bindings_for_target( + referring.flattened_properties(), + contract.id(), + &target_name, + ) { + self.referring_type = Some(referring.name().clone()); + let result = + self.preallocation(referring, index, &binding.key_sources, flags.as_ref()); + self.referring_type = None; + result?; + } + } + } + Ok(()) + } + + /// The trees of one preallocated index for entries referencing the + /// document: each level's key resolved from the document, then the + /// property-name trees below the first, the value trees and the empty + /// member tree. + fn preallocation( + &mut self, + referring: DocumentTypeRef, + index: &Index, + key_sources: &[PreallocatedKeySource], + flags: Option<&StorageFlags>, + ) -> Result<(), Error> { + let mut levels = Vec::with_capacity(index.properties.len()); + let mut current = referring.index_structure(); + for (property, source) in index.properties.iter().zip(key_sources) { + let name = property.name.as_str(); + let Some(sub_level) = current.sub_levels().get(name) else { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a preallocated index's property must exist in the index structure", + ))); + }; + let raw = match *source { + PreallocatedKeySource::ReferencedDocumentId => self.raw("$id")?, + PreallocatedKeySource::ReferencedDocumentProperty(referenced) => { + let Some(referring_property) = referring.flattened_properties().get(name) + else { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a preallocated index's property must be a property of its \ + document type", + ))); + }; + if !bound_value_fits_referring_property( + self.document, + referenced, + &referring_property.property_type, + self.platform_version, + )? { + return Ok(()); + } + self.raw(referenced)? + } + }; + // A value the document does not carry, or a null one: no entry + // can agree with it, so nothing is preallocated. + match raw { + Some(raw) if !raw.is_empty() => levels.push((name, sub_level, raw)), + _ => return Ok(()), + } + current = sub_level; + } + let Some(info) = current.has_index_with_type() else { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a preallocated index must terminate at its last property", + ))); + }; + + let indexes = vec![index.name.clone()]; + let mut path: Vec> = Vec::new(); + let mut parent_value_tree_type = TreeType::NormalTree; + let mut parent_counts_continuations = false; + for (position, (name, sub_level, raw)) in levels.into_iter().enumerate() { + let tree_types = index_level_tree_types_with_continuation_demotion(sub_level)?; + if position > 0 { + let wrapped = continuation_contributes_zero( + parent_value_tree_type, + parent_counts_continuations, + sub_level, + ) && zero_contribution_wrapper( + parent_value_tree_type, + tree_types.property_name_tree_type, + ) + .map_err(|refusal| { + Error::Drive(DriveError::CorruptedContractIndexes(format!( + "index level {name:?} cannot hang under its value tree: {refusal:?}" + ))) + })? + .is_some(); + self.push(Write { + path: path.clone(), + key: name.as_bytes().to_vec(), + element: empty_tree(tree_types.property_name_tree_type, wrapped, flags), + parent: parent_value_tree_type, + role: LayoutRole::NextIndexProperty, + indexes: indexes.clone(), + if_absent: true, + ephemeral: false, + ranking: None, + referring_type: None, + expiration: false, + flagged: flags.is_some(), + }); + } + path.push(name.as_bytes().to_vec()); + self.push(Write { + path: path.clone(), + key: raw.clone(), + element: empty_tree(tree_types.value_tree_type, false, flags), + parent: tree_types.property_name_tree_type, + role: LayoutRole::IndexValue, + indexes: indexes.clone(), + if_absent: true, + ephemeral: false, + ranking: None, + referring_type: None, + expiration: false, + flagged: flags.is_some(), + }); + // An empty value tree ranks with its empty aggregate. + let (count, sum) = empty_tree_aggregate(tree_types.value_tree_type); + self.ranking_rows(&path, &raw, &tree_types.ranked_axes, count, sum, &indexes)?; + path.push(raw); + parent_value_tree_type = tree_types.value_tree_type; + parent_counts_continuations = level_counts_continuations(sub_level); + } + self.push(Write { + path, + key: vec![0], + element: empty_tree(terminal_member_tree_type(info), false, flags), + parent: parent_value_tree_type, + role: LayoutRole::Terminal, + indexes, + if_absent: true, + ephemeral: false, + ranking: None, + referring_type: None, + expiration: false, + flagged: flags.is_some(), + }); + Ok(()) + } + + /// Where an index ends: its entry for the document + /// (`add_reference_for_index_level_for_contract_operations`). + #[allow(clippy::too_many_arguments)] + fn terminal( + &mut self, + path: &[Vec], + names: &[String], + value_tree_type: TreeType, + info: &IndexLevelTypeInfo, + any_null: bool, + all_null: bool, + flags: Option<&StorageFlags>, + ephemeral: bool, + ) -> Result<(), Error> { + if all_null && !info.should_insert_with_all_null { + return Ok(()); + } + let indexes = index_ending_at(&self.index_paths, names); + let member_tree_type = terminal_member_tree_type(info); + let mut members_path = path.to_vec(); + members_path.push(vec![0]); + + if let Some(terminal) = info.terminal.as_deref() { + // indexOnly: a tree of entries keyed by the terminal components, + // each holding the row commitment and the entry payload. + self.push(Write { + path: path.to_vec(), + key: vec![0], + element: empty_tree(member_tree_type, false, flags), + parent: value_tree_type, + role: LayoutRole::Terminal, + indexes: indexes.clone(), + if_absent: true, + ephemeral, + ranking: None, + referring_type: None, + expiration: false, + flagged: flags.is_some(), + }); + let member_key = index_only_member_key( + self.document, + self.document_type, + terminal, + None, + self.platform_version, + )?; + let mut item = index_only_row_commitment( + self.document, + self.document_type, + self.platform_version, + )? + .to_vec(); + item.extend(encode_index_only_entry_payload( + self.document, + self.document_type, + )?); + let element_flags = StorageFlags::map_to_some_element_flags(flags); + let element = match info.summable.as_deref() { + Some(_) => PricedElement::ItemWithSumItem { + item_len: item.len() as u32, + flags_len: flags_len(flags), + }, + None => PricedElement::Serialized { + serialized_len: serialized_len( + &Element::new_item_with_flags(item, element_flags), + self.platform_version, + )?, + }, + }; + self.push(Write { + path: members_path, + key: member_key, + element, + parent: member_tree_type, + role: LayoutRole::Member, + indexes, + if_absent: false, + ephemeral, + ranking: None, + referring_type: None, + expiration: false, + flagged: flags.is_some(), + }); + return Ok(()); + } + + // References carry the document's own flags, except under a time + // window with a ttl, whose elements Drive strips of every flag + // (`retag_ephemeral_with`). + let reference_flags = if ephemeral { + None + } else { + self.document_flags.clone() + }; + let reference = match info.summable.as_deref() { + Some(property) => make_document_reference_with_sum_item( + self.document, + self.document_type, + read_document_sum_contribution(self.document, property)?, + reference_flags.as_ref(), + ), + None => { + make_document_reference(self.document, self.document_type, reference_flags.as_ref()) + } + }; + let reference = PricedElement::Serialized { + serialized_len: serialized_len(&reference, self.platform_version)?, + }; + + if info.index_type.is_unique() && !any_null { + self.push(Write { + path: path.to_vec(), + key: vec![0], + element: reference, + parent: value_tree_type, + role: LayoutRole::Terminal, + indexes, + if_absent: false, + ephemeral, + ranking: None, + referring_type: None, + expiration: false, + flagged: reference_flags.is_some(), + }); + return Ok(()); + } + + self.push(Write { + path: path.to_vec(), + key: vec![0], + element: empty_tree(member_tree_type, false, flags), + parent: value_tree_type, + role: LayoutRole::Terminal, + indexes: indexes.clone(), + if_absent: true, + ephemeral, + ranking: None, + referring_type: None, + expiration: false, + flagged: flags.is_some(), + }); + self.push(Write { + path: members_path, + key: self.document.id().to_vec(), + element: reference, + parent: member_tree_type, + role: LayoutRole::Member, + indexes, + if_absent: false, + ephemeral, + ranking: None, + referring_type: None, + expiration: false, + flagged: reference_flags.is_some(), + }); + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/document/expiration/mod.rs b/packages/rs-drive/src/drive/document/expiration/mod.rs index 5ed2b5633f4..64e2595f82f 100644 --- a/packages/rs-drive/src/drive/document/expiration/mod.rs +++ b/packages/rs-drive/src/drive/document/expiration/mod.rs @@ -21,18 +21,26 @@ //! //! [`Drive::remove_expired_documents`]: crate::drive::Drive::remove_expired_documents +#[cfg(feature = "server")] mod add_document_expiration_operations; +#[cfg(feature = "server")] mod add_estimation_costs_for_document_expiration; +#[cfg(feature = "server")] mod fetch_expired_documents; +#[cfg(feature = "server")] mod insert_document_ttl_trees; /// Paths of the documents expirations tree pub mod paths; /// Prices of the bytes and the deletion of documents with a time to live pub mod pricing; +#[cfg(feature = "server")] mod remove_document_expiration_operations; +#[cfg(feature = "server")] mod remove_expired_documents; +#[cfg(feature = "server")] pub use fetch_expired_documents::ExpiredDocument; +#[cfg(feature = "server")] pub use remove_expired_documents::RemovedExpiredDocuments; use crate::error::drive::DriveError; diff --git a/packages/rs-drive/src/drive/document/expiration/pricing.rs b/packages/rs-drive/src/drive/document/expiration/pricing.rs index 58052f6812b..685410b4e62 100644 --- a/packages/rs-drive/src/drive/document/expiration/pricing.rs +++ b/packages/rs-drive/src/drive/document/expiration/pricing.rs @@ -4,6 +4,7 @@ use crate::error::drive::DriveError; use crate::error::fee::FeeError; use crate::error::Error; +#[cfg(feature = "server")] use crate::fees::op::EphemeralPricing; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; @@ -19,20 +20,45 @@ use platform_version::version::fee::FeeVersion; /// The price never decreases with the lifetime, which keeps an estimate made at an earlier /// block time (a longer remaining lifetime) an upper bound of the price at execution. The /// epochs only decide which epochs the pools pay the amount to. +#[cfg(feature = "server")] pub fn document_ttl_pricing( remaining_lifetime_ms: u64, epoch_time_length_s: u64, epochs_per_era: u16, fee_version: &FeeVersion, ) -> Result { + let credit_per_byte = document_ttl_credit_per_byte(remaining_lifetime_ms, fee_version)?; + let epoch_ms = epoch_time_length_s + .checked_mul(1000) + .filter(|epoch_ms| *epoch_ms > 0) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "the epoch length must be a positive number of milliseconds", + )))?; + let lifetime_epochs = u16::try_from(remaining_lifetime_ms.div_ceil(epoch_ms)) + .unwrap_or(u16::MAX) + .clamp(1, epochs_per_era.max(1)); + Ok(EphemeralPricing::DocumentTtl { + credit_per_byte, + lifetime_epochs, + }) +} + +/// What a byte of a document with `remaining_lifetime_ms` left to live costs: the first tier +/// covering that lifetime, or past the last tier the schedule's price per pricing period times +/// the periods it spans, rounded up. Shared by [`document_ttl_pricing`] and +/// `drive::document::cost`. +pub fn document_ttl_credit_per_byte( + remaining_lifetime_ms: u64, + fee_version: &FeeVersion, +) -> Result { let schedule = &fee_version.document_ttl; let tier_price = schedule .tiers .iter() .find(|tier| remaining_lifetime_ms <= u64::from(tier.max_ttl_seconds) * 1000) .map(|tier| tier.credit_per_byte); - let credit_per_byte = match tier_price { - Some(credit_per_byte) => credit_per_byte, + match tier_price { + Some(credit_per_byte) => Ok(credit_per_byte), None => { let period_ms = u64::from(schedule.pricing_period_seconds) * 1000; if period_ms == 0 { @@ -45,22 +71,9 @@ pub fn document_ttl_pricing( .checked_mul(remaining_lifetime_ms.div_ceil(period_ms)) .ok_or(Error::Fee(FeeError::Overflow( "overflow pricing the periods a document with a time to live spans", - )))? + ))) } - }; - let epoch_ms = epoch_time_length_s - .checked_mul(1000) - .filter(|epoch_ms| *epoch_ms > 0) - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "the epoch length must be a positive number of milliseconds", - )))?; - let lifetime_epochs = u16::try_from(remaining_lifetime_ms.div_ceil(epoch_ms)) - .unwrap_or(u16::MAX) - .clamp(1, epochs_per_era.max(1)); - Ok(EphemeralPricing::DocumentTtl { - credit_per_byte, - lifetime_epochs, - }) + } } /// When a document created at `created_at` expires under a time to live of `ttl_seconds`: diff --git a/packages/rs-drive/src/drive/document/fixture_contracts.rs b/packages/rs-drive/src/drive/document/fixture_contracts.rs new file mode 100644 index 00000000000..17138b47340 --- /dev/null +++ b/packages/rs-drive/src/drive/document/fixture_contracts.rs @@ -0,0 +1,90 @@ +//! The test contracts `drive::document::layout` and `drive::document::cost` +//! are held to Drive with, and the adjustments random documents need to +//! insert into them. + +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::document::Document; +use dpp::document::DocumentV0Getters; +use dpp::platform_value::Value; +use std::collections::BTreeSet; + +/// Contracts covering the index shapes Drive lays out: plain, unique and +/// compound indexes, history, countable and summable types and indexes, +/// ranked and chained indexes, time windows, indexOnly types with +/// terminals, flat and preallocated indexes. +pub(crate) const CONTRACTS: [&str; 19] = [ + "tests/supporting_files/contract/family/family-contract.json", + "tests/supporting_files/contract/family/family-contract-fields-optional.json", + "tests/supporting_files/contract/family/family-contract-countable.json", + "tests/supporting_files/contract/family/family-contract-with-history.json", + "tests/supporting_files/contract/dashpay/dashpay-contract.json", + "tests/supporting_files/contract/references/references_with_contract_history.json", + "tests/supporting_files/contract/restaurants/restaurants-contract.json", + "tests/supporting_files/contract/trending/trending-contract.json", + "tests/supporting_files/contract/trending/trending-sibling-contract.json", + "tests/supporting_files/contract/yappr-likes/yappr-likes-contract.json", + "tests/supporting_files/contract/yappr-likes/yappr-likes-preallocated-contract.json", + "tests/supporting_files/contract/yappr-likes/yappr-likes-author-preallocated-contract.json", + "tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json", + "tests/supporting_files/contract/index-only-scalar-terminal/index-only-scalar-terminal-contract.json", + "tests/supporting_files/contract/tally/tally-contract.json", + "tests/supporting_files/contract/tip-jar/tip-jar-contract.json", + "tests/supporting_files/contract/grades/grades-contract.json", + "tests/supporting_files/contract/grades/grades-ranked-contract.json", + "tests/supporting_files/contract/grades/grades-compound-ranked-contract.json", +]; + +/// Leaves out the optional properties of the type's unique indexes, so a +/// unique index gets entries with null values (two such documents share +/// a key, so they go in a tree by id). +pub(crate) fn leave_out_optional_unique_values( + document: &mut Document, + document_type: DocumentTypeRef, +) { + let required = document_type.required_fields(); + for index in document_type + .indexes() + .values() + .filter(|index| index.unique) + { + for property in &index.properties { + if !property.name.starts_with('$') && !required.contains(&property.name) { + document.properties_mut().remove(&property.name); + } + } + } +} + +/// Random integers ignore the schema's bounds, and a few of them overflow +/// a sum tree; give each summed property a small value instead (within +/// the bounds of every fixture: 1 to 7). +pub(crate) fn small_sums(document: &mut Document, document_type: DocumentTypeRef, seed: u64) { + let summed = document_type + .documents_summable() + .map(str::to_string) + .into_iter() + .chain( + document_type + .indexes() + .values() + .filter_map(|index| index.summable.clone()), + ) + .collect::>(); + for property in summed { + if let Some(value) = document.properties_mut().get_mut(&property) { + let small = match &*value { + Value::U8(_) => Value::U8(seed as u8), + Value::I8(_) => Value::I8(seed as i8), + Value::U16(_) => Value::U16(seed as u16), + Value::I16(_) => Value::I16(seed as i16), + Value::U32(_) => Value::U32(seed as u32), + Value::I32(_) => Value::I32(seed as i32), + Value::U64(_) => Value::U64(seed), + Value::I64(_) => Value::I64(seed as i64), + other => other.clone(), + }; + *value = small; + } + } +} diff --git a/packages/rs-drive/src/drive/document/index_only.rs b/packages/rs-drive/src/drive/document/index_only.rs index ecc75bb88bf..5742468c37c 100644 --- a/packages/rs-drive/src/drive/document/index_only.rs +++ b/packages/rs-drive/src/drive/document/index_only.rs @@ -23,6 +23,7 @@ use crate::drive::constants::CONTRACT_DOCUMENTS_PATH_HEIGHT; use crate::drive::document::index_level_tree_types::terminal_member_tree_type; use crate::drive::document::index_only_item_estimated_value_size; +pub(crate) use crate::drive::document::index_only_member_key; use crate::drive::document::time_range_ttl::entry_key_bucket_start; use crate::drive::{Drive, RootTree}; use crate::error::drive::DriveError; @@ -447,28 +448,3 @@ pub(crate) fn index_only_terminal_max_key_size( } Ok(u8::try_from(total).unwrap_or(u8::MAX)) } - -/// The member key `document` produces under a terminal: the components' -/// values in their tree-key encoding, concatenated in the terminal's -/// order — one component for a plain terminal, several for a composite -/// one. The write path's twin of the query side's -/// `serialize_value_for_key` concatenation. -pub(crate) fn index_only_member_key( - document: &Document, - document_type: DocumentTypeRef, - terminal: &[String], - owner_id: Option<[u8; 32]>, - platform_version: &PlatformVersion, -) -> Result, Error> { - let mut member_key = Vec::new(); - for component in terminal { - let encoded = document - .get_raw_for_document_type(component, document_type, owner_id, platform_version)? - .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( - "indexOnly terminal value must be present: the parser requires every \ - indexOnly property (and $ownerId) to be set", - )))?; - member_key.extend(encoded); - } - Ok(member_key) -} diff --git a/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs b/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs index a4a1ecb2881..71e0d678665 100644 --- a/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_preallocated_index_tree_operations/mod.rs @@ -36,6 +36,7 @@ //! this code. The delete-side counterpart is versioned the same way //! (`remove_reference_for_index_level_for_contract_operations_v1`). +use crate::drive::document::bound_value_fits_referring_property; use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; use crate::drive::document::index_level_tree_types::{ continuation_contributes_zero, index_level_tree_types_with_continuation_demotion, @@ -58,11 +59,7 @@ use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::config::v0::DataContractConfigGettersV0; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; -use dpp::data_contract::document_type::{ - DocumentPropertyType, DocumentTypeRef, Index, PreallocatedKeySource, -}; -use dpp::document::{Document, DocumentV0Getters}; -use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper; +use dpp::data_contract::document_type::{DocumentTypeRef, Index, PreallocatedKeySource}; use dpp::version::PlatformVersion; use grovedb::batch::KeyInfoPath; use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; @@ -530,36 +527,3 @@ impl Drive { Ok(()) } } - -/// Whether `document`'s value of `referenced_property`, which a -/// `propertyAgreement` binds to a referring index property of -/// `referring_property_type`, is no wider as a tree key than a value of that -/// property can be. A wider value equals no referring document's value, so no -/// entry would ever sit under trees keyed by it, and past 255 bytes it is no -/// tree key at all. An absent value fits (the caller skips it on its own), as -/// do the referenced document's `$ownerId` and `$creatorId`, 32-byte -/// identifiers that registration pairs with an identifier. -fn bound_value_fits_referring_property( - document: &Document, - referenced_property: &str, - referring_property_type: &DocumentPropertyType, - platform_version: &PlatformVersion, -) -> Result { - if referenced_property.starts_with('$') { - return Ok(true); - } - let Some(value) = document - .properties() - .get_optional_at_path(referenced_property)? - else { - return Ok(true); - }; - let Some(max_width) = referring_property_type.saturating_max_byte_size(platform_version)? - else { - return Ok(true); - }; - let width = referring_property_type - .encode_value_for_tree_keys(value)? - .len(); - Ok(width <= usize::from(max_width)) -} diff --git a/packages/rs-drive/src/drive/document/layout.rs b/packages/rs-drive/src/drive/document/layout.rs index 9b6f377c026..fc810bde3d8 100644 --- a/packages/rs-drive/src/drive/document/layout.rs +++ b/packages/rs-drive/src/drive/document/layout.rs @@ -26,13 +26,14 @@ use crate::drive::document::index_level_tree_types::{ zero_contribution_wrapper, }; use crate::drive::document::primary_key_tree_type::DocumentTypePrimaryKeyTreeType; +use crate::drive::document::sdk_value::{map, number, text, texts}; use crate::error::drive::DriveError; use crate::error::Error; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::{ is_flat_level_key, DocumentTypeRef, IndexLevel, IndexLevelTypeInfo, IndexType, }; -use dpp::platform_value::{Value, ValueMap}; +use dpp::platform_value::Value; use dpp::version::PlatformVersion; use grovedb::element::IndexAxis; use grovedb_merk::tree_type::TreeType; @@ -422,7 +423,7 @@ pub fn document_type_layout( /// Each index's name and the level keys its path takes through the index /// structure, as `IndexLevel::try_from_indices` keys them. -fn index_paths(document_type: DocumentTypeRef) -> Vec<(String, Vec)> { +pub(crate) fn index_paths(document_type: DocumentTypeRef) -> Vec<(String, Vec)> { document_type .indexes() .values() @@ -442,7 +443,10 @@ fn index_paths(document_type: DocumentTypeRef) -> Vec<(String, Vec)> { } /// The indexes whose path passes through `path`. -fn indexes_through(index_paths: &[(String, Vec)], path: &[String]) -> Vec { +pub(crate) fn indexes_through( + index_paths: &[(String, Vec)], + path: &[String], +) -> Vec { index_paths .iter() .filter(|(_, index_path)| index_path.starts_with(path)) @@ -451,7 +455,10 @@ fn indexes_through(index_paths: &[(String, Vec)], path: &[String]) -> Ve } /// The index whose path ends at `path`. -fn index_ending_at(index_paths: &[(String, Vec)], path: &[String]) -> Vec { +pub(crate) fn index_ending_at( + index_paths: &[(String, Vec)], + path: &[String], +) -> Vec { index_paths .iter() .filter(|(_, index_path)| index_path.as_slice() == path) @@ -783,31 +790,6 @@ fn leaf(key: LayoutKey, role: LayoutRole, element: LayoutElement) -> LayoutNode } } -fn text(value: &str) -> Value { - Value::Text(value.to_string()) -} - -fn map(entries: Vec<(&str, Value)>) -> Value { - Value::Map( - entries - .into_iter() - .map(|(key, value)| (text(key), value)) - .collect::(), - ) -} - -/// Seconds as a value JavaScript reads as a number: a u32 (every valid -/// window fits), else a float, rather than a u64, which becomes a BigInt. -fn seconds(value: u64) -> Value { - u32::try_from(value) - .map(Value::U32) - .unwrap_or(Value::Float(value as f64)) -} - -fn texts(values: &[String]) -> Value { - Value::Array(values.iter().map(|value| text(value)).collect()) -} - impl LayoutKey { fn to_value(&self) -> Value { match self { @@ -830,9 +812,9 @@ impl LayoutKey { } => map(vec![ ("kind", text("timeRangeBucket")), ("property", text(property)), - ("rangeSeconds", seconds(*range_seconds)), - ("stepSeconds", seconds(*step_seconds)), - ("phaseSeconds", seconds(*phase_seconds)), + ("rangeSeconds", number(*range_seconds)), + ("stepSeconds", number(*step_seconds)), + ("phaseSeconds", number(*phase_seconds)), ]), LayoutKey::MemberKey { components } => map(vec![ ("kind", text("memberKey")), @@ -911,6 +893,9 @@ impl DocumentTypeLayout { #[cfg(all(test, feature = "server"))] mod tests { use super::*; + use crate::drive::document::fixture_contracts::{ + leave_out_optional_unique_values, small_sums, CONTRACTS, + }; use crate::drive::{Drive, RootTree}; use crate::structure::{drive_structure, ElementKind, StructureNode}; use crate::util::test_helpers::setup::{ @@ -920,37 +905,10 @@ mod tests { use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::random_document::CreateRandomDocument; use dpp::data_contract::DataContract; - use dpp::document::{Document, DocumentV0Getters}; use grovedb::query_result_type::QueryResultType::QueryKeyElementPairResultType; use grovedb::{Element, PathQuery, Query, SizedQuery}; use std::collections::BTreeSet; - /// Contracts covering the index shapes Drive lays out: plain, unique and - /// compound indexes, history, countable and summable types and indexes, - /// ranked and chained indexes, time windows, indexOnly types with - /// terminals, flat and preallocated indexes. - const CONTRACTS: [&str; 19] = [ - "tests/supporting_files/contract/family/family-contract.json", - "tests/supporting_files/contract/family/family-contract-fields-optional.json", - "tests/supporting_files/contract/family/family-contract-countable.json", - "tests/supporting_files/contract/family/family-contract-with-history.json", - "tests/supporting_files/contract/dashpay/dashpay-contract.json", - "tests/supporting_files/contract/references/references_with_contract_history.json", - "tests/supporting_files/contract/restaurants/restaurants-contract.json", - "tests/supporting_files/contract/trending/trending-contract.json", - "tests/supporting_files/contract/trending/trending-sibling-contract.json", - "tests/supporting_files/contract/yappr-likes/yappr-likes-contract.json", - "tests/supporting_files/contract/yappr-likes/yappr-likes-preallocated-contract.json", - "tests/supporting_files/contract/yappr-likes/yappr-likes-author-preallocated-contract.json", - "tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json", - "tests/supporting_files/contract/index-only-scalar-terminal/index-only-scalar-terminal-contract.json", - "tests/supporting_files/contract/tally/tally-contract.json", - "tests/supporting_files/contract/tip-jar/tip-jar-contract.json", - "tests/supporting_files/contract/grades/grades-contract.json", - "tests/supporting_files/contract/grades/grades-ranked-contract.json", - "tests/supporting_files/contract/grades/grades-compound-ranked-contract.json", - ]; - fn layer(drive: &Drive, path: &[Vec]) -> Vec<(Vec, Element)> { let mut query = Query::new(); query.insert_all(); @@ -1123,57 +1081,6 @@ mod tests { contract } - /// Leaves out the optional properties of the type's unique indexes, so a - /// unique index gets entries with null values (two such documents share - /// a key, so they go in a tree by id). - fn leave_out_optional_unique_values(document: &mut Document, document_type: DocumentTypeRef) { - let required = document_type.required_fields(); - for index in document_type - .indexes() - .values() - .filter(|index| index.unique) - { - for property in &index.properties { - if !property.name.starts_with('$') && !required.contains(&property.name) { - document.properties_mut().remove(&property.name); - } - } - } - } - - /// Random integers ignore the schema's bounds, and a few of them overflow - /// a sum tree; give each summed property a small value instead (within - /// the bounds of every fixture: 1 to 7). - fn small_sums(document: &mut Document, document_type: DocumentTypeRef, seed: u64) { - let summed = document_type - .documents_summable() - .map(str::to_string) - .into_iter() - .chain( - document_type - .indexes() - .values() - .filter_map(|index| index.summable.clone()), - ) - .collect::>(); - for property in summed { - if let Some(value) = document.properties_mut().get_mut(&property) { - let small = match &*value { - Value::U8(_) => Value::U8(seed as u8), - Value::I8(_) => Value::I8(seed as i8), - Value::U16(_) => Value::U16(seed as u16), - Value::I16(_) => Value::I16(seed as i16), - Value::U32(_) => Value::U32(seed as u32), - Value::I32(_) => Value::I32(seed as i32), - Value::U64(_) => Value::U64(seed), - Value::I64(_) => Value::I64(seed as i64), - other => other.clone(), - }; - *value = small; - } - } - } - #[test] fn should_lay_out_what_drive_writes() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive/src/drive/document/mod.rs b/packages/rs-drive/src/drive/document/mod.rs index 3d4dc2ddebf..4ca36e201d3 100644 --- a/packages/rs-drive/src/drive/document/mod.rs +++ b/packages/rs-drive/src/drive/document/mod.rs @@ -6,19 +6,31 @@ #[cfg(feature = "server")] use crate::drive::votes::paths::CONTESTED_DOCUMENT_STORAGE_TREE_KEY; -#[cfg(feature = "server")] +#[cfg(any(feature = "server", feature = "verify"))] +use crate::error::drive::DriveError; +#[cfg(any(feature = "server", feature = "verify"))] +use crate::error::Error; +#[cfg(any(feature = "server", feature = "verify"))] use crate::util::storage_flags::StorageFlags; -#[cfg(feature = "server")] +#[cfg(any(feature = "server", feature = "verify"))] use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -#[cfg(feature = "server")] +#[cfg(any(feature = "server", feature = "verify"))] +use dpp::data_contract::document_type::DocumentPropertyType; +#[cfg(any(feature = "server", feature = "verify"))] use dpp::data_contract::document_type::DocumentTypeRef; #[cfg(any(feature = "server", feature = "verify"))] +use dpp::document::document_methods::DocumentMethodsV0; +#[cfg(any(feature = "server", feature = "verify"))] use dpp::document::Document; -#[cfg(feature = "server")] +#[cfg(any(feature = "server", feature = "verify"))] use dpp::document::DocumentV0Getters; -#[cfg(feature = "server")] -use grovedb::reference_path::ReferencePathType::UpstreamRootHeightReference; -#[cfg(feature = "server")] +#[cfg(any(feature = "server", feature = "verify"))] +use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper; +#[cfg(any(feature = "server", feature = "verify"))] +use dpp::version::PlatformVersion; +#[cfg(any(feature = "server", feature = "verify"))] +use grovedb::element::reference_path::ReferencePathType::UpstreamRootHeightReference; +#[cfg(any(feature = "server", feature = "verify"))] use grovedb::Element; #[cfg(feature = "server")] @@ -27,7 +39,7 @@ mod delete; mod estimation_costs; /// Document expiry: the expirations tree of documents whose type declares a `ttl`, their /// pricing, and the cleanup that deletes them once expired -#[cfg(feature = "server")] +#[cfg(any(feature = "server", feature = "verify"))] pub mod expiration; #[cfg(feature = "server")] mod fetch_property_constraint_aggregate; @@ -78,6 +90,19 @@ pub(crate) mod index_level_tree_types; #[cfg(any(feature = "server", feature = "verify"))] pub mod layout; +/// What creating one document costs, element by element, from the elements +/// the index walkers write +#[cfg(any(feature = "server", feature = "verify"))] +pub mod cost; + +/// The plain values the layout and the cost estimate hand to the SDKs +#[cfg(any(feature = "server", feature = "verify"))] +pub(crate) mod sdk_value; + +/// The test contracts the layout and the cost estimate are held to Drive with +#[cfg(all(test, feature = "server"))] +pub(crate) mod fixture_contracts; + /// Shared TTL semantics for time-range indexes — see /// `book/src/drive/time-range-ttl.md`. #[cfg(feature = "server")] @@ -116,7 +141,68 @@ pub use index_only_row_commitment::INDEX_ONLY_ROW_COMMITMENT_SIZE; /// and prevents unbounded history reads. pub const MAX_DOCUMENT_HISTORY_FETCH_LIMIT: u16 = 10; -#[cfg(feature = "server")] +#[cfg(any(feature = "server", feature = "verify"))] +/// The member key `document` produces under a terminal: the components' +/// values in their tree-key encoding, concatenated in the terminal's +/// order — one component for a plain terminal, several for a composite +/// one. The write path's twin of the query side's +/// `serialize_value_for_key` concatenation. +pub(crate) fn index_only_member_key( + document: &Document, + document_type: DocumentTypeRef, + terminal: &[String], + owner_id: Option<[u8; 32]>, + platform_version: &PlatformVersion, +) -> Result, Error> { + let mut member_key = Vec::new(); + for component in terminal { + let encoded = document + .get_raw_for_document_type(component, document_type, owner_id, platform_version)? + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "indexOnly terminal value must be present: the parser requires every \ + indexOnly property (and $ownerId) to be set", + )))?; + member_key.extend(encoded); + } + Ok(member_key) +} + +#[cfg(any(feature = "server", feature = "verify"))] +/// Whether `document`'s value of `referenced_property`, which a +/// `propertyAgreement` binds to a referring index property of +/// `referring_property_type`, is no wider as a tree key than a value of that +/// property can be. A wider value equals no referring document's value, so no +/// entry would ever sit under trees keyed by it, and past 255 bytes it is no +/// tree key at all. An absent value fits (the caller skips it on its own), as +/// do the referenced document's `$ownerId` and `$creatorId`, 32-byte +/// identifiers that registration pairs with an identifier. +/// Shared by the preallocation path and `drive::document::cost`. +pub(crate) fn bound_value_fits_referring_property( + document: &Document, + referenced_property: &str, + referring_property_type: &DocumentPropertyType, + platform_version: &PlatformVersion, +) -> Result { + if referenced_property.starts_with('$') { + return Ok(true); + } + let Some(value) = document + .properties() + .get_optional_at_path(referenced_property)? + else { + return Ok(true); + }; + let Some(max_width) = referring_property_type.saturating_max_byte_size(platform_version)? + else { + return Ok(true); + }; + let width = referring_property_type + .encode_value_for_tree_keys(value)? + .len(); + Ok(width <= usize::from(max_width)) +} + +#[cfg(any(feature = "server", feature = "verify"))] /// Creates a reference to a document. fn make_document_reference( document: &Document, @@ -151,7 +237,7 @@ fn make_document_reference( ) } -#[cfg(feature = "server")] +#[cfg(any(feature = "server", feature = "verify"))] /// Creates an `Element::ReferenceWithSumItem` that pins a document to /// a summable index path AND carries that document's `sum_property` /// contribution to the parent sum tree. diff --git a/packages/rs-drive/src/drive/document/sdk_value.rs b/packages/rs-drive/src/drive/document/sdk_value.rs new file mode 100644 index 00000000000..16a42e05050 --- /dev/null +++ b/packages/rs-drive/src/drive/document/sdk_value.rs @@ -0,0 +1,34 @@ +//! Building the plain values `drive::document::layout` and +//! `drive::document::cost` hand to the SDKs, in the shapes JavaScript reads +//! well. + +use dpp::platform_value::{Value, ValueMap}; + +pub(crate) fn text(value: &str) -> Value { + Value::Text(value.to_string()) +} + +/// A map of `entries`, leaving out a key whose value is absent (`Null`): +/// JavaScript reads a null that crosses into it as undefined anyway. +pub(crate) fn map(entries: Vec<(&str, Value)>) -> Value { + Value::Map( + entries + .into_iter() + .filter(|(_, value)| !value.is_null()) + .map(|(key, value)| (text(key), value)) + .collect::(), + ) +} + +pub(crate) fn texts(values: &[String]) -> Value { + Value::Array(values.iter().map(|value| text(value)).collect()) +} + +/// A count as a value JavaScript reads as a number: a u32 when it fits, +/// else a float (exact up to 2^53), rather than a u64, which becomes a +/// BigInt. +pub(crate) fn number(value: u64) -> Value { + u32::try_from(value) + .map(Value::U32) + .unwrap_or(Value::Float(value as f64)) +} diff --git a/packages/wasm-sdk/Cargo.toml b/packages/wasm-sdk/Cargo.toml index b3c9c473da3..1095bad312d 100644 --- a/packages/wasm-sdk/Cargo.toml +++ b/packages/wasm-sdk/Cargo.toml @@ -66,6 +66,8 @@ dash-sdk = { path = "../rs-sdk", features = [ ], default-features = false } drive = { path = "../rs-drive", default-features = false, features = [ "verify", + # the storage refund of `documentCreateCost` + "fee-distribution", ] } console_error_panic_hook = { version = "0.1.6" } thiserror = { version = "2.0.17" } diff --git a/packages/wasm-sdk/src/document_create_cost.rs b/packages/wasm-sdk/src/document_create_cost.rs new file mode 100644 index 00000000000..86f1ab092f7 --- /dev/null +++ b/packages/wasm-sdk/src/document_create_cost.rs @@ -0,0 +1,284 @@ +//! What creating a document of a type costs, computed by Drive +//! (`drive::drive::document::cost`) from the contract alone. + +use crate::error::WasmSdkError; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::identity::KeyType; +use dash_sdk::dpp::version::PlatformVersion; +use drive::drive::document::cost::{document_type_create_cost, CostAssumptions, FieldChoice}; +use serde::Deserialize; +use std::collections::BTreeMap; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::{JsCast, JsValue}; +use wasm_dpp2::data_contract::model::DataContractWasm; +use wasm_dpp2::serialization::conversions::platform_value_to_object; +use wasm_dpp2::version::{PlatformVersionLikeJs, PlatformVersionWasm}; + +#[wasm_bindgen(typescript_custom_section)] +const DOCUMENT_CREATE_COST_TS: &'static str = r#" +/** What to assume when pricing a document create. Every field is optional. */ +export interface DocumentCreateCostOptions { + /** + * The document to price, by field (`a.b` for a property of an object): + * whether an optional field is present (default: present), and the length + * of a variable-size one, in characters, bytes or elements (default: the + * middle between its bounds, the size Drive's own fee estimates assume). + */ + fields?: Record; + /** Documents of the type already stored, for the processing estimate (default 1000). */ + existingDocuments?: number; + /** The type of the key the transition is signed with (default ECDSA_SECP256K1). */ + signatureKeyType?: 'ECDSA_SECP256K1' | 'BLS12_381' | 'ECDSA_HASH160' | 'BIP13_SCRIPT_HASH' | 'EDDSA_25519_HASH160'; + /** The fee increase the transition asks for, in percent of the processing fee (default 0). */ + userFeeIncrease?: number; + /** The epoch's fee multiplier in permille, for action fees priced by it (default 1000). */ + feeMultiplierPermille?: number; + /** Contenders already in the contest, for a contested create (default 0). */ + contenders?: number; +} + +/** + * An amount under both storage scenarios: every index value new (the first + * document with these values creates their trees), and every value an + * earlier document can hold already stored. + */ +export interface DocumentCostScenarios { + newValues: number; + knownValues: number; +} + +/** What creating one document of a type costs; amounts in credits (1 Dash = `creditsPerDash`). */ +export interface DocumentCreateCost { + documentType: string; + assumptions: { + existingDocuments: number; + signatureKeyType: string; + userFeeIncrease: number; + feeMultiplierPermille: number; + contenders: number; + }; + /** The serialized document, in bytes. */ + documentBytes: number; + /** What a stored byte costs: 27,000 credits, or a ttl type's price for its lifetime. */ + creditsPerByte: number; + creditsPerDash: number; + storage: { + bytes: DocumentCostScenarios; + credits: DocumentCostScenarios; + /** The document by id. */ + primaryBytes: DocumentCostScenarios; + /** Trees created for preallocated indexes of types that will reference the document. */ + preallocatedBytes: DocumentCostScenarios; + /** A document with a ttl's entry in the documents expirations tree. */ + expirationBytes: DocumentCostScenarios; + }; + /** Each index: the bytes of the layers it shares with other indexes (counted once) and of its own. */ + indexes: Array<{ + name: string; + sharedWith: string[]; + sharedBytes: DocumentCostScenarios; + ownBytes: DocumentCostScenarios; + }>; + /** Every element the insert writes. */ + elements: Array<{ + role: string; + path: string[]; + bytes: number; + indexes: string[]; + /** Written only when absent: a tree an earlier document with the same values created. */ + ifAbsent: boolean; + writtenWhenValuesKnown: boolean; + /** On a time window with a ttl: priced as processing. */ + ephemeral: boolean; + /** In the documents expirations tree: a document with a ttl's entry. */ + expiration: boolean; + /** The axis, when the element is a ranked tree's row for the value. */ + rankingAxis?: 'count' | 'sum' | 'avg'; + /** The type whose tree the element is in, when it is a preallocated index's. */ + referringType?: string; + }>; + /** The processing fee, part by part; `exact: false` parts are estimated from the assumptions. */ + processing: Array<{ code: string; text: string; credits: DocumentCostScenarios; exact: boolean }>; + processingCredits: DocumentCostScenarios; + contractCharges: Array< + | { kind: 'actionFee'; pricing: 'feeMultiplier' | 'fixed'; declared: { owner: number; moderators: number }; charged: { owner: number; moderators: number } } + | { kind: 'tokenCost'; tokenPosition: number; amount: number; effect: 'transferToContractOwner' | 'burn'; gasFeesPaidBy: string; optional: boolean; tokenContractId?: string } + /** Paid when the value is contested; such a create is stored in the vote poll until the contest ends, and that storage is not priced here. */ + | { kind: 'contestFund'; index: string; credits: number } + >; + /** The storage fee a delete refunds, in the same epoch and a year later (nothing for a ttl type). */ + refund: { sameEpoch: DocumentCostScenarios; afterOneYear: DocumentCostScenarios }; + /** Storage, processing and action fees; not a token cost or a contest fund. */ + totalCredits: DocumentCostScenarios; + /** How each field of the priced document was filled; a length only for a variable-size field. */ + fields: Array<{ + path: string; + kind: string; + optional: boolean; + present: boolean; + length?: number; + minLength?: number; + maxLength?: number; + }>; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "DocumentCreateCostOptions | undefined")] + pub type DocumentCreateCostOptionsJs; + + #[wasm_bindgen(typescript_type = "DocumentCreateCost")] + pub type DocumentCreateCostJs; +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Options { + fields: Option>, + existing_documents: Option, + signature_key_type: Option, + user_fee_increase: Option, + fee_multiplier_permille: Option, + contenders: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct FieldOption { + present: Option, + length: Option, +} + +/// Refuses a key of `object` that is not one of `known`: `serde_wasm_bindgen` +/// reads only the keys a struct names, so a misspelled option would be +/// silently ignored. +fn refuse_unknown_keys(object: &JsValue, known: &[&str], what: &str) -> Result<(), WasmSdkError> { + let Some(object) = object.dyn_ref::() else { + return Ok(()); + }; + for key in js_sys::Object::keys(object).iter() { + let key = key.as_string().unwrap_or_default(); + if !known.contains(&key.as_str()) { + return Err(WasmSdkError::invalid_argument(format!( + "unknown {what} {key:?}; expected one of {}", + known.join(", ") + ))); + } + } + Ok(()) +} + +fn key_type(name: &str) -> Result { + Ok(match name { + "ECDSA_SECP256K1" => KeyType::ECDSA_SECP256K1, + "BLS12_381" => KeyType::BLS12_381, + "ECDSA_HASH160" => KeyType::ECDSA_HASH160, + "BIP13_SCRIPT_HASH" => KeyType::BIP13_SCRIPT_HASH, + "EDDSA_25519_HASH160" => KeyType::EDDSA_25519_HASH160, + other => { + return Err(WasmSdkError::invalid_argument(format!( + "unknown signature key type {other:?}" + ))) + } + }) +} + +/// What creating a document of the type `documentTypeName` of `contract` +/// costs: the storage every element it writes adds (exact, split by index +/// into the layers an index shares with others and its own), the processing +/// (the fixed charges of a signed batch, exact, and the writes' work, +/// estimated), what the contract adds (action fees, a token cost, a +/// contest's vote fund) and what a delete refunds. +/// +/// The document is built from sizes, not values: each variable-size field +/// at its middle size and each optional field present, unless `options` +/// says otherwise. Computed by Drive from the contract alone, so it needs no +/// network. Follows protocol version 14 on; an earlier `platformVersion` is +/// refused. +#[wasm_bindgen(js_name = "documentCreateCost")] +pub fn document_create_cost( + contract: &DataContractWasm, + #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, + options: DocumentCreateCostOptionsJs, + #[wasm_bindgen(js_name = "platformVersion")] platform_version: PlatformVersionLikeJs, +) -> Result { + let platform_version: PlatformVersion = PlatformVersionWasm::try_from(platform_version)?.into(); + let options: JsValue = options.into(); + let options: Options = if options.is_undefined() || options.is_null() { + Options::default() + } else { + refuse_unknown_keys( + &options, + &[ + "fields", + "existingDocuments", + "signatureKeyType", + "userFeeIncrease", + "feeMultiplierPermille", + "contenders", + ], + "option", + )?; + if let Some(fields) = js_sys::Reflect::get(&options, &JsValue::from_str("fields")) + .ok() + .and_then(|fields| fields.dyn_into::().ok()) + { + for field in js_sys::Object::values(&fields).iter() { + refuse_unknown_keys(&field, &["present", "length"], "field option")?; + } + } + serde_wasm_bindgen::from_value(options) + .map_err(|error| WasmSdkError::invalid_argument(format!("options: {error}")))? + }; + + let contract = contract.as_ref(); + let document_type = contract + .document_type_for_name(&document_type_name) + .map_err(|_| { + WasmSdkError::invalid_argument(format!( + "document type '{document_type_name}' not found in contract" + )) + })?; + + let mut assumptions = CostAssumptions::new(&platform_version); + if let Some(existing_documents) = options.existing_documents { + assumptions.existing_documents = existing_documents; + } + if let Some(name) = options.signature_key_type.as_deref() { + assumptions.signature_key_type = key_type(name)?; + } + if let Some(user_fee_increase) = options.user_fee_increase { + assumptions.user_fee_increase = user_fee_increase; + } + if let Some(fee_multiplier_permille) = options.fee_multiplier_permille { + assumptions.fee_multiplier_permille = fee_multiplier_permille; + } + if let Some(contenders) = options.contenders { + assumptions.contenders = contenders; + } + let choices: BTreeMap = options + .fields + .unwrap_or_default() + .into_iter() + .map(|(path, field)| { + ( + path, + FieldChoice { + present: field.present, + length: field.length, + }, + ) + }) + .collect(); + + let cost = document_type_create_cost( + contract, + document_type, + &choices, + &assumptions, + &platform_version, + ) + .map_err(|error| WasmSdkError::generic(format!("document create cost: {error}")))?; + Ok(platform_value_to_object(&cost.to_value())?.into()) +} diff --git a/packages/wasm-sdk/src/lib.rs b/packages/wasm-sdk/src/lib.rs index 1e6ca0501d8..e08467ae81a 100644 --- a/packages/wasm-sdk/src/lib.rs +++ b/packages/wasm-sdk/src/lib.rs @@ -3,6 +3,7 @@ use wasm_bindgen::prelude::wasm_bindgen; mod browser_storage; pub mod context_provider; mod contract_store; +pub mod document_create_cost; pub mod document_type_layout; pub mod dpns; pub mod encrypted_for; diff --git a/packages/wasm-sdk/tests/unit/document-create-cost.spec.ts b/packages/wasm-sdk/tests/unit/document-create-cost.spec.ts new file mode 100644 index 00000000000..7d4286b3ae5 --- /dev/null +++ b/packages/wasm-sdk/tests/unit/document-create-cost.spec.ts @@ -0,0 +1,148 @@ +/** + * `documentCreateCost`: what creating a document of a type costs, computed by + * Drive from the contract alone (no connection). The Drive test + * `should_price_what_drive_charges` holds the storage to what Drive charges; + * this checks the binding, its options and the shape JS receives. + */ +import { expect } from './helpers/chai.ts'; +import init, * as sdk from '../../dist/sdk.compressed.js'; + +const ownerId = '11111111111111111111111111111111'; + +type Scenarios = { newValues: number; knownValues: number }; +type Cost = { + documentType: string; + documentBytes: number; + creditsPerByte: number; + creditsPerDash: number; + storage: { bytes: Scenarios; credits: Scenarios; primaryBytes: Scenarios; preallocatedBytes: Scenarios }; + indexes: Array<{ name: string; sharedWith: string[]; sharedBytes: Scenarios; ownBytes: Scenarios }>; + processing: Array<{ code: string; credits: Scenarios; exact: boolean }>; + processingCredits: Scenarios; + contractCharges: Array<{ kind: string; charged?: { owner: number; moderators: number } }>; + refund: { sameEpoch: Scenarios; afterOneYear: Scenarios }; + totalCredits: Scenarios; + fields: Array<{ + path: string; + kind: string; + optional: boolean; + present: boolean; + length?: number; + maxLength?: number; + }>; +}; + +const schemas = { + note: { + type: 'object', + documentsMutable: true, + canBeDeleted: true, + properties: { + tag: { type: 'string', maxLength: 20, position: 0 }, + text: { type: 'string', maxLength: 60, position: 1 }, + mood: { type: 'string', maxLength: 10, position: 2 }, + }, + indices: [ + { name: 'byTag', properties: [{ tag: 'asc' }] }, + { name: 'byTagText', properties: [{ tag: 'asc' }, { text: 'asc' }] }, + ], + required: ['tag', 'text'], + additionalProperties: false, + actionFees: { pricing: 'fixed', create: { owner: 1000, moderators: 500 } }, + }, + /** Kept for a day. */ + memo: { + type: 'object', + ttl: 86400, + documentsMutable: true, + properties: { + text: { type: 'string', maxLength: 60, position: 0 }, + }, + indices: [{ name: 'byText', properties: [{ text: 'asc' }] }], + required: ['$createdAt', 'text'], + additionalProperties: false, + }, +}; + +describe('documentCreateCost()', () => { + let contract: sdk.DataContract; + + before(async () => { + await init(); + contract = new sdk.DataContract({ + ownerId, + identityNonce: BigInt(1), + schemas, + fullValidation: true, + platformVersion: new sdk.PlatformVersion(14), + }); + }); + + function cost(options?: unknown, documentType = 'note'): Cost { + return sdk.documentCreateCost( + contract, + documentType, + options as sdk.DocumentCreateCostOptions, + new sdk.PlatformVersion(14), + ) as unknown as Cost; + } + + it('should price a document of middle sizes in credits, as numbers', () => { + const result = cost(); + expect(result.documentType).to.equal('note'); + expect(result.creditsPerDash).to.equal(100_000_000_000); + expect(result.storage.bytes.newValues).to.be.a('number'); + expect(result.storage.credits.newValues).to.equal( + result.storage.bytes.newValues * result.creditsPerByte, + ); + // A later document with the same tag adds less. + expect(result.storage.bytes.knownValues).to.be.lessThan(result.storage.bytes.newValues); + const tag = result.fields.find((f) => f.path === 'tag'); + expect(tag).to.include({ kind: 'string', optional: false, present: true, length: 10, maxLength: 20 }); + }); + + it('should split the storage into what indexes share and what each adds', () => { + const byTag = cost().indexes.find((i) => i.name === 'byTag'); + expect(byTag?.sharedWith).to.deep.equal(['byTagText']); + expect(byTag?.sharedBytes.newValues).to.be.greaterThan(0); + expect(byTag?.ownBytes.newValues).to.be.greaterThan(0); + }); + + it('should grow with a longer value and shrink without an optional one', () => { + const middle = cost().storage.bytes.newValues; + const longer = cost({ fields: { text: { length: 60 } } }).storage.bytes.newValues; + const without = cost({ fields: { mood: { present: false } } }); + expect(longer).to.be.greaterThan(middle); + expect(without.storage.bytes.newValues).to.be.lessThan(middle); + expect(without.fields.find((f) => f.path === 'mood')).to.include({ present: false }); + }); + + it('should add the action fee and itemize the processing', () => { + const result = cost(); + expect(result.contractCharges[0]).to.deep.include({ + kind: 'actionFee', + charged: { owner: 1000, moderators: 500 }, + }); + expect(result.totalCredits.newValues).to.equal( + result.storage.credits.newValues + result.processingCredits.newValues + 1500, + ); + const signature = result.processing.find((p) => p.code === 'signature'); + expect(signature).to.deep.include({ exact: true, credits: { newValues: 15000, knownValues: 15000 } }); + const bls = cost({ signatureKeyType: 'BLS12_381' }).processing.find((p) => p.code === 'signature'); + expect(bls?.credits.newValues).to.equal(300000); + expect(result.refund.sameEpoch.newValues).to.be.lessThan(result.storage.credits.newValues); + }); + + it('should price a document with a ttl by its lifetime, with no refund', () => { + const memo = cost(undefined, 'memo'); + expect(memo.creditsPerByte).to.be.lessThan(cost().creditsPerByte); + expect(memo.refund.sameEpoch.newValues).to.equal(0); + expect(memo.processing.map((p) => p.code)).to.include('ttlCleanup'); + }); + + it('should refuse an unknown field or option', () => { + expect(() => cost({ fields: { nope: { length: 1 } } })).to.throw(/nope/); + expect(() => cost({ existingDocs: 5 })).to.throw(/existingDocs/); + expect(() => cost({ fields: { text: { size: 5 } } })).to.throw(/size/); + }); +}); From eefca92aa21436d916b9cf6e47803c0a0b6c501d Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 29 Sep 2026 07:44:46 +0700 Subject: [PATCH 113/113] feat(sdk): let apps that build document creates by hand state the contest fund (#5163) Co-authored-by: Claude Opus 5.5 --- packages/js-evo-sdk/README.md | 12 ++- packages/js-evo-sdk/src/documents/facade.ts | 17 ++++ .../tests/unit/facades/documents.spec.ts | 24 +++++ .../src/platform/documents/contest_fund.rs | 50 ++++++++-- .../batch/document_transitions/create.rs | 93 +++++++++++++++++- .../tests/unit/DocumentsTransitions.spec.ts | 94 +++++++++++++++++++ .../src/state_transitions/document.rs | 61 ++++++++++++ .../wasm-sdk/tests/functional/voting.spec.ts | 43 ++++++++- .../tests/unit/contest-fund-to-join.spec.ts | 23 +++++ 9 files changed, 405 insertions(+), 12 deletions(-) create mode 100644 packages/wasm-sdk/tests/unit/contest-fund-to-join.spec.ts diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index bdadaf359cb..ef5830eb823 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -249,7 +249,13 @@ try { import { Document, DocumentCreateTransition, BatchTransition } from '@dashevo/evo-sdk'; const document = new Document({ properties, documentTypeName, dataContractId, ownerId }); -const transition = new DocumentCreateTransition({ document, identityContractNonce: nonce }); +// undefined unless the document enters a contest (a DPNS name, a moderation charter) +const prefundedVotingBalance = await sdk.documents.contestFundToJoin(document); +const transition = new DocumentCreateTransition({ + document, + identityContractNonce: nonce, + prefundedVotingBalance, +}); const batch = BatchTransition.fromBatchedTransitions([transition.toDocumentTransition()], ownerId, 0); // userFeeIncrease const stateTransition = batch.toStateTransition(); // sign, then sdk.stateTransitions.broadcast(stateTransition) @@ -257,6 +263,10 @@ const stateTransition = batch.toStateTransition(); From protocol version 14 the id of a new document commits to the identity contract nonce of its create transition. `new DocumentCreateTransition(...)` derives that id from the document's entropy and `identityContractNonce`, puts it on the transition and writes it back onto `document`, so `document.id` is final once the transition exists and equals `transition.base.id`. Before that the `Document` carries a placeholder. To know the id earlier, `document.setIdForCreation(nonce)` or `Document.generateId(type, owner, contract, entropy, nonce)`, or pass `identityContractNonce` to the `Document` constructor. Pass `platformVersion` (defaults to latest) to any of them for a network on an earlier protocol version. No app needs to reimplement the hash. +A document whose values fall under a contested index enters a contest, and its create must state the most it pays into the contest's fund: without it, or stating less than the fund to join, Platform refuses the create with error 40114 and still charges its fees. From protocol version 14 that fund doubles once the contest holds 250 contenders and again for every 50 more. `sdk.documents.create` states it itself. For a transition built by hand, `sdk.documents.contestFundToJoin(document)` reads the contest's contenders (one proved query per 100) and returns the `PrefundedVotingBalance` to pass, the contested index's name and the fund to join now, or `undefined` for a document that joins no contest. Without the SDK, pass the document's contract as `dataContract` to `new DocumentCreateTransition(...)`: a contested document then states the contest's fund on its contested index, what joining costs below 250 contenders, and `contestFund` replaces that amount. + +From protocol version 14 a create may state more, as headroom for contenders joining before it lands: `new PrefundedVotingBalance({ indexName: prefundedVotingBalance.indexName, credits: 2n * prefundedVotingBalance.credits })`. Platform charges only the fund to join, but the identity must hold what the create states. Before 14 the stated amount must be exactly the contest's fund, and a create stating more is refused. + ## Encrypted properties (`encryptedFor`) From protocol version 14 a byte array property can declare how its ciphertext was produced, so a wallet reads the recipe from the contract instead of a side channel: the recipient (an identifier property of the same document type, or `$ownerId` for a message the writer encrypts to themself), the integer properties carrying the recipient's and the sender's key ids, and the scheme. The one scheme today, `ecdh-secp256k1-aes256-cbc`, is the dashpay contact request's: a random 16-byte IV followed by AES-256-CBC with PKCS7 padding under the libsecp256k1 ECDH shared key of the two identities' keys. A fetched contract can be asked what it declares: diff --git a/packages/js-evo-sdk/src/documents/facade.ts b/packages/js-evo-sdk/src/documents/facade.ts index f1ecb2039e5..2a34c3afa37 100644 --- a/packages/js-evo-sdk/src/documents/facade.ts +++ b/packages/js-evo-sdk/src/documents/facade.ts @@ -114,6 +114,23 @@ export class DocumentsFacade { return w.documentCreate(options); } + /** + * The prefunded voting balance a create of `document` states to join the + * contest it enters (a DPNS name, a moderation charter): the contested index + * and the fund to join it now, which from protocol version 14 doubles once + * the contest holds 250 contenders and again for every 50 more. Undefined + * when the document joins no contest. {@link create} states it itself; a + * transition built by hand passes it as `prefundedVotingBalance` to + * `new DocumentCreateTransition`. Its `credits` alone is what the Rust SDK's + * `contest_fund_to_join` returns; this is its `prefunded_voting_balance_to_join`. + */ + async contestFundToJoin( + document: wasm.Document, + ): Promise { + const w = await this.sdk.getWasmSdkConnected(); + return w.getContestFundToJoin(document); + } + async replace(options: wasm.DocumentReplaceOptions): Promise { const w = await this.sdk.getWasmSdkConnected(); return w.documentReplace(options); diff --git a/packages/js-evo-sdk/tests/unit/facades/documents.spec.ts b/packages/js-evo-sdk/tests/unit/facades/documents.spec.ts index f8ba44ca385..6dca0d6ebb8 100644 --- a/packages/js-evo-sdk/tests/unit/facades/documents.spec.ts +++ b/packages/js-evo-sdk/tests/unit/facades/documents.spec.ts @@ -24,6 +24,7 @@ describe('DocumentsFacade', () => { let getDocumentStub: SinonStub; let getDocumentWithProofInfoStub: SinonStub; let documentCreateStub: SinonStub; + let getContestFundToJoinStub: SinonStub; let documentReplaceStub: SinonStub; let documentDeleteStub: SinonStub; let documentTransferStub: SinonStub; @@ -90,6 +91,7 @@ describe('DocumentsFacade', () => { // Stub transition methods documentCreateStub = this.sinon.stub(wasmSdk, 'documentCreate').resolves(); + getContestFundToJoinStub = this.sinon.stub(wasmSdk, 'getContestFundToJoin').resolves(undefined); documentReplaceStub = this.sinon.stub(wasmSdk, 'documentReplace').resolves(); documentDeleteStub = this.sinon.stub(wasmSdk, 'documentDelete').resolves(); documentTransferStub = this.sinon.stub(wasmSdk, 'documentTransfer').resolves(); @@ -232,6 +234,28 @@ describe('DocumentsFacade', () => { }); }); + describe('contestFundToJoin()', () => { + it('should resolve to the prefunded voting balance the create states', async () => { + const prefundedVotingBalance = new wasmSDKPackage.PrefundedVotingBalance({ + indexName: 'parentNameAndLabel', + credits: BigInt(20000000000), + }); + getContestFundToJoinStub.resolves(prefundedVotingBalance); + + const result = await client.documents.contestFundToJoin(document); + + expect(getContestFundToJoinStub).to.be.calledOnceWithExactly(document); + expect(result).to.equal(prefundedVotingBalance); + }); + + it('should resolve to undefined for a document that joins no contest', async () => { + const result = await client.documents.contestFundToJoin(document); + + expect(getContestFundToJoinStub).to.be.calledOnceWithExactly(document); + expect(result).to.be.undefined(); + }); + }); + describe('replace()', () => { it('should replace an existing document', async () => { const options = { diff --git a/packages/rs-sdk/src/platform/documents/contest_fund.rs b/packages/rs-sdk/src/platform/documents/contest_fund.rs index fb42840ab0b..740f43218ff 100644 --- a/packages/rs-sdk/src/platform/documents/contest_fund.rs +++ b/packages/rs-sdk/src/platform/documents/contest_fund.rs @@ -35,6 +35,23 @@ impl Sdk { document_type: DocumentTypeRef<'_>, document: &Document, ) -> Result, Error> { + Ok(self + .prefunded_voting_balance_to_join(document_type, document) + .await? + .map(|(_, contest_fund)| contest_fund)) + } + + /// The prefunded voting balance a create of `document` states to join its contest now: + /// the name of the contested index the document falls under and [`Self::contest_fund_to_join`], + /// or `None` when the document joins no contest. It is what a create transition built by + /// hand carries, the pair [`DocumentCreateTransition`] keeps as `prefunded_voting_balance`. + /// + /// [`DocumentCreateTransition`]: dpp::state_transition::batch_transition::DocumentCreateTransition + pub async fn prefunded_voting_balance_to_join( + &self, + document_type: DocumentTypeRef<'_>, + document: &Document, + ) -> Result, Error> { // The contest is resolved on the document the transition builder sends, with every // `generatedFrom` property generated from its params as the platform generates it let mut document = document.clone(); @@ -75,10 +92,9 @@ impl Sdk { } } - Ok(Some(vote_poll.required_vote_resolution_fund_to_join( - contenders, - self.version(), - ))) + let contest_fund = + vote_poll.required_vote_resolution_fund_to_join(contenders, self.version()); + Ok(Some((vote_poll.index_name, contest_fund))) } } @@ -192,7 +208,7 @@ mod tests { } /// The fund to join a contest of 250 contenders is twice the contest's fund, read page by - /// page, and it is what a create naming no maximum states + /// page, and it is what a create naming no maximum states, on the contested index #[tokio::test] async fn should_state_the_fund_to_join_a_contest_read_page_by_page() { let mut sdk = SdkBuilder::new_mock() @@ -230,9 +246,16 @@ mod tests { options.and_then(|options| options.contest_fund), Some(2 * fund) ); + assert_eq!( + sdk.prefunded_voting_balance_to_join(document_type, &domain("quantum")) + .await + .expect("expected to read the prefunded voting balance to join"), + Some(("parentNameAndLabel".to_string(), 2 * fund)) + ); } - /// Nothing is read for a create joining no contest, or one naming the most it pays + /// Nothing is read for a create joining no contest (its type has no contested index, or its + /// values match none), or one naming the most it pays #[tokio::test] async fn should_read_nothing_when_the_create_joins_no_contest_or_names_its_maximum() { let sdk = SdkBuilder::new_mock().build().expect("expected a mock sdk"); @@ -255,6 +278,21 @@ mod tests { .expect("expected no read"), None ); + assert_eq!( + sdk.prefunded_voting_balance_to_join(preorder_type, &preorder) + .await + .expect("expected no read"), + None + ); + // A label of 20 characters or with digits other than 0 and 1 is not contested + for label in ["quantumexplorerdashx", "quantum2"] { + assert_eq!( + sdk.prefunded_voting_balance_to_join(domain_type, &domain(label)) + .await + .expect("expected no read"), + None + ); + } let naming_its_maximum = Some(StateTransitionCreationOptions { contest_fund: Some(5), diff --git a/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs b/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs index 8aa514d7dfb..d4a4c2c228a 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs @@ -1,4 +1,6 @@ use crate::data_contract::document::DocumentWasm; +use crate::data_contract::DataContractWasm; +use crate::error::WasmDppError; use crate::error::WasmDppResult; use crate::impl_wasm_type_info; use crate::serialization; @@ -9,11 +11,15 @@ use crate::state_transitions::batch::generators::generate_create_transition; use crate::state_transitions::batch::prefunded_voting_balance::PrefundedVotingBalanceWasm; use crate::state_transitions::batch::token_payment_info::TokenPaymentInfoWasm; use crate::utils::{ - try_from_options_mut, try_from_options_optional, try_from_options_with, try_to_u64, - try_vec_to_fixed_bytes, ToSerdeJSONExt, + try_from_options_mut, try_from_options_optional, try_from_options_optional_with, + try_from_options_with, try_to_u64, try_vec_to_fixed_bytes, ToSerdeJSONExt, }; use crate::version::PlatformVersionWasm; -use dpp::prelude::IdentityNonce; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::methods::{DocumentTypeBasicMethods, DocumentTypeV0Methods}; +use dpp::document::DocumentV0Getters; +use dpp::fee::Credits; +use dpp::prelude::{Identifier, IdentityNonce}; use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; use dpp::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; @@ -34,7 +40,30 @@ export interface DocumentCreateTransitionOptions { */ document: Document; identityContractNonce: bigint; + /** + * The contested index the document falls under and what it pays into that + * contest. Required when the document enters a contest, or the create is + * refused and still pays its fees. From protocol version 14 the amount is + * the most it pays: it is charged the fund to join and refused when that is + * more. Before 14 it must be exactly the contest's fund. + * `sdk.documents.contestFundToJoin(document)` in the SDK returns the one to + * state now. + */ prefundedVotingBalance?: PrefundedVotingBalance; + /** + * The contract of `document`. When given and `prefundedVotingBalance` is + * not, a document that enters a contest states the contest's fund on its + * contested index, as the Rust builder does: what joining costs while the + * contest holds fewer than 250 contenders. + */ + dataContract?: DataContract; + /** + * The most, in credits, a contested create pays into its contest (protocol + * version 14), stated in place of the amount of its prefunded voting + * balance. Needs `dataContract` or `prefundedVotingBalance` to name the + * contested index. A document that joins no contest ignores it. + */ + contestFund?: bigint; tokenPaymentInfo?: TokenPaymentInfo; actionFeeAgreement?: DocumentActionFeeAgreement; /** Platform version the id is derived for (default: latest) */ @@ -92,6 +121,20 @@ impl DocumentCreateTransitionWasm { let action_fee_agreement: Option = try_from_options_optional(&options, "actionFeeAgreement")?; + let data_contract: Option = + try_from_options_optional(&options, "dataContract")?; + + let contest_fund: Option = + try_from_options_optional_with(&options, "contestFund", |v| { + try_to_u64(v, "contestFund") + })?; + + if contest_fund.is_some() && prefunded_voting_balance.is_none() && data_contract.is_none() { + return Err(WasmDppError::invalid_argument( + "contestFund needs dataContract or prefundedVotingBalance to name the contested index", + )); + } + // The id a document carries before its nonce is known is a // placeholder: derive the one consensus will recompute, on the // caller's own object so `document.id` matches `transition.base.id`, @@ -103,13 +146,28 @@ impl DocumentCreateTransitionWasm { // as an unrecoverable runtime error, not as a result. From here on // nothing calls back into JavaScript. let mut document = try_from_options_mut::(&options, "document", "Document")?; + + // Resolved before the id is rewritten, so a refused build leaves the document as it was + let prefunded_voting_balance = match (prefunded_voting_balance, &data_contract) { + (Some(prefunded_voting_balance), _) => Some(prefunded_voting_balance.into()), + (None, Some(data_contract)) => { + contest_fund_of_contract(&document, data_contract, &platform_version)? + } + (None, None) => None, + }; + // Like `StateTransitionCreationOptions::apply_contest_fund` in dpp + let prefunded_voting_balance = match (prefunded_voting_balance, contest_fund) { + (Some((index_name, _)), Some(contest_fund)) => Some((index_name, contest_fund)), + (prefunded_voting_balance, _) => prefunded_voting_balance, + }; + document.set_id_for_creation(identity_contract_nonce, &platform_version)?; let rs_create_transition = generate_create_transition( &document, identity_contract_nonce, document.document_type_name().to_string(), - prefunded_voting_balance, + prefunded_voting_balance.map(PrefundedVotingBalanceWasm::from), token_payment_info, action_fee_agreement, ); @@ -195,4 +253,31 @@ impl DocumentCreateTransitionWasm { } } +/// The prefunded voting balance a create of `document` states when it enters a contest of +/// `data_contract`: its contested index and the contest's fund, as +/// `DocumentCreateTransitionV0::from_document` in dpp states it, or `None` when it joins none. +/// The contest is resolved with every `generatedFrom` property generated as the platform +/// generates it; the transition keeps the document's own values. +fn contest_fund_of_contract( + document: &DocumentWasm, + data_contract: &DataContractWasm, + platform_version: &PlatformVersion, +) -> WasmDppResult> { + let data_contract = data_contract.as_ref(); + let document_contract_id: Identifier = document.data_contract_id.into(); + if data_contract.id() != document_contract_id { + return Err(WasmDppError::invalid_argument(format!( + "dataContract {} is not the contract of the document, {}", + data_contract.id(), + document_contract_id + ))); + } + let document_type = data_contract + .document_type_for_name(&document.document_type_name) + .map_err(|error| WasmDppError::invalid_argument(error.to_string()))?; + let mut resolved = document.document.clone(); + document_type.regenerate_generated_properties(resolved.properties_mut(), platform_version)?; + Ok(document_type.prefunded_voting_balance_for_document(&resolved, platform_version)?) +} + impl_wasm_type_info!(DocumentCreateTransitionWasm, DocumentCreateTransition); diff --git a/packages/wasm-dpp2/tests/unit/DocumentsTransitions.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentsTransitions.spec.ts index 807b3f4663e..a99eeb1d150 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentsTransitions.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentsTransitions.spec.ts @@ -333,6 +333,100 @@ describe('DocumentsTransitions', () => { }); }); + describe('contest fund from dataContract', () => { + // A DPNS-like name contested while its label is 3 to 19 characters + const contract = () => new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: { + domain: { + type: 'object', + documentsMutable: false, + canBeDeleted: true, + properties: { + normalizedLabel: { type: 'string', maxLength: 63, position: 0 }, + normalizedParentDomainName: { type: 'string', maxLength: 63, position: 1 }, + }, + required: ['normalizedLabel', 'normalizedParentDomainName'], + indices: [{ + name: 'parentNameAndLabel', + properties: [{ normalizedParentDomainName: 'asc' }, { normalizedLabel: 'asc' }], + unique: true, + contested: { + fieldMatches: [{ field: 'normalizedLabel', regexPattern: '^[a-zA-Z01-]{3,19}$' }], + resolution: 0, + }, + }], + additionalProperties: false, + }, + }, + definitions: null, + fullValidation: false, + platformVersion: new wasm.PlatformVersion(14), + }); + const name = (dataContract: InstanceType, label: string) => new wasm.Document({ + properties: { normalizedLabel: label, normalizedParentDomainName: 'dash' }, + documentTypeName: 'domain', + dataContractId: dataContract.id, + ownerId, + }); + // 0.1 Dash, the contested document fund of protocol version 14 + const contestedDocumentFund = BigInt(10000000000); + + it('should state the contest fund on the contested index of a contested document', () => { + const dataContract = contract(); + const createTransition = new wasm.DocumentCreateTransition({ + document: name(dataContract, 'quantum'), + identityContractNonce: BigInt(1), + dataContract, + }); + + expect(createTransition.prefundedVotingBalance.indexName).to.equal('parentNameAndLabel'); + expect(createTransition.prefundedVotingBalance.credits).to.equal(contestedDocumentFund); + }); + + it('should state contestFund in place of the contest fund', () => { + const dataContract = contract(); + const createTransition = new wasm.DocumentCreateTransition({ + document: name(dataContract, 'quantum'), + identityContractNonce: BigInt(1), + dataContract, + contestFund: BigInt(7), + }); + + expect(createTransition.prefundedVotingBalance.indexName).to.equal('parentNameAndLabel'); + expect(createTransition.prefundedVotingBalance.credits).to.equal(BigInt(7)); + }); + + it('should state no fund for a document that matches no contested index', () => { + const dataContract = contract(); + const createTransition = new wasm.DocumentCreateTransition({ + document: name(dataContract, 'quantumexplorerdashx'), + identityContractNonce: BigInt(1), + dataContract, + contestFund: BigInt(7), + }); + + expect(createTransition.prefundedVotingBalance).to.equal(undefined); + }); + + it('should refuse contestFund without dataContract or prefundedVotingBalance', () => { + expect(() => new wasm.DocumentCreateTransition({ + document: name(contract(), 'quantum'), + identityContractNonce: BigInt(1), + contestFund: BigInt(7), + })).to.throw(/contestFund needs dataContract/); + }); + + it('should refuse a dataContract that is not the contract of the document', () => { + expect(() => new wasm.DocumentCreateTransition({ + document: createDocument(), + identityContractNonce: BigInt(1), + dataContract: contract(), + })).to.throw(/is not the contract of the document/); + }); + }); + describe('BatchTransition.fromBatchedTransitions()', () => { it('should create BatchTransition from document transitions', () => { const documentInstance = createDocument(); diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 052ba79d5b3..8d7fded37fd 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -24,6 +24,7 @@ use wasm_bindgen::{prelude::*, JsCast}; use wasm_dpp2::data_contract::document::DocumentWasm; use wasm_dpp2::identifier::IdentifierWasm; use wasm_dpp2::identity::IdentityPublicKeyWasm; +use wasm_dpp2::state_transitions::batch::prefunded_voting_balance::PrefundedVotingBalanceWasm; use wasm_dpp2::state_transitions::batch::token_payment_info::{ TokenPaymentInfoOptionsJs, TokenPaymentInfoWasm, }; @@ -272,6 +273,66 @@ impl WasmSdk { } } +// ============================================================================ +// Contest Fund To Join +// ============================================================================ + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "Document")] + pub type DocumentJs; +} + +#[wasm_bindgen] +impl WasmSdk { + /// The prefunded voting balance a create of `document` states to join the + /// contest it enters: the contested index the document falls under and the + /// fund to join that contest now, or `undefined` when the document joins no + /// contest (its type has no contested index, or its values do not match one). + /// + /// `documentCreate` states this itself. A create transition built by hand + /// passes it as `prefundedVotingBalance` to `new DocumentCreateTransition`: + /// from protocol version 14 a contested create that states less than the + /// fund to join is refused and still pays its fees. The fund doubles once the + /// contest holds 250 contenders and again for every 50 more, so this reads + /// the contenders with proved queries, one per 100 of them. From protocol + /// version 14 a create may state more than this, as headroom against + /// contenders joining before it lands: Platform charges it only the fund to + /// join, but the identity must hold what it states. Before 14 it must state + /// exactly this. + /// + /// @param document - The document to create; its contract is fetched (or + /// read from the cache) to find the contested index + /// @returns The index name and credits to state, or undefined + #[wasm_bindgen(js_name = "getContestFundToJoin")] + pub async fn get_contest_fund_to_join( + &self, + document: DocumentJs, + ) -> Result, WasmSdkError> { + // Cloned out of the caller's `Document` before the first await, so the + // object is not borrowed while the contest is read + let document = DocumentWasm::try_from(&JsValue::from(document))?; + let contract_id: Identifier = document.data_contract_id().into(); + let data_contract = self.get_or_fetch_contract(contract_id).await?; + let document_type_name = document.document_type_name(); + let document_type = data_contract + .document_type_for_name(&document_type_name) + .map_err(|e| { + WasmSdkError::not_found(format!( + "Document type '{}' not found: {}", + document_type_name, e + )) + })?; + let document: Document = document.into(); + + let prefunded_voting_balance = self + .inner_sdk() + .prefunded_voting_balance_to_join(document_type, &document) + .await?; + Ok(prefunded_voting_balance.map(PrefundedVotingBalanceWasm::from)) + } +} + // ============================================================================ // Document Replace // ============================================================================ diff --git a/packages/wasm-sdk/tests/functional/voting.spec.ts b/packages/wasm-sdk/tests/functional/voting.spec.ts index 7eaae367638..f056c9b67da 100644 --- a/packages/wasm-sdk/tests/functional/voting.spec.ts +++ b/packages/wasm-sdk/tests/functional/voting.spec.ts @@ -7,7 +7,7 @@ describe('Voting', function describeVoting() { let client: sdk.WasmSdk; let builder: sdk.WasmSdkBuilder; - const { dpnsContractId, dpnsDomain } = wasmFunctionalTestRequirements(); + const { dpnsContractId, dpnsDomain, identityId } = wasmFunctionalTestRequirements(); before(async () => { await init(); @@ -53,6 +53,47 @@ describe('Voting', function describeVoting() { }); }); + describe('getContestFundToJoin()', () => { + function domain(label: string, normalizedLabel: string): sdk.Document { + return new sdk.Document({ + properties: { + label, + normalizedLabel, + parentDomainName: 'dash', + normalizedParentDomainName: 'dash', + preorderSalt: new Uint8Array(32), + records: { identity: new Uint8Array(32) }, + subdomainRules: { allowSubdomains: false }, + }, + documentTypeName: 'domain', + dataContractId: dpnsContractId, + ownerId: identityId, + }); + } + + it('should state the contest fund on the contested index for a contested name', async () => { + const balance = await client.getContestFundToJoin(domain('quantum', 'quantum')); + + expect(balance).to.be.an.instanceOf(sdk.PrefundedVotingBalance); + expect(balance?.indexName).to.equal('parentNameAndLabel'); + // The DPNS contest fund at the network's version, until a contest holds 250 contenders + const contract = await client.getDataContract(dpnsContractId); + if (!contract) { + throw new Error('expected the DPNS contract'); + } + const [contestFund] = sdk.documentCreateCost(contract, 'domain', undefined, client.version()) + .contractCharges.flatMap((charge) => (charge.kind === 'contestFund' ? [charge.credits] : [])); + expect(balance?.credits).to.equal(BigInt(contestFund)); + }); + + it('should resolve to undefined for a name that is not contested', async () => { + const label = 'contest-fund-test-name-2'; + const balance = await client.getContestFundToJoin(domain(label, label)); + + expect(balance).to.be.undefined(); + }); + }); + describe('getVotePollsByEndDate()', () => { const DAY_MS = 24 * 60 * 60 * 1000; diff --git a/packages/wasm-sdk/tests/unit/contest-fund-to-join.spec.ts b/packages/wasm-sdk/tests/unit/contest-fund-to-join.spec.ts new file mode 100644 index 00000000000..4107804f6b8 --- /dev/null +++ b/packages/wasm-sdk/tests/unit/contest-fund-to-join.spec.ts @@ -0,0 +1,23 @@ +/** + * `getContestFundToJoin` reads the contest a document enters from the network, so the unit + * suite checks only what it does before any read: the document is taken apart first. + */ +import { expect } from './helpers/chai.ts'; +import init, * as sdk from '../../dist/sdk.compressed.js'; + +describe('getContestFundToJoin()', () => { + let client: sdk.WasmSdk; + + before(async () => { + await init(); + client = await sdk.WasmSdkBuilder.testnet().build(); + }); + + after(() => { + client?.free(); + }); + + it('should refuse a value that is not a Document before reading anything', async () => { + await expect(client.getContestFundToJoin({} as never)).to.be.rejectedWith(/Document/); + }); +});