penumbra_sdk_dex/lp/
action.rs

1use serde::{Deserialize, Serialize};
2
3use penumbra_sdk_asset::{balance, Balance, Value};
4use penumbra_sdk_proto::{penumbra::core::component::dex::v1 as pb, DomainType};
5use penumbra_sdk_txhash::{EffectHash, EffectingData};
6
7use super::{position, position::Position, LpNft};
8
9/// A transaction action that opens a new position.
10///
11/// This action's contribution to the transaction's value balance is to consume
12/// the initial reserves and contribute an opened position NFT.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(try_from = "pb::PositionOpen", into = "pb::PositionOpen")]
15pub struct PositionOpen {
16    /// Contains the data defining the position, sufficient to compute its `PositionId`.
17    ///
18    /// Positions are immutable, so the `PositionData` (and hence the `PositionId`)
19    /// are unchanged over the entire lifetime of the position.
20    pub position: Position,
21    /// Optional encrypted metadata about this position.
22    ///
23    /// If present, this is a 50-byte blob containing a 24-byte nonce and a 26-byte ciphertext
24    /// (a 10-byte serialized `PositionMetadata` plus a 16-byte authentication tag).
25    pub encrypted_metadata: Option<Vec<u8>>,
26}
27
28impl EffectingData for PositionOpen {
29    fn effect_hash(&self) -> EffectHash {
30        // The position open action consists only of the position, which
31        // we consider effecting data.
32        EffectHash::from_proto_effecting_data(&self.to_proto())
33    }
34}
35
36impl PositionOpen {
37    /// Compute a commitment to the value this action contributes to its transaction.
38    pub fn balance(&self) -> Balance {
39        let opened_position_nft = Value {
40            amount: 1u64.into(),
41            asset_id: LpNft::new(self.position.id(), position::State::Opened).asset_id(),
42        };
43
44        let reserves = self.position.reserves.balance(&self.position.phi.pair);
45
46        // The action consumes the reserves and produces an LP NFT
47        Balance::from(opened_position_nft) - reserves
48    }
49}
50
51/// A transaction action that closes a position.
52///
53/// This action's contribution to the transaction's value balance is to consume
54/// an opened position NFT and contribute a closed position NFT.
55///
56/// Closing a position does not immediately withdraw funds, because Penumbra
57/// transactions (like any ZK transaction model) are early-binding: the prover
58/// must know the state transition they prove knowledge of, and they cannot know
59/// the final reserves with certainty until after the position has been deactivated.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(try_from = "pb::PositionClose", into = "pb::PositionClose")]
62pub struct PositionClose {
63    pub position_id: position::Id,
64}
65
66impl EffectingData for PositionClose {
67    fn effect_hash(&self) -> EffectHash {
68        EffectHash::from_proto_effecting_data(&self.to_proto())
69    }
70}
71
72impl PositionClose {
73    /// Compute the value this action contributes to its transaction.
74    pub fn balance(&self) -> Balance {
75        let opened_position_nft = Value {
76            amount: 1u64.into(),
77            asset_id: LpNft::new(self.position_id, position::State::Opened).asset_id(),
78        };
79
80        let closed_position_nft = Value {
81            amount: 1u64.into(),
82            asset_id: LpNft::new(self.position_id, position::State::Closed).asset_id(),
83        };
84
85        // The action consumes an opened position and produces a closed position.
86        Balance::from(closed_position_nft) - opened_position_nft
87    }
88}
89
90/// A transaction action that withdraws funds from a closed position.
91///
92/// This action's contribution to the transaction's value balance is to consume a
93/// closed position NFT and contribute a withdrawn position NFT, as well as all
94/// of the funds that were in the position at the time of closing.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[serde(try_from = "pb::PositionWithdraw", into = "pb::PositionWithdraw")]
97pub struct PositionWithdraw {
98    pub position_id: position::Id,
99    /// A transparent (zero blinding factor) commitment to the position's final reserves and fees.
100    ///
101    /// The chain will check this commitment by recomputing it with the on-chain state.
102    pub reserves_commitment: balance::Commitment,
103    /// The sequence number of the withdrawal, allowing multiple withdrawals from the same position.
104    pub sequence: u64,
105}
106
107impl EffectingData for PositionWithdraw {
108    fn effect_hash(&self) -> EffectHash {
109        EffectHash::from_proto_effecting_data(&self.to_proto())
110    }
111}
112
113impl DomainType for PositionOpen {
114    type Proto = pb::PositionOpen;
115}
116
117impl From<PositionOpen> for pb::PositionOpen {
118    fn from(value: PositionOpen) -> Self {
119        Self {
120            position: Some(value.position.into()),
121            encrypted_metadata: value.encrypted_metadata.unwrap_or_default(),
122        }
123    }
124}
125
126impl TryFrom<pb::PositionOpen> for PositionOpen {
127    type Error = anyhow::Error;
128
129    fn try_from(value: pb::PositionOpen) -> Result<Self, Self::Error> {
130        // Validate that if encrypted_metadata is present, it's exactly 50 bytes (24-byte nonce + 26-byte ciphertext)
131        if !value.encrypted_metadata.is_empty() && value.encrypted_metadata.len() != 50 {
132            anyhow::bail!("encrypted_metadata must be exactly 50 bytes if present");
133        }
134
135        Ok(Self {
136            position: value
137                .position
138                .ok_or_else(|| anyhow::anyhow!("missing position"))?
139                .try_into()?,
140            encrypted_metadata: if value.encrypted_metadata.is_empty() {
141                None
142            } else {
143                Some(value.encrypted_metadata)
144            },
145        })
146    }
147}
148
149impl DomainType for PositionClose {
150    type Proto = pb::PositionClose;
151}
152
153impl From<PositionClose> for pb::PositionClose {
154    fn from(value: PositionClose) -> Self {
155        Self {
156            position_id: Some(value.position_id.into()),
157        }
158    }
159}
160
161impl TryFrom<pb::PositionClose> for PositionClose {
162    type Error = anyhow::Error;
163
164    fn try_from(value: pb::PositionClose) -> Result<Self, Self::Error> {
165        Ok(Self {
166            position_id: value
167                .position_id
168                .ok_or_else(|| anyhow::anyhow!("missing position_id"))?
169                .try_into()?,
170        })
171    }
172}
173
174impl DomainType for PositionWithdraw {
175    type Proto = pb::PositionWithdraw;
176}
177
178impl From<PositionWithdraw> for pb::PositionWithdraw {
179    fn from(value: PositionWithdraw) -> Self {
180        Self {
181            position_id: Some(value.position_id.into()),
182            reserves_commitment: Some(value.reserves_commitment.into()),
183            sequence: value.sequence,
184        }
185    }
186}
187
188impl TryFrom<pb::PositionWithdraw> for PositionWithdraw {
189    type Error = anyhow::Error;
190
191    fn try_from(value: pb::PositionWithdraw) -> Result<Self, Self::Error> {
192        Ok(Self {
193            position_id: value
194                .position_id
195                .ok_or_else(|| anyhow::anyhow!("missing position_id"))?
196                .try_into()?,
197            reserves_commitment: value
198                .reserves_commitment
199                .ok_or_else(|| anyhow::anyhow!("missing balance_commitment"))?
200                .try_into()?,
201            sequence: value.sequence,
202        })
203    }
204}