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
133    /// The budget `GroupAction` decoded under before it was sized from
134    /// memory claims.
135    const PREVIOUS_BUDGET: usize = 100_000;
136
137    /// Builds an action holding the given number of entries in one container.
138    type ActionBuilder = fn(usize) -> GroupAction;
139
140    fn action_with_event(event: TokenEvent) -> GroupAction {
141        GroupAction::V0(GroupActionV0 {
142            contract_id: Identifier::new([1; 32]),
143            proposer_id: Identifier::new([2; 32]),
144            token_contract_position: 0,
145            event: GroupActionEvent::TokenEvent(event),
146        })
147    }
148
149    fn conventions_change(conventions: TokenConfigurationConvention) -> GroupAction {
150        action_with_event(TokenEvent::ConfigUpdate(
151            TokenConfigurationChangeItem::Conventions(conventions),
152            None,
153        ))
154    }
155
156    fn localizations_action(entries: usize) -> GroupAction {
157        conventions_change(get_token_conventions_with_localizations_fixture(entries))
158    }
159
160    /// `entries` keys counted up from 0, each mapping to 0: the shortest
161    /// encoding a `u64` to `u64` map with that many entries can have.
162    fn shortest_u64_map(entries: usize) -> BTreeMap<u64, u64> {
163        (0..entries as u64).map(|key| (key, 0)).collect()
164    }
165
166    fn set_prices_action(entries: usize) -> GroupAction {
167        action_with_event(TokenEvent::ChangePriceForDirectPurchase(
168            Some(TokenPricingSchedule::SetPrices(shortest_u64_map(entries))),
169            None,
170        ))
171    }
172
173    fn stepwise_action(entries: usize) -> GroupAction {
174        action_with_event(TokenEvent::ConfigUpdate(
175            TokenConfigurationChangeItem::PerpetualDistribution(Some(
176                TokenPerpetualDistribution::V0(TokenPerpetualDistributionV0 {
177                    distribution_type: RewardDistributionType::BlockBasedDistribution {
178                        interval: 1,
179                        function: DistributionFunction::Stepwise(shortest_u64_map(entries)),
180                    },
181                    distribution_recipient: TokenDistributionRecipient::ContractOwner,
182                }),
183            )),
184            None,
185        ))
186    }
187
188    fn note_action(length: usize) -> GroupAction {
189        action_with_event(TokenEvent::Mint(
190            1,
191            Identifier::new([3; 32]),
192            Some("a".repeat(length)),
193        ))
194    }
195
196    fn encode(action: &GroupAction) -> Vec<u8> {
197        action
198            .serialize_to_bytes()
199            .expect("expected to encode the action")
200    }
201
202    /// The action `build` makes with the most entries whose encoding still
203    /// fits in `max_bytes`.
204    fn largest_fitting(build: ActionBuilder, max_bytes: usize) -> GroupAction {
205        // Every entry takes at least one byte, so `max_bytes` entries never fit.
206        let (mut fits, mut too_many) = (0, max_bytes);
207        while too_many - fits > 1 {
208            let entries = (fits + too_many) / 2;
209            if encode(&build(entries)).len() <= max_bytes {
210                fits = entries;
211            } else {
212                too_many = entries;
213            }
214        }
215        build(fits)
216    }
217
218    /// Whether `bytes` decode untrusted under the budget `GroupAction` had
219    /// before.
220    fn decodes_under_previous_budget(bytes: &[u8]) -> bool {
221        let config = bincode::config::standard()
222            .with_big_endian()
223            .with_limit::<PREVIOUS_BUDGET>();
224        bincode::decode_from_slice_untrusted::<GroupAction, _>(bytes, config).is_ok()
225    }
226
227    /// `bytes` decode back to `action` untrusted under the budget, as a client
228    /// reads a proof, and trusted with and without it.
229    fn assert_decodes_with_every_decoder(bytes: &[u8], action: &GroupAction, shape: &str) {
230        let untrusted = GroupAction::deserialize_from_bytes_untrusted(bytes)
231            .unwrap_or_else(|e| panic!("{shape}: untrusted decode failed: {e}"));
232        assert_eq!(&untrusted, action, "{shape}: untrusted decode");
233        let trusted = GroupAction::deserialize_from_bytes_trusted(bytes)
234            .unwrap_or_else(|e| panic!("{shape}: trusted decode failed: {e}"));
235        assert_eq!(&trusted, action, "{shape}: trusted decode");
236        let trusted_no_limit = GroupAction::deserialize_from_bytes_trusted_no_limit(bytes)
237            .unwrap_or_else(|e| panic!("{shape}: trusted decode without a limit failed: {e}"));
238        assert_eq!(
239            &trusted_no_limit, action,
240            "{shape}: trusted decode without a limit"
241        );
242    }
243
244    /// A conventions change with 1,250 valid localizations encodes in 16,325
245    /// bytes, but its map claims 1,250 * 80 = 100,000 bytes at its length
246    /// prefix. The previous budget refused it, on Drive's reads as well as on
247    /// proofs.
248    #[test]
249    fn should_decode_a_conventions_change_with_1250_localizations() {
250        let conventions = get_token_conventions_with_localizations_fixture(1_250);
251        assert!(conventions
252            .validate_localizations(PlatformVersion::latest())
253            .expect("expected to validate the localizations")
254            .is_valid());
255        let action = conventions_change(conventions);
256        let bytes = encode(&action);
257        assert_eq!(bytes.len(), 16_325);
258
259        assert!(!decodes_under_previous_budget(&bytes));
260        assert_decodes_with_every_decoder(&bytes, &action, "1,250 localizations");
261    }
262
263    /// A stored action copies its containers from the transition that
264    /// proposed it and is smaller than that transition, which also carries its
265    /// signature and the token id, so no stored action encodes in more than
266    /// `max_state_transition_size` bytes.
267    /// For each container a group action can hold, the one packing the most
268    /// entries into that size decodes with every decoder. Only the
269    /// localizations map claimed more than the previous budget.
270    #[test]
271    fn should_decode_the_largest_storable_action_of_each_container_shape() {
272        let max_bytes = PLATFORM_VERSIONS
273            .iter()
274            .map(|platform_version| platform_version.system_limits.max_state_transition_size)
275            .max()
276            .expect("expected platform versions") as usize;
277        let shapes: [(&str, ActionBuilder, bool); 4] = [
278            ("localizations", localizations_action, false),
279            ("SetPrices", set_prices_action, true),
280            ("Stepwise", stepwise_action, true),
281            ("note", note_action, true),
282        ];
283        for (shape, build, decoded_under_previous_budget) in shapes {
284            let action = largest_fitting(build, max_bytes);
285            let bytes = encode(&action);
286            assert!(
287                bytes.len() > max_bytes - 16,
288                "{shape}: {} bytes",
289                bytes.len()
290            );
291            assert_eq!(
292                decodes_under_previous_budget(&bytes),
293                decoded_under_previous_budget,
294                "{shape}: previous budget"
295            );
296            assert_decodes_with_every_decoder(&bytes, &action, shape);
297        }
298    }
299
300    /// A proof element is untrusted input: a localizations length prefix
301    /// claiming a million entries is refused against the budget before any
302    /// entry is read.
303    #[test]
304    fn should_refuse_a_localizations_length_prefix_beyond_the_budget() {
305        // An empty map ends the encoding with its length (0), the decimals
306        // and the absent note; keep everything before them.
307        let empty = encode(&localizations_action(0));
308        let mut buf = empty[..empty.len() - 3].to_vec();
309        buf.extend_from_slice(
310            &bincode::encode_to_vec(1_000_000u64, bincode::config::standard().with_big_endian())
311                .unwrap(),
312        );
313
314        let err = GroupAction::deserialize_from_bytes_untrusted(&buf)
315            .expect_err("oversized length prefix must be rejected");
316        assert!(
317            matches!(err, ProtocolError::MaxEncodedBytesReachedError { .. }),
318            "unexpected error: {err}"
319        );
320    }
321
322    /// A proof element is untrusted input: a note length prefix must be
323    /// rejected against the byte budget before it sizes an allocation.
324    #[test]
325    fn rejects_note_length_prefix_beyond_budget_without_allocating() {
326        let config = bincode::config::standard()
327            .with_big_endian()
328            .with_no_limit();
329        let mut buf = Vec::new();
330        // GroupAction::V0, then GroupActionV0 { contract_id, proposer_id, position, event }
331        buf.extend_from_slice(&bincode::encode_to_vec(0u32, config).unwrap());
332        buf.extend_from_slice(&[0u8; 32]);
333        buf.extend_from_slice(&[0u8; 32]);
334        buf.extend_from_slice(&bincode::encode_to_vec(0u16, config).unwrap());
335        // GroupActionEvent::TokenEvent(TokenEvent::Freeze(id, Some(note)))
336        buf.extend_from_slice(&bincode::encode_to_vec(0u32, config).unwrap());
337        buf.extend_from_slice(&bincode::encode_to_vec(2u32, config).unwrap());
338        buf.extend_from_slice(&[0u8; 32]);
339        buf.push(1);
340        // note length prefix claiming 8 GB, with no bytes following it
341        buf.extend_from_slice(&bincode::encode_to_vec(8_000_000_000u64, config).unwrap());
342
343        let err = GroupAction::deserialize_from_bytes_untrusted(&buf)
344            .expect_err("oversized length prefix must be rejected");
345        assert!(
346            matches!(err, ProtocolError::MaxEncodedBytesReachedError { .. }),
347            "unexpected error: {err}"
348        );
349    }
350}