diff --git a/packages/rs-dpp/src/group/group_action/mod.rs b/packages/rs-dpp/src/group/group_action/mod.rs index 4dbf87eefc6..86c73f52cee 100644 --- a/packages/rs-dpp/src/group/group_action/mod.rs +++ b/packages/rs-dpp/src/group/group_action/mod.rs @@ -39,13 +39,36 @@ use serde::{Deserialize, Serialize}; serde(tag = "$formatVersion") )] #[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] -// Stored group actions are decoded from GroveDB proof elements on the client -// before the quorum signature is checked, so the byte budget must be enforced -// by the decoder itself. Every payload (notes, config change, pricing -// schedule) is copied out of the state transition that proposed the action, -// and `StateTransition` is capped at the same 100,000 bytes, so no valid -// stored action can exceed this. -#[platform_serialize(limit = 100000, unversioned)] //versioned directly, no need to use platform_version +// Group actions reach two decoders. Drive reads the actions it stored itself +// with `deserialize_from_bytes_trusted_no_limit`, as v4.1 did: every stored +// action was valid when it was written, so a budget there could only refuse +// one of them. A client decodes them from GroveDB proof elements before the +// quorum signature is checked, and that untrusted decode runs under this +// budget. +// +// bincode's limit counts memory claimed, not bytes read. A map, a set or a +// vector of anything but bytes claims `len * size_of::()` from its length +// prefix before reading an element; a string or a byte vector claims its +// length. Of the events a group can store, the conventions localizations map +// claims the most per encoded byte: 80 bytes per entry +// (`size_of::<(String, TokenConfigurationLocalization)>()` on a 64-bit target) +// against at least 13 encoded (a two-letter language code, two three-letter +// forms, their three length prefixes, the variant tag and the capitalization +// flag). The `SetPrices` and `Stepwise` maps claim 16 bytes per entry against +// at least 2 encoded for their first 251 keys and 4 after; strings, notes and +// identifiers claim what they encode. A stored action copies its containers +// from the transition that proposed it and is smaller than that transition +// (which also carries its signature and the token id), and a transition is at +// most `max_state_transition_size` bytes (20,480 at every protocol version), +// so no stored action claims more than 20,480 / 13 * 80, about 126,000 bytes. +// 262,144 (256 KiB) leaves more than twice that. +// +// The derive takes the budget as a literal, so it cannot live in +// `SystemLimits`. No consensus path reads it. It has to grow if +// `max_state_transition_size` ever does, and +// `should_decode_the_largest_storable_action_of_each_container_shape` fails +// until it does. +#[platform_serialize(limit = 262144, unversioned)] //versioned directly, no need to use platform_version pub enum GroupAction { #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] V0(GroupActionV0), @@ -91,7 +114,210 @@ impl GroupActionAccessors for GroupAction { #[cfg(test)] mod deserialize_limit_tests { use super::*; - use crate::serialization::PlatformDeserializableUntrusted; + use crate::data_contract::associated_token::token_configuration_convention::TokenConfigurationConvention; + use crate::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; + use crate::data_contract::associated_token::token_perpetual_distribution::distribution_function::DistributionFunction; + use crate::data_contract::associated_token::token_perpetual_distribution::distribution_recipient::TokenDistributionRecipient; + use crate::data_contract::associated_token::token_perpetual_distribution::reward_distribution_type::RewardDistributionType; + use crate::data_contract::associated_token::token_perpetual_distribution::v0::TokenPerpetualDistributionV0; + use crate::data_contract::associated_token::token_perpetual_distribution::TokenPerpetualDistribution; + use crate::serialization::{ + PlatformDeserializableTrusted, PlatformDeserializableUntrusted, PlatformSerializable, + }; + use crate::tests::fixtures::get_token_conventions_with_localizations_fixture; + use crate::tokens::token_event::TokenEvent; + use crate::tokens::token_pricing_schedule::TokenPricingSchedule; + use platform_version::version::{PlatformVersion, PLATFORM_VERSIONS}; + use std::collections::BTreeMap; + + /// The budget `GroupAction` decoded under before it was sized from + /// memory claims. + const PREVIOUS_BUDGET: usize = 100_000; + + /// Builds an action holding the given number of entries in one container. + type ActionBuilder = fn(usize) -> GroupAction; + + fn action_with_event(event: TokenEvent) -> GroupAction { + GroupAction::V0(GroupActionV0 { + contract_id: Identifier::new([1; 32]), + proposer_id: Identifier::new([2; 32]), + token_contract_position: 0, + event: GroupActionEvent::TokenEvent(event), + }) + } + + fn conventions_change(conventions: TokenConfigurationConvention) -> GroupAction { + action_with_event(TokenEvent::ConfigUpdate( + TokenConfigurationChangeItem::Conventions(conventions), + None, + )) + } + + fn localizations_action(entries: usize) -> GroupAction { + conventions_change(get_token_conventions_with_localizations_fixture(entries)) + } + + /// `entries` keys counted up from 0, each mapping to 0: the shortest + /// encoding a `u64` to `u64` map with that many entries can have. + fn shortest_u64_map(entries: usize) -> BTreeMap { + (0..entries as u64).map(|key| (key, 0)).collect() + } + + fn set_prices_action(entries: usize) -> GroupAction { + action_with_event(TokenEvent::ChangePriceForDirectPurchase( + Some(TokenPricingSchedule::SetPrices(shortest_u64_map(entries))), + None, + )) + } + + fn stepwise_action(entries: usize) -> GroupAction { + action_with_event(TokenEvent::ConfigUpdate( + TokenConfigurationChangeItem::PerpetualDistribution(Some( + TokenPerpetualDistribution::V0(TokenPerpetualDistributionV0 { + distribution_type: RewardDistributionType::BlockBasedDistribution { + interval: 1, + function: DistributionFunction::Stepwise(shortest_u64_map(entries)), + }, + distribution_recipient: TokenDistributionRecipient::ContractOwner, + }), + )), + None, + )) + } + + fn note_action(length: usize) -> GroupAction { + action_with_event(TokenEvent::Mint( + 1, + Identifier::new([3; 32]), + Some("a".repeat(length)), + )) + } + + fn encode(action: &GroupAction) -> Vec { + action + .serialize_to_bytes() + .expect("expected to encode the action") + } + + /// The action `build` makes with the most entries whose encoding still + /// fits in `max_bytes`. + fn largest_fitting(build: ActionBuilder, max_bytes: usize) -> GroupAction { + // Every entry takes at least one byte, so `max_bytes` entries never fit. + let (mut fits, mut too_many) = (0, max_bytes); + while too_many - fits > 1 { + let entries = (fits + too_many) / 2; + if encode(&build(entries)).len() <= max_bytes { + fits = entries; + } else { + too_many = entries; + } + } + build(fits) + } + + /// Whether `bytes` decode untrusted under the budget `GroupAction` had + /// before. + fn decodes_under_previous_budget(bytes: &[u8]) -> bool { + let config = bincode::config::standard() + .with_big_endian() + .with_limit::(); + bincode::decode_from_slice_untrusted::(bytes, config).is_ok() + } + + /// `bytes` decode back to `action` untrusted under the budget, as a client + /// reads a proof, and trusted with and without it. + fn assert_decodes_with_every_decoder(bytes: &[u8], action: &GroupAction, shape: &str) { + let untrusted = GroupAction::deserialize_from_bytes_untrusted(bytes) + .unwrap_or_else(|e| panic!("{shape}: untrusted decode failed: {e}")); + assert_eq!(&untrusted, action, "{shape}: untrusted decode"); + let trusted = GroupAction::deserialize_from_bytes_trusted(bytes) + .unwrap_or_else(|e| panic!("{shape}: trusted decode failed: {e}")); + assert_eq!(&trusted, action, "{shape}: trusted decode"); + let trusted_no_limit = GroupAction::deserialize_from_bytes_trusted_no_limit(bytes) + .unwrap_or_else(|e| panic!("{shape}: trusted decode without a limit failed: {e}")); + assert_eq!( + &trusted_no_limit, action, + "{shape}: trusted decode without a limit" + ); + } + + /// A conventions change with 1,250 valid localizations encodes in 16,325 + /// bytes, but its map claims 1,250 * 80 = 100,000 bytes at its length + /// prefix. The previous budget refused it, on Drive's reads as well as on + /// proofs. + #[test] + fn should_decode_a_conventions_change_with_1250_localizations() { + let conventions = get_token_conventions_with_localizations_fixture(1_250); + assert!(conventions + .validate_localizations(PlatformVersion::latest()) + .expect("expected to validate the localizations") + .is_valid()); + let action = conventions_change(conventions); + let bytes = encode(&action); + assert_eq!(bytes.len(), 16_325); + + assert!(!decodes_under_previous_budget(&bytes)); + assert_decodes_with_every_decoder(&bytes, &action, "1,250 localizations"); + } + + /// A stored action copies its containers from the transition that + /// proposed it and is smaller than that transition, which also carries its + /// signature and the token id, so no stored action encodes in more than + /// `max_state_transition_size` bytes. + /// For each container a group action can hold, the one packing the most + /// entries into that size decodes with every decoder. Only the + /// localizations map claimed more than the previous budget. + #[test] + fn should_decode_the_largest_storable_action_of_each_container_shape() { + let max_bytes = PLATFORM_VERSIONS + .iter() + .map(|platform_version| platform_version.system_limits.max_state_transition_size) + .max() + .expect("expected platform versions") as usize; + let shapes: [(&str, ActionBuilder, bool); 4] = [ + ("localizations", localizations_action, false), + ("SetPrices", set_prices_action, true), + ("Stepwise", stepwise_action, true), + ("note", note_action, true), + ]; + for (shape, build, decoded_under_previous_budget) in shapes { + let action = largest_fitting(build, max_bytes); + let bytes = encode(&action); + assert!( + bytes.len() > max_bytes - 16, + "{shape}: {} bytes", + bytes.len() + ); + assert_eq!( + decodes_under_previous_budget(&bytes), + decoded_under_previous_budget, + "{shape}: previous budget" + ); + assert_decodes_with_every_decoder(&bytes, &action, shape); + } + } + + /// A proof element is untrusted input: a localizations length prefix + /// claiming a million entries is refused against the budget before any + /// entry is read. + #[test] + fn should_refuse_a_localizations_length_prefix_beyond_the_budget() { + // An empty map ends the encoding with its length (0), the decimals + // and the absent note; keep everything before them. + let empty = encode(&localizations_action(0)); + let mut buf = empty[..empty.len() - 3].to_vec(); + buf.extend_from_slice( + &bincode::encode_to_vec(1_000_000u64, bincode::config::standard().with_big_endian()) + .unwrap(), + ); + + let err = GroupAction::deserialize_from_bytes_untrusted(&buf) + .expect_err("oversized length prefix must be rejected"); + assert!( + matches!(err, ProtocolError::MaxEncodedBytesReachedError { .. }), + "unexpected error: {err}" + ); + } /// A proof element is untrusted input: a note length prefix must be /// rejected against the byte budget before it sizes an allocation. diff --git a/packages/rs-dpp/src/tests/fixtures/get_token_conventions_fixture.rs b/packages/rs-dpp/src/tests/fixtures/get_token_conventions_fixture.rs new file mode 100644 index 00000000000..3e60cb57675 --- /dev/null +++ b/packages/rs-dpp/src/tests/fixtures/get_token_conventions_fixture.rs @@ -0,0 +1,41 @@ +use crate::data_contract::associated_token::token_configuration_convention::v0::TokenConfigurationConventionV0; +use crate::data_contract::associated_token::token_configuration_convention::TokenConfigurationConvention; +use crate::data_contract::associated_token::token_configuration_localization::v0::TokenConfigurationLocalizationV0; + +/// Token conventions with `entries` localizations: "en", then distinct +/// language codes, two letters while they last (2,704 of them) and three +/// after. Every form is "abc", the shortest `validate_localizations` accepts, +/// so each entry encodes in the fewest bytes a valid localization can: 13 for +/// a two-letter code. +pub fn get_token_conventions_with_localizations_fixture( + entries: usize, +) -> TokenConfigurationConvention { + let letters = || ('a'..='z').chain('A'..='Z'); + let two_letter_codes = letters().flat_map(move |a| letters().map(move |b| format!("{a}{b}"))); + let three_letter_codes = letters().flat_map(move |a| { + letters().flat_map(move |b| letters().map(move |c| format!("{a}{b}{c}"))) + }); + let localizations = std::iter::once("en".to_string()) + .chain( + two_letter_codes + .chain(three_letter_codes) + .filter(|code| code != "en"), + ) + .take(entries) + .map(|code| { + ( + code, + TokenConfigurationLocalizationV0 { + should_capitalize: false, + singular_form: "abc".to_string(), + plural_form: "abc".to_string(), + } + .into(), + ) + }) + .collect(); + TokenConfigurationConvention::V0(TokenConfigurationConventionV0 { + localizations, + decimals: 8, + }) +} diff --git a/packages/rs-dpp/src/tests/fixtures/mod.rs b/packages/rs-dpp/src/tests/fixtures/mod.rs index 60b5cd4dfe2..7efb84a8c6d 100644 --- a/packages/rs-dpp/src/tests/fixtures/mod.rs +++ b/packages/rs-dpp/src/tests/fixtures/mod.rs @@ -28,6 +28,7 @@ mod get_dashpay_document_fixture; mod get_document_transitions_fixture; pub use get_masternode_reward_shares_documents_fixture::*; +pub use get_token_conventions_fixture::*; mod get_dashpay_contract_fixture; mod get_dashpay_contract_with_generalized_encryption_key_fixture; @@ -39,6 +40,7 @@ mod get_dpp; #[cfg(feature = "state-transitions")] mod get_identity_update_transition_fixture; mod get_masternode_reward_shares_documents_fixture; +mod get_token_conventions_fixture; #[cfg(feature = "state-transitions")] mod identity_credit_withdrawal_transition_fixture; mod identity_fixture; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/config_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/config_update/mod.rs index 7c5da1e06fd..ec661e53c2f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/config_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/config_update/mod.rs @@ -1585,6 +1585,11 @@ mod token_config_update_tests { use super::*; use dpp::data_contract::associated_token::token_configuration_localization::v0::TokenConfigurationLocalizationV0; use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; + use dpp::group::action_event::GroupActionEvent; + use dpp::group::group_action::v0::GroupActionV0; + use dpp::group::group_action::GroupAction; + use dpp::tests::fixtures::get_token_conventions_with_localizations_fixture; + use dpp::tokens::token_event::TokenEvent; #[tokio::test] async fn test_token_config_update_by_group_member_changing_total_max_supply_not_using_group_gives_error( @@ -3868,6 +3873,215 @@ mod token_config_update_tests { .expect("expected to commit transaction"); } + /// A conventions change with 1,250 valid localizations encodes in + /// about 16 KB, but its localizations map claims 100,000 bytes at its + /// length prefix. Drive used to read the stored action under the + /// budget proofs decode under, so the second member's signature ended + /// in an internal error and the change could never be applied. + #[tokio::test] + async fn should_let_a_group_co_sign_a_conventions_change_with_1250_localizations() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(49853); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (identity_2, signer_2, key_2) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + identity.id(), + Some(|token_configuration: &mut TokenConfiguration| { + token_configuration.set_conventions_change_rules(ChangeControlRules::V0( + ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::Group(0), + admin_action_takers: AuthorizedActionTakers::NoOne, + changing_authorized_action_takers_to_no_one_allowed: false, + changing_admin_action_takers_to_no_one_allowed: false, + self_changing_admin_action_takers_allowed: false, + }, + )); + }), + None, + Some( + [( + 0, + Group::V0(GroupV0 { + members: [(identity.id(), 1), (identity_2.id(), 1)].into(), + required_power: 2, + }), + )] + .into(), + ), + None, + platform_version, + ); + + let conventions = get_token_conventions_with_localizations_fixture(1_250); + let change = TokenConfigurationChangeItem::Conventions(conventions.clone()); + + let action_id = TokenConfigUpdateTransition::calculate_action_id_with_fields( + token_id.as_bytes(), + identity.id().as_bytes(), + 2, + &change, + platform_version, + ) + .expect("expected to calculate action id"); + + let proposal = BatchTransition::new_token_config_update_transition( + token_id, + identity.id(), + contract.id(), + 0, + change.clone(), + None, + Some(GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(0)), + &key, + 2, + 0, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition") + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[proposal], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // The proposal stored the action as it was proposed. + let stored = platform + .drive + .fetch_action_id_info_keep_serialized( + contract.id(), + 0, + action_id, + None, + platform_version, + ) + .expect("expected the proposed action to be stored"); + let proposed = GroupAction::V0(GroupActionV0 { + contract_id: contract.id(), + proposer_id: identity.id(), + token_contract_position: 0, + event: GroupActionEvent::TokenEvent(TokenEvent::ConfigUpdate(change.clone(), None)), + }); + assert_eq!( + stored, + proposed + .serialize_to_bytes() + .expect("expected to encode the action") + ); + + let co_signature = BatchTransition::new_token_config_update_transition( + token_id, + identity_2.id(), + contract.id(), + 0, + change, + None, + Some( + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: 0, + action_id, + action_is_proposer: false, + }, + ), + ), + &key_2, + 2, + 0, + &signer_2, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition") + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[co_signature], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let new_contract = platform + .drive + .fetch_contract( + contract.id().to_buffer(), + None, + None, + None, + platform_version, + ) + .unwrap() + .expect("expected to fetch contract") + .expect("expected contract"); + let updated_token_config = new_contract + .contract + .expected_token_configuration(0) + .expect("expected token configuration"); + assert_eq!(updated_token_config.conventions(), &conventions); + } + #[tokio::test] async fn test_token_config_update_as_group_member_but_group_not_needed() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive/src/drive/group/fetch/fetch_action_infos/v0/mod.rs b/packages/rs-drive/src/drive/group/fetch/fetch_action_infos/v0/mod.rs index 3fc67557a12..38b54f12f63 100644 --- a/packages/rs-drive/src/drive/group/fetch/fetch_action_infos/v0/mod.rs +++ b/packages/rs-drive/src/drive/group/fetch/fetch_action_infos/v0/mod.rs @@ -76,9 +76,13 @@ impl Drive { let action_id = Identifier::from_bytes(last_path_component)?; match element { + // Drive wrote these actions itself, so they are read without + // the budget `GroupAction` sets for proofs, as every v4.1 + // binary reads them. That budget counts memory claimed from + // length prefixes, not bytes, and refused valid stored actions. Item(value, ..) => Ok(( action_id, - GroupAction::deserialize_from_bytes_trusted(&value)?, + GroupAction::deserialize_from_bytes_trusted_no_limit(&value)?, )), _ => Err(Error::Drive(DriveError::CorruptedDriveState( "element should be an item representing the group action".to_string(), @@ -96,6 +100,7 @@ mod tests { use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; use dpp::data_contract::config::v0::DataContractConfigV0; use dpp::data_contract::config::DataContractConfig; use dpp::data_contract::group::v0::GroupV0; @@ -109,11 +114,30 @@ mod tests { use dpp::identifier::Identifier; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; + use dpp::tests::fixtures::get_token_conventions_with_localizations_fixture; use dpp::tokens::token_event::TokenEvent; use dpp::version::PlatformVersion; use std::collections::BTreeMap; fn setup_with_one_active_action() -> (crate::drive::Drive, Identifier, Identifier, Identifier) { + let (drive, contract_id, action_id, proposer_id, _) = + setup_with_one_active_action_event(|proposer_id| { + TokenEvent::Mint(100, proposer_id, None) + }); + (drive, contract_id, action_id, proposer_id) + } + + /// A drive holding one active action whose event `event` builds from the + /// proposer's id. + fn setup_with_one_active_action_event( + event: impl FnOnce(Identifier) -> TokenEvent, + ) -> ( + crate::drive::Drive, + Identifier, + Identifier, + Identifier, + GroupAction, + ) { let drive = setup_drive_with_initial_state_structure(None); let platform_version = PlatformVersion::latest(); @@ -175,14 +199,14 @@ mod tests { contract_id, proposer_id: id_1, token_contract_position: 0, - event: GroupActionEvent::TokenEvent(TokenEvent::Mint(100, id_1, None)), + event: GroupActionEvent::TokenEvent(event(id_1)), }); drive .add_group_action( contract_id, 0, - Some(action), + Some(action.clone()), false, action_id, id_1, @@ -194,7 +218,7 @@ mod tests { ) .expect("add group action"); - (drive, contract_id, action_id, id_1) + (drive, contract_id, action_id, id_1, action) } #[test] @@ -283,4 +307,35 @@ mod tests { assert_eq!(infos.len(), 1); assert!(!ops.is_empty(), "path query should record operations"); } + + /// The server's list query reads the actions Drive stored without the + /// budget proofs decode under. A conventions change with 1,250 valid + /// localizations claims 100,000 bytes for its map at the length prefix; + /// that budget refused it, and with it every page that included it. + #[test] + fn should_list_a_stored_conventions_change_with_1250_localizations() { + let (drive, contract_id, action_id, _, action) = setup_with_one_active_action_event(|_| { + TokenEvent::ConfigUpdate( + TokenConfigurationChangeItem::Conventions( + get_token_conventions_with_localizations_fixture(1_250), + ), + None, + ) + }); + let platform_version = PlatformVersion::latest(); + + let infos = drive + .fetch_action_infos( + contract_id, + 0, + GroupActionStatus::ActionActive, + None, + Some(10), + None, + platform_version, + ) + .expect("expected to list the stored actions"); + + assert_eq!(infos, BTreeMap::from([(action_id, action)])); + } } diff --git a/packages/rs-drive/src/drive/group/fetch/fetch_active_action_info/v0/mod.rs b/packages/rs-drive/src/drive/group/fetch/fetch_active_action_info/v0/mod.rs index 331ace6e925..08bdbf51507 100644 --- a/packages/rs-drive/src/drive/group/fetch/fetch_active_action_info/v0/mod.rs +++ b/packages/rs-drive/src/drive/group/fetch/fetch_active_action_info/v0/mod.rs @@ -37,7 +37,12 @@ impl Drive { &platform_version.drive, )?; - let group_action = GroupAction::deserialize_from_bytes_trusted(&value)?; + // Drive wrote this action itself, so it is read without the budget + // `GroupAction` sets for proofs. That budget counts memory claimed from + // length prefixes, not bytes, and refused valid stored actions. Every + // v4.1 binary reads it this way at protocol versions up to 13, the + // shipped ones; 14 is unreleased. + let group_action = GroupAction::deserialize_from_bytes_trusted_no_limit(&value)?; Ok(group_action) } @@ -81,7 +86,9 @@ impl Drive { )?; if !approximate_without_state_for_costs { - let group_action = GroupAction::deserialize_from_bytes_trusted(&value)?; + // Without the proof budget, as in `fetch_active_action_info_v0`: + // every co-signer of a group action loads it here. + let group_action = GroupAction::deserialize_from_bytes_trusted_no_limit(&value)?; Ok(Some(group_action)) } else { @@ -97,6 +104,7 @@ mod tests { use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; use dpp::data_contract::config::v0::DataContractConfigV0; use dpp::data_contract::config::DataContractConfig; use dpp::data_contract::group::v0::GroupV0; @@ -109,11 +117,22 @@ mod tests { use dpp::identifier::Identifier; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; + use dpp::tests::fixtures::get_token_conventions_with_localizations_fixture; use dpp::tokens::token_event::TokenEvent; use dpp::version::PlatformVersion; use std::collections::BTreeMap; fn setup_with_action() -> (crate::drive::Drive, Identifier, Identifier) { + let (drive, contract_id, action_id, _) = + setup_with_action_event(|proposer_id| TokenEvent::Mint(1, proposer_id, None)); + (drive, contract_id, action_id) + } + + /// A drive holding one active action whose event `event` builds from the + /// proposer's id. + fn setup_with_action_event( + event: impl FnOnce(Identifier) -> TokenEvent, + ) -> (crate::drive::Drive, Identifier, Identifier, GroupAction) { let drive = setup_drive_with_initial_state_structure(None); let platform_version = PlatformVersion::latest(); @@ -175,13 +194,13 @@ mod tests { contract_id, proposer_id: id_1, token_contract_position: 0, - event: GroupActionEvent::TokenEvent(TokenEvent::Mint(1, id_1, None)), + event: GroupActionEvent::TokenEvent(event(id_1)), }); drive .add_group_action( contract_id, 0, - Some(action), + Some(action.clone()), false, action_id, id_1, @@ -193,7 +212,7 @@ mod tests { ) .unwrap(); - (drive, contract_id, action_id) + (drive, contract_id, action_id, action) } #[test] @@ -266,4 +285,44 @@ mod tests { assert!(result.is_some()); } + + /// Drive reads the actions it stored without the budget proofs decode + /// under. A conventions change with 1,250 valid localizations claims + /// 100,000 bytes for its map at the length prefix; that budget refused + /// it, so no co-signer could load the action. Protocol version 13, the + /// last one v4.1 runs, reads it as v4.1 does: without a budget. + #[test] + fn should_fetch_a_stored_conventions_change_with_1250_localizations() { + let (drive, contract_id, action_id, action) = setup_with_action_event(|_| { + TokenEvent::ConfigUpdate( + TokenConfigurationChangeItem::Conventions( + get_token_conventions_with_localizations_fixture(1_250), + ), + None, + ) + }); + + for platform_version in [ + PlatformVersion::get(13).expect("expected protocol version 13"), + PlatformVersion::latest(), + ] { + let fetched = drive + .fetch_active_action_info(contract_id, 0, action_id, None, platform_version) + .expect("expected to fetch the stored action"); + assert_eq!(fetched, action); + + let fetched = drive + .fetch_active_action_info_and_add_operations( + contract_id, + 0, + action_id, + false, + None, + &mut vec![], + platform_version, + ) + .expect("expected to fetch the stored action"); + assert_eq!(fetched.as_ref(), Some(&action)); + } + } } diff --git a/packages/rs-drive/src/verify/group/verify_active_action_infos/v0/mod.rs b/packages/rs-drive/src/verify/group/verify_active_action_infos/v0/mod.rs index f3de4798a07..1a73fc9f04e 100644 --- a/packages/rs-drive/src/verify/group/verify_active_action_infos/v0/mod.rs +++ b/packages/rs-drive/src/verify/group/verify_active_action_infos/v0/mod.rs @@ -82,6 +82,7 @@ mod tests { use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem; use dpp::data_contract::config::v0::DataContractConfigV0; use dpp::data_contract::config::DataContractConfig; use dpp::data_contract::group::v0::GroupV0; @@ -90,17 +91,20 @@ mod tests { use dpp::data_contract::DataContract; use dpp::group::action_event::GroupActionEvent; use dpp::group::group_action::v0::GroupActionV0; + use dpp::group::group_action::GroupActionAccessors; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; + use dpp::tests::fixtures::get_token_conventions_with_localizations_fixture; use dpp::tokens::token_event::TokenEvent; use dpp::version::PlatformVersion; use std::collections::BTreeMap; - #[test] - fn should_prove_and_verify_action_infos_roundtrip() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); - + /// Inserts a token contract whose group 0 has two members of power 1 and + /// needs both, returning the contract id and the two members. + fn insert_contract_with_group( + drive: &Drive, + platform_version: &PlatformVersion, + ) -> (Identifier, Identifier, Identifier) { let identity_1 = Identity::random_identity(3, Some(14), platform_version) .expect("expected a platform identity"); let identity_1_id = identity_1.id(); @@ -109,7 +113,6 @@ mod tests { .expect("expected a platform identity"); let identity_2_id = identity_2.id(); - // Create a data contract with groups let contract = DataContract::V1(DataContractV1 { id: Default::default(), version: 0, @@ -157,7 +160,41 @@ mod tests { ) .expect("expected to insert contract"); - let contract_id = contract.id(); + (contract.id(), identity_1_id, identity_2_id) + } + + /// Stores `action` as active in group 0 of its contract, signed by its + /// proposer. + fn store_active_action( + drive: &Drive, + action_id: Identifier, + action: &GroupAction, + platform_version: &PlatformVersion, + ) { + drive + .add_group_action( + action.contract_id(), + 0, + Some(action.clone()), + false, + action_id, + action.proposer_id(), + 1, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add the action"); + } + + #[test] + fn should_prove_and_verify_action_infos_roundtrip() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let (contract_id, identity_1_id, identity_2_id) = + insert_contract_with_group(&drive, platform_version); let group_contract_position = 0; let action_id_1 = Identifier::random(); @@ -177,37 +214,8 @@ mod tests { event: GroupActionEvent::TokenEvent(TokenEvent::Burn(50, identity_2_id, None)), }); - drive - .add_group_action( - contract_id, - group_contract_position, - Some(action_1.clone()), - false, - action_id_1, - identity_1_id, - 1, - &BlockInfo::default(), - true, - None, - platform_version, - ) - .expect("expected to add action 1"); - - drive - .add_group_action( - contract_id, - group_contract_position, - Some(action_2.clone()), - false, - action_id_2, - identity_2_id, - 1, - &BlockInfo::default(), - true, - None, - platform_version, - ) - .expect("expected to add action 2"); + store_active_action(&drive, action_id_1, &action_1, platform_version); + store_active_action(&drive, action_id_2, &action_2, platform_version); // Prove using the public prove_action_infos method let proof = drive @@ -288,4 +296,77 @@ mod tests { assert!(!root_hash.is_empty(), "root hash should not be empty"); assert!(proved_actions.is_empty(), "should have no actions"); } + + /// A client decodes proved actions under the budget `GroupAction` sets for + /// untrusted input. A conventions change with 1,250 valid localizations + /// claims 100,000 bytes for its map at the length prefix, which the + /// previous budget refused, so every proof of a page including it failed + /// to verify. + #[test] + fn should_verify_a_proof_holding_a_conventions_change_with_1250_localizations() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let (contract_id, identity_1_id, identity_2_id) = + insert_contract_with_group(&drive, platform_version); + + let conventions_action_id = Identifier::new([1; 32]); + let conventions_action = GroupAction::V0(GroupActionV0 { + contract_id, + proposer_id: identity_1_id, + token_contract_position: 0, + event: GroupActionEvent::TokenEvent(TokenEvent::ConfigUpdate( + TokenConfigurationChangeItem::Conventions( + get_token_conventions_with_localizations_fixture(1_250), + ), + None, + )), + }); + let mint_action_id = Identifier::new([2; 32]); + let mint_action = GroupAction::V0(GroupActionV0 { + contract_id, + proposer_id: identity_2_id, + token_contract_position: 0, + event: GroupActionEvent::TokenEvent(TokenEvent::Mint(100, identity_2_id, None)), + }); + store_active_action( + &drive, + conventions_action_id, + &conventions_action, + platform_version, + ); + store_active_action(&drive, mint_action_id, &mint_action, platform_version); + + let proof = drive + .prove_action_infos( + contract_id, + 0, + GroupActionStatus::ActionActive, + None, + Some(10), + None, + platform_version, + ) + .expect("expected to prove the actions"); + + let (_, proved_actions): (_, BTreeMap) = + Drive::verify_action_infos_in_contract( + proof.as_slice(), + contract_id, + 0, + GroupActionStatus::ActionActive, + None, + Some(10), + false, + platform_version, + ) + .expect("expected to verify the proof"); + + assert_eq!( + proved_actions, + BTreeMap::from([ + (conventions_action_id, conventions_action), + (mint_action_id, mint_action), + ]) + ); + } }