penumbra_sdk_dex/lp/
metadata.rs

1use std::num::NonZeroU32;
2
3use anyhow::Context;
4use penumbra_sdk_keys::{
5    symmetric::{POSITION_METADATA_NONCE_SIZE_BYTES, POSITION_METADATA_SIZE_BYTES},
6    PositionMetadataKey,
7};
8use penumbra_sdk_proto::{penumbra::core::component::dex::v1 as pb, DomainType};
9
10/// Metadata about a position, or bundle of positions.
11///
12/// See [UIP-9](https://uips.penumbra.zone/uip-9.html) for more details.
13#[derive(Debug, Clone, PartialEq, Copy)]
14pub struct PositionMetadata {
15    /// A strategy tag for the bundle.
16    pub strategy: NonZeroU32,
17
18    /// A unique identifier for the bundle this position belongs to.
19    pub identifier: NonZeroU32,
20}
21
22impl PositionMetadata {
23    pub fn encrypt(
24        self,
25        pmk: &PositionMetadataKey,
26        nonce: &[u8; POSITION_METADATA_NONCE_SIZE_BYTES],
27    ) -> Vec<u8> {
28        let bytes = self.encode_to_vec();
29        let plaintext: [u8; POSITION_METADATA_SIZE_BYTES] = bytes
30            .try_into()
31            .expect("PositionMetadata MUST always be exactly POSITION_METADATA_SIZE_BYTES long");
32        pmk.encrypt(&plaintext, nonce)
33    }
34
35    pub fn decrypt(
36        pmk: &PositionMetadataKey,
37        ciphertext: Option<&[u8]>,
38    ) -> anyhow::Result<Option<Self>> {
39        let Some(ciphertext) = ciphertext else {
40            return Ok(None);
41        };
42        if ciphertext.is_empty() {
43            return Ok(None);
44        }
45        let Some(bytes) = pmk.decrypt(ciphertext) else {
46            return Ok(None);
47        };
48
49        let metadata = PositionMetadata::decode(bytes.as_slice())
50            .context("failed to decode PositionMetadata from decrypted bytes")?;
51
52        Ok(Some(metadata))
53    }
54}
55
56impl DomainType for PositionMetadata {
57    type Proto = pb::PositionMetadata;
58}
59
60impl From<PositionMetadata> for pb::PositionMetadata {
61    fn from(value: PositionMetadata) -> Self {
62        Self {
63            strategy: value.strategy.into(),
64            identifier: value.identifier.into(),
65        }
66    }
67}
68
69impl TryFrom<pb::PositionMetadata> for PositionMetadata {
70    type Error = anyhow::Error;
71
72    fn try_from(value: pb::PositionMetadata) -> Result<Self, Self::Error> {
73        Ok(Self {
74            strategy: value
75                .strategy
76                .try_into()
77                .context("strategy should be non zero")?,
78            identifier: value
79                .identifier
80                .try_into()
81                .context("identifier should be non zero")?,
82        })
83    }
84}
85
86impl Default for PositionMetadata {
87    fn default() -> Self {
88        Self {
89            strategy: NonZeroU32::new(1).expect("1 is non-zero"),
90            identifier: NonZeroU32::new(1).expect("1 is non-zero"),
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use crate::lp::PositionMetadata;
98
99    use super::pb;
100    use penumbra_sdk_keys::keys::Bip44Path;
101    use penumbra_sdk_keys::keys::SeedPhrase;
102    use penumbra_sdk_keys::keys::SpendKey;
103    use penumbra_sdk_keys::symmetric::ENCRYPTED_POSITION_METADATA_SIZE_BYTES;
104    use penumbra_sdk_keys::symmetric::POSITION_METADATA_SIZE_BYTES;
105    use penumbra_sdk_keys::PositionMetadataKey;
106    use prost::Message;
107    use rand_core::OsRng;
108    use std::num::NonZeroU32;
109
110    #[test]
111    fn encrypted_metadata_len() {
112        let posmet = PositionMetadata {
113            strategy: NonZeroU32::new(1337u32).unwrap(),
114            identifier: NonZeroU32::new(1337u32).unwrap(),
115        };
116
117        let seed_phrase = SeedPhrase::generate(OsRng);
118        let sk_sender = SpendKey::from_seed_phrase_bip44(seed_phrase, &Bip44Path::new(0));
119        let fvk_sender = sk_sender.full_viewing_key();
120
121        let proto: pb::PositionMetadata = posmet.clone().into();
122        let size = proto.encode_to_vec().len();
123        assert_eq!(size, POSITION_METADATA_SIZE_BYTES);
124
125        let pmk = PositionMetadataKey::derive(fvk_sender.outgoing());
126        let encrypted_posmet = posmet.encrypt(&pmk, &[0u8; 24]);
127        let size = encrypted_posmet.len();
128        assert_eq!(size, ENCRYPTED_POSITION_METADATA_SIZE_BYTES);
129    }
130    #[test]
131    fn encrypted_format_check() {
132        let posmet = PositionMetadata {
133            strategy: NonZeroU32::new(1337u32).unwrap(),
134            identifier: NonZeroU32::new(1337u32).unwrap(),
135        };
136
137        let seed_phrase = SeedPhrase::generate(OsRng);
138        let sk_sender = SpendKey::from_seed_phrase_bip44(seed_phrase, &Bip44Path::new(0));
139        let fvk_sender = sk_sender.full_viewing_key();
140
141        let pmk = fvk_sender.position_metadata_key();
142        let raw_nonce = [0u8; 24];
143        let encrypted_posmet = posmet.encrypt(&pmk, &raw_nonce.clone());
144        assert_eq!(
145            encrypted_posmet.len(),
146            ENCRYPTED_POSITION_METADATA_SIZE_BYTES
147        );
148
149        let nonce = encrypted_posmet[..24].to_vec();
150        assert_eq!(nonce, raw_nonce);
151    }
152
153    #[test]
154    fn encrypted_metadata_roundtrip() {
155        let posmet = PositionMetadata {
156            strategy: NonZeroU32::new(55u32).unwrap(),
157            identifier: NonZeroU32::new(1337u32).unwrap(),
158        };
159
160        let seed_phrase = SeedPhrase::generate(OsRng);
161        let sk_sender = SpendKey::from_seed_phrase_bip44(seed_phrase, &Bip44Path::new(0));
162        let fvk_sender = sk_sender.full_viewing_key();
163
164        let proto: pb::PositionMetadata = posmet.clone().into();
165        let raw_metadata = proto.encode_to_vec();
166        let size = raw_metadata.len();
167
168        assert_eq!(size, POSITION_METADATA_SIZE_BYTES);
169
170        let pmk = fvk_sender.position_metadata_key();
171        let encrypted_posmet = posmet.clone().encrypt(&pmk, &[1u8; 24]);
172        let size = encrypted_posmet.len();
173        assert_eq!(size, ENCRYPTED_POSITION_METADATA_SIZE_BYTES);
174
175        let decrypted_posmet = PositionMetadata::decrypt(&pmk, Some(&encrypted_posmet))
176            .expect("decryption should succeed")
177            .expect("decrypted metadata should not be None");
178        assert!(decrypted_posmet == posmet);
179    }
180
181    #[test]
182    fn fixed_wire_size_some_id() {
183        let posmet = PositionMetadata {
184            strategy: NonZeroU32::new(55u32).unwrap(),
185            identifier: NonZeroU32::new(1337u32).unwrap(),
186        };
187        let proto: pb::PositionMetadata = posmet.into();
188        let size = proto.encoded_len();
189        assert_eq!(size, POSITION_METADATA_SIZE_BYTES);
190    }
191
192    #[test]
193    fn fixed_wire_size_max_id() {
194        let posmet = PositionMetadata {
195            strategy: NonZeroU32::new(u32::MAX).unwrap(),
196            identifier: NonZeroU32::new(u32::MAX).unwrap(),
197        };
198        let proto: pb::PositionMetadata = posmet.into();
199        let size = proto.encoded_len();
200        assert_eq!(size, POSITION_METADATA_SIZE_BYTES);
201    }
202
203    #[test]
204    fn fixed_wire_size_max_strat_max_id() {
205        let proto = pb::PositionMetadata {
206            strategy: 127u32,
207            identifier: u32::MAX,
208        };
209        let size = proto.encoded_len();
210        assert_eq!(size, POSITION_METADATA_SIZE_BYTES);
211    }
212
213    #[test]
214    fn fixed_wire_size_small_id() {
215        let posmet = PositionMetadata {
216            strategy: NonZeroU32::new(1u32).unwrap(),
217            identifier: NonZeroU32::new(1u32).unwrap(),
218        };
219        let proto: pb::PositionMetadata = posmet.into();
220        let size = proto.encoded_len();
221        assert_eq!(size, POSITION_METADATA_SIZE_BYTES);
222    }
223
224    #[test]
225    #[should_panic]
226    fn domain_type_invalid_identifier() {
227        let proto = pb::PositionMetadata {
228            strategy: 127,
229            identifier: 0,
230        };
231        let _: PositionMetadata = proto.try_into().unwrap();
232    }
233
234    #[test]
235    /// Tests that metadata passing through the domain type is not lossy
236    /// and that the wire size is correct.
237    fn domain_type_max_strategy() {
238        let original_proto = pb::PositionMetadata {
239            strategy: u32::MAX,
240            identifier: u32::MAX,
241        };
242        let domain: PositionMetadata = original_proto.clone().try_into().unwrap();
243
244        let expected = PositionMetadata {
245            strategy: NonZeroU32::new(u32::MAX).unwrap(),
246            identifier: NonZeroU32::new(u32::MAX).unwrap(),
247        };
248        assert_eq!(domain, expected);
249
250        let new_proto: pb::PositionMetadata = domain.clone().into();
251        assert_eq!(new_proto, original_proto);
252
253        let serialized = new_proto.encode_to_vec();
254        assert_eq!(serialized.len(), POSITION_METADATA_SIZE_BYTES);
255    }
256
257    #[test]
258    #[should_panic]
259    fn domain_type_invalid_zero_strategy() {
260        let proto = pb::PositionMetadata {
261            strategy: 0,
262            identifier: 1,
263        };
264        let _: PositionMetadata = proto.try_into().unwrap();
265    }
266
267    #[test]
268    #[should_panic]
269    fn domain_type_invalid_variant_invalid_id() {
270        let proto = pb::PositionMetadata {
271            strategy: 0,
272            identifier: 0,
273        };
274        let _: PositionMetadata = proto.try_into().unwrap();
275    }
276
277    #[test]
278    fn custom_strategy_lossy() {
279        let metadata = PositionMetadata {
280            strategy: NonZeroU32::new(1).unwrap(),
281            identifier: NonZeroU32::new(1).unwrap(),
282        };
283
284        let proto: pb::PositionMetadata = metadata.clone().into();
285
286        let roundtrip_metadata: PositionMetadata = proto.try_into().unwrap();
287        assert_eq!(roundtrip_metadata, metadata);
288    }
289}