Skip to main content

dpp/group/group_action/
mod.rs

1pub mod v0;
2
3use crate::data_contract::TokenContractPosition;
4use crate::group::action_event::GroupActionEvent;
5use crate::group::group_action::v0::GroupActionV0;
6#[cfg(feature = "json-conversion")]
7use crate::serialization::JsonConvertible;
8#[cfg(feature = "value-conversion")]
9use crate::serialization::ValueConvertible;
10use crate::ProtocolError;
11use bincode::{Decode, DecodeUntrusted, Encode};
12use platform_serialization_derive::{
13    PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize,
14};
15use platform_value::Identifier;
16#[cfg(feature = "serde-conversion")]
17use serde::{Deserialize, Serialize};
18
19#[cfg_attr(
20    all(feature = "json-conversion", feature = "serde-conversion"),
21    derive(JsonConvertible)
22)]
23#[derive(
24    Debug,
25    PartialEq,
26    PartialOrd,
27    Clone,
28    Eq,
29    Encode,
30    Decode,
31    PlatformDeserializeTrusted,
32    PlatformDeserializeUntrusted,
33    PlatformSerialize,
34    DecodeUntrusted,
35)]
36#[cfg_attr(
37    feature = "serde-conversion",
38    derive(Serialize, Deserialize),
39    serde(tag = "$formatVersion")
40)]
41#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))]
42// Group actions reach two decoders. Drive reads the actions it stored itself
43// with `deserialize_from_bytes_trusted_no_limit`, as v4.1 did: every stored
44// action was valid when it was written, so a budget there could only refuse
45// one of them. A client decodes them from GroveDB proof elements before the
46// quorum signature is checked, and that untrusted decode runs under this
47// budget.
48//
49// bincode's limit counts memory claimed, not bytes read. A map, a set or a
50// vector of anything but bytes claims `len * size_of::<T>()` from its length
51// prefix before reading an element; a string or a byte vector claims its
52// length. Of the events a group can store, the conventions localizations map
53// claims the most per encoded byte: 80 bytes per entry
54// (`size_of::<(String, TokenConfigurationLocalization)>()` on a 64-bit target)
55// against at least 13 encoded (a two-letter language code, two three-letter
56// forms, their three length prefixes, the variant tag and the capitalization
57// flag). The `SetPrices` and `Stepwise` maps claim 16 bytes per entry against
58// at least 2 encoded for their first 251 keys and 4 after; strings, notes and
59// identifiers claim what they encode. A stored action copies its containers
60// from the transition that proposed it and is smaller than that transition
61// (which also carries its signature and the token id), and a transition is at
62// most `max_state_transition_size` bytes (20,480 at every protocol version),
63// so no stored action claims more than 20,480 / 13 * 80, about 126,000 bytes.
64// 262,144 (256 KiB) leaves more than twice that.
65//
66// The derive takes the budget as a literal, so it cannot live in
67// `SystemLimits`. No consensus path reads it. It has to grow if
68// `max_state_transition_size` ever does, and
69// `should_decode_the_largest_storable_action_of_each_container_shape` fails
70// until it does.
71#[platform_serialize(limit = 262144, unversioned)] //versioned directly, no need to use platform_version
72pub enum GroupAction {
73    #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))]
74    V0(GroupActionV0),
75}
76
77pub trait GroupActionAccessors {
78    fn contract_id(&self) -> Identifier;
79
80    fn proposer_id(&self) -> Identifier;
81    fn token_contract_position(&self) -> TokenContractPosition;
82    fn event(&self) -> &GroupActionEvent;
83}
84impl GroupActionAccessors for GroupAction {
85    fn contract_id(&self) -> Identifier {
86        match self {
87            GroupAction::V0(inner) => inner.contract_id(),
88        }
89    }
90
91    fn proposer_id(&self) -> Identifier {
92        match self {
93            GroupAction::V0(inner) => inner.proposer_id(),
94        }
95    }
96
97    fn token_contract_position(&self) -> TokenContractPosition {
98        match self {
99            GroupAction::V0(inner) => inner.token_contract_position(),
100        }
101    }
102
103    fn event(&self) -> &GroupActionEvent {
104        match self {
105            GroupAction::V0(inner) => inner.event(),
106        }
107    }
108}
109
110// TODO(unification pass 2): add round-trip tests for GroupAction once we have an
111// explicit fixture (GroupActionV0 has no Default — its `event: GroupActionEvent`
112// field is itself a versioned enum without Default).
113
114#[cfg(test)]
115mod deserialize_limit_tests {
116    use super::*;
117    use crate::data_contract::associated_token::token_configuration_convention::TokenConfigurationConvention;
118    use crate::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem;
119    use crate::data_contract::associated_token::token_perpetual_distribution::distribution_function::DistributionFunction;
120    use crate::data_contract::associated_token::token_perpetual_distribution::distribution_recipient::TokenDistributionRecipient;
121    use crate::data_contract::associated_token::token_perpetual_distribution::reward_distribution_type::RewardDistributionType;
122    use crate::data_contract::associated_token::token_perpetual_distribution::v0::TokenPerpetualDistributionV0;
123    use crate::data_contract::associated_token::token_perpetual_distribution::TokenPerpetualDistribution;
124    use crate::serialization::{
125        PlatformDeserializableTrusted, PlatformDeserializableUntrusted, PlatformSerializable,
126    };
127    use crate::tests::fixtures::get_token_conventions_with_localizations_fixture;
128    use crate::tokens::token_event::TokenEvent;
129    use crate::tokens::token_pricing_schedule::TokenPricingSchedule;
130    use platform_version::version::{PlatformVersion, PLATFORM_VERSIONS};
131    use std::collections::BTreeMap;
132    use platform_serialization::PLATFORM_BINCODE_CONFIG;
133
134    /// The budget `GroupAction` decoded under before it was sized from
135    /// memory claims.
136    const PREVIOUS_BUDGET: usize = 100_000;
137
138    /// Builds an action holding the given number of entries in one container.
139    type ActionBuilder = fn(usize) -> GroupAction;
140
141    fn action_with_event(event: TokenEvent) -> GroupAction {
142        GroupAction::V0(GroupActionV0 {
143            contract_id: Identifier::new([1; 32]),
144            proposer_id: Identifier::new([2; 32]),
145            token_contract_position: 0,
146            event: GroupActionEvent::TokenEvent(event),
147        })
148    }
149
150    fn conventions_change(conventions: TokenConfigurationConvention) -> GroupAction {
151        action_with_event(TokenEvent::ConfigUpdate(
152            TokenConfigurationChangeItem::Conventions(conventions),
153            None,
154        ))
155    }
156
157    fn localizations_action(entries: usize) -> GroupAction {
158        conventions_change(get_token_conventions_with_localizations_fixture(entries))
159    }
160
161    /// `entries` keys counted up from 0, each mapping to 0: the shortest
162    /// encoding a `u64` to `u64` map with that many entries can have.
163    fn shortest_u64_map(entries: usize) -> BTreeMap<u64, u64> {
164        (0..entries as u64).map(|key| (key, 0)).collect()
165    }
166
167    fn set_prices_action(entries: usize) -> GroupAction {
168        action_with_event(TokenEvent::ChangePriceForDirectPurchase(
169            Some(TokenPricingSchedule::SetPrices(shortest_u64_map(entries))),
170            None,
171        ))
172    }
173
174    fn stepwise_action(entries: usize) -> GroupAction {
175        action_with_event(TokenEvent::ConfigUpdate(
176            TokenConfigurationChangeItem::PerpetualDistribution(Some(
177                TokenPerpetualDistribution::V0(TokenPerpetualDistributionV0 {
178                    distribution_type: RewardDistributionType::BlockBasedDistribution {
179                        interval: 1,
180                        function: DistributionFunction::Stepwise(shortest_u64_map(entries)),
181                    },
182                    distribution_recipient: TokenDistributionRecipient::ContractOwner,
183                }),
184            )),
185            None,
186        ))
187    }
188
189    fn note_action(length: usize) -> GroupAction {
190        action_with_event(TokenEvent::Mint(
191            1,
192            Identifier::new([3; 32]),
193            Some("a".repeat(length)),
194        ))
195    }
196
197    fn encode(action: &GroupAction) -> Vec<u8> {
198        action
199            .serialize_to_bytes()
200            .expect("expected to encode the action")
201    }
202
203    /// The action `build` makes with the most entries whose encoding still
204    /// fits in `max_bytes`.
205    fn largest_fitting(build: ActionBuilder, max_bytes: usize) -> GroupAction {
206        // Every entry takes at least one byte, so `max_bytes` entries never fit.
207        let (mut fits, mut too_many) = (0, max_bytes);
208        while too_many - fits > 1 {
209            let entries = (fits + too_many) / 2;
210            if encode(&build(entries)).len() <= max_bytes {
211                fits = entries;
212            } else {
213                too_many = entries;
214            }
215        }
216        build(fits)
217    }
218
219    /// Whether `bytes` decode untrusted under the budget `GroupAction` had
220    /// before.
221    fn decodes_under_previous_budget(bytes: &[u8]) -> bool {
222        let config = PLATFORM_BINCODE_CONFIG.with_limit::<PREVIOUS_BUDGET>();
223        bincode::decode_from_slice_untrusted::<GroupAction, _>(bytes, config).is_ok()
224    }
225
226    /// `bytes` decode back to `action` untrusted under the budget, as a client
227    /// reads a proof, and trusted with and without it.
228    fn assert_decodes_with_every_decoder(bytes: &[u8], action: &GroupAction, shape: &str) {
229        let untrusted = GroupAction::deserialize_from_bytes_untrusted(bytes)
230            .unwrap_or_else(|e| panic!("{shape}: untrusted decode failed: {e}"));
231        assert_eq!(&untrusted, action, "{shape}: untrusted decode");
232        let trusted = GroupAction::deserialize_from_bytes_trusted(bytes)
233            .unwrap_or_else(|e| panic!("{shape}: trusted decode failed: {e}"));
234        assert_eq!(&trusted, action, "{shape}: trusted decode");
235        let trusted_no_limit = GroupAction::deserialize_from_bytes_trusted_no_limit(bytes)
236            .unwrap_or_else(|e| panic!("{shape}: trusted decode without a limit failed: {e}"));
237        assert_eq!(
238            &trusted_no_limit, action,
239            "{shape}: trusted decode without a limit"
240        );
241    }
242
243    /// A conventions change with 1,250 valid localizations encodes in 16,325
244    /// bytes, but its map claims 1,250 * 80 = 100,000 bytes at its length
245    /// prefix. The previous budget refused it, on Drive's reads as well as on
246    /// proofs.
247    #[test]
248    fn should_decode_a_conventions_change_with_1250_localizations() {
249        let conventions = get_token_conventions_with_localizations_fixture(1_250);
250        assert!(conventions
251            .validate_localizations(PlatformVersion::latest())
252            .expect("expected to validate the localizations")
253            .is_valid());
254        let action = conventions_change(conventions);
255        let bytes = encode(&action);
256        assert_eq!(bytes.len(), 16_325);
257
258        assert!(!decodes_under_previous_budget(&bytes));
259        assert_decodes_with_every_decoder(&bytes, &action, "1,250 localizations");
260    }
261
262    /// A stored action copies its containers from the transition that
263    /// proposed it and is smaller than that transition, which also carries its
264    /// signature and the token id, so no stored action encodes in more than
265    /// `max_state_transition_size` bytes.
266    /// For each container a group action can hold, the one packing the most
267    /// entries into that size decodes with every decoder. Only the
268    /// localizations map claimed more than the previous budget.
269    #[test]
270    fn should_decode_the_largest_storable_action_of_each_container_shape() {
271        let max_bytes = PLATFORM_VERSIONS
272            .iter()
273            .map(|platform_version| platform_version.system_limits.max_state_transition_size)
274            .max()
275            .expect("expected platform versions") as usize;
276        let shapes: [(&str, ActionBuilder, bool); 4] = [
277            ("localizations", localizations_action, false),
278            ("SetPrices", set_prices_action, true),
279            ("Stepwise", stepwise_action, true),
280            ("note", note_action, true),
281        ];
282        for (shape, build, decoded_under_previous_budget) in shapes {
283            let action = largest_fitting(build, max_bytes);
284            let bytes = encode(&action);
285            assert!(
286                bytes.len() > max_bytes - 16,
287                "{shape}: {} bytes",
288                bytes.len()
289            );
290            assert_eq!(
291                decodes_under_previous_budget(&bytes),
292                decoded_under_previous_budget,
293                "{shape}: previous budget"
294            );
295            assert_decodes_with_every_decoder(&bytes, &action, shape);
296        }
297    }
298
299    /// A proof element is untrusted input: a localizations length prefix
300    /// claiming a million entries is refused against the budget before any
301    /// entry is read.
302    #[test]
303    fn should_refuse_a_localizations_length_prefix_beyond_the_budget() {
304        // An empty map ends the encoding with its length (0), the decimals
305        // and the absent note; keep everything before them.
306        let empty = encode(&localizations_action(0));
307        let mut buf = empty[..empty.len() - 3].to_vec();
308        buf.extend_from_slice(
309            &bincode::encode_to_vec(1_000_000u64, PLATFORM_BINCODE_CONFIG).unwrap(),
310        );
311
312        let err = GroupAction::deserialize_from_bytes_untrusted(&buf)
313            .expect_err("oversized length prefix must be rejected");
314        assert!(
315            matches!(err, ProtocolError::MaxEncodedBytesReachedError { .. }),
316            "unexpected error: {err}"
317        );
318    }
319
320    /// A proof element is untrusted input: a note length prefix must be
321    /// rejected against the byte budget before it sizes an allocation.
322    #[test]
323    fn rejects_note_length_prefix_beyond_budget_without_allocating() {
324        let config = PLATFORM_BINCODE_CONFIG;
325        let mut buf = Vec::new();
326        // GroupAction::V0, then GroupActionV0 { contract_id, proposer_id, position, event }
327        buf.extend_from_slice(&bincode::encode_to_vec(0u32, config).unwrap());
328        buf.extend_from_slice(&[0u8; 32]);
329        buf.extend_from_slice(&[0u8; 32]);
330        buf.extend_from_slice(&bincode::encode_to_vec(0u16, config).unwrap());
331        // GroupActionEvent::TokenEvent(TokenEvent::Freeze(id, Some(note)))
332        buf.extend_from_slice(&bincode::encode_to_vec(0u32, config).unwrap());
333        buf.extend_from_slice(&bincode::encode_to_vec(2u32, config).unwrap());
334        buf.extend_from_slice(&[0u8; 32]);
335        buf.push(1);
336        // note length prefix claiming 8 GB, with no bytes following it
337        buf.extend_from_slice(&bincode::encode_to_vec(8_000_000_000u64, config).unwrap());
338
339        let err = GroupAction::deserialize_from_bytes_untrusted(&buf)
340            .expect_err("oversized length prefix must be rejected");
341        assert!(
342            matches!(err, ProtocolError::MaxEncodedBytesReachedError { .. }),
343            "unexpected error: {err}"
344        );
345    }
346}