penumbra_sdk_dex/lp/
nft.rs

1use penumbra_sdk_asset::asset;
2use penumbra_sdk_proto::{penumbra::core::component::dex::v1 as pb, DomainType};
3use regex::Regex;
4
5use super::position::{Id, State};
6
7/// The denomination of an LPNFT tracking both ownership and state of a position.
8///
9/// Tracking the state as part of the LPNFT means that all LP-related actions can
10/// be authorized by spending funds: a state transition (e.g., closing a
11/// position) is modeled as spending an "open position LPNFT" and minting a
12/// "closed position LPNFT" for the same (globally unique) position ID.
13///
14/// This means that the LP mechanics can be agnostic to the mechanism used to
15/// record custody and spend authorization.  For instance, they can be recorded
16/// in the shielded pool, where custody is based on off-chain keys, or they could
17/// be recorded in a programmatic on-chain account (in the future, e.g., to
18/// support interchain accounts).  This also means that LP-related actions don't
19/// require any cryptographic implementation (proofs, signatures, etc), other
20/// than hooking into the balance commitment mechanism used for transaction
21/// balances.
22#[derive(Debug, Clone)]
23pub struct LpNft {
24    position_id: Id,
25    state: State,
26    base_denom: asset::Metadata,
27}
28
29impl LpNft {
30    pub fn new(position_id: Id, state: State) -> Self {
31        let base_denom = asset::REGISTRY
32            .parse_denom(&format!("lpnft_{state}_{position_id}"))
33            .expect("base denom format is valid");
34
35        Self {
36            position_id,
37            state,
38            base_denom,
39        }
40    }
41
42    pub fn denom(&self) -> asset::Metadata {
43        self.base_denom.clone()
44    }
45
46    pub fn asset_id(&self) -> asset::Id {
47        self.base_denom.id()
48    }
49
50    pub fn position_id(&self) -> Id {
51        self.position_id
52    }
53
54    pub fn state(&self) -> State {
55        self.state
56    }
57}
58
59impl TryFrom<asset::Metadata> for LpNft {
60    type Error = anyhow::Error;
61
62    fn try_from(base_denom: asset::Metadata) -> Result<Self, Self::Error> {
63        // Note: this regex must be in sync with both asset::REGISTRY
64        // and the bech32 prefix for LP IDs defined in the proto crate.
65        let base_denom_string = base_denom.to_string();
66        let captures = Regex::new("^lpnft_(?P<state>[a-z_0-9]+)_(?P<id>plpid1[a-zA-HJ-NP-Z0-9]+)$")
67            .expect("regex is valid")
68            .captures(&base_denom_string)
69            .ok_or_else(|| {
70                anyhow::anyhow!(
71                    "base denom {} is not a delegation token",
72                    base_denom.to_string()
73                )
74            })?;
75
76        let position_id = captures
77            .name("id")
78            .expect("id is a named capture")
79            .as_str()
80            .parse()?;
81        let state = captures
82            .name("state")
83            .expect("state is a named capture")
84            .as_str()
85            .parse()?;
86
87        Ok(Self {
88            position_id,
89            state,
90            base_denom,
91        })
92    }
93}
94
95impl std::fmt::Display for LpNft {
96    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
97        self.base_denom.fmt(f)
98    }
99}
100
101impl std::str::FromStr for LpNft {
102    type Err = anyhow::Error;
103
104    fn from_str(s: &str) -> Result<Self, Self::Err> {
105        let base_denom = asset::REGISTRY
106            .parse_denom(s)
107            .ok_or_else(|| anyhow::anyhow!("invalid denom string"))?;
108        base_denom.try_into()
109    }
110}
111
112impl std::cmp::PartialEq for LpNft {
113    fn eq(&self, other: &Self) -> bool {
114        self.position_id == other.position_id && self.state == other.state
115    }
116}
117
118impl std::cmp::Eq for LpNft {}
119
120impl DomainType for LpNft {
121    type Proto = pb::LpNft;
122}
123
124impl TryFrom<pb::LpNft> for LpNft {
125    type Error = anyhow::Error;
126
127    fn try_from(value: pb::LpNft) -> Result<Self, Self::Error> {
128        let position_id = value
129            .position_id
130            .ok_or_else(|| anyhow::anyhow!("missing position id"))?
131            .try_into()?;
132        let state = value
133            .state
134            .ok_or_else(|| anyhow::anyhow!("missing position state"))?
135            .try_into()?;
136
137        Ok(Self::new(position_id, state))
138    }
139}
140
141impl From<LpNft> for pb::LpNft {
142    fn from(v: LpNft) -> Self {
143        pb::LpNft {
144            position_id: Some(v.position_id.into()),
145            state: Some(v.state.into()),
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use penumbra_sdk_asset::STAKING_TOKEN_ASSET_ID;
154
155    use super::super::{super::DirectedTradingPair, position::*};
156
157    #[test]
158    fn lpnft_denom_parsing_roundtrip() {
159        let pair = DirectedTradingPair {
160            start: *STAKING_TOKEN_ASSET_ID,
161            end: asset::Cache::with_known_assets()
162                .get_unit("cube")
163                .unwrap()
164                .id(),
165        };
166
167        let position = Position::new(
168            rand_core::OsRng,
169            pair,
170            1u32,
171            1u64.into(),
172            1u64.into(),
173            crate::lp::Reserves {
174                r1: 1u64.into(),
175                r2: 1u64.into(),
176            },
177        );
178        let position_id = position.id();
179
180        let lpnft1 = LpNft::new(position_id, State::Opened);
181        let lpnft1_string = lpnft1.denom().to_string();
182        assert_eq!(lpnft1.to_string(), lpnft1_string);
183
184        let lpnft2_denom = asset::REGISTRY.parse_denom(&lpnft1_string).unwrap();
185        let lpnft2 = LpNft::try_from(lpnft2_denom).unwrap();
186        assert_eq!(lpnft1, lpnft2);
187
188        let lpnft3: LpNft = lpnft1_string.parse().unwrap();
189        assert_eq!(lpnft1, lpnft3);
190
191        let lpnft_c = LpNft::new(position_id, State::Closed);
192        let lpnft_c_string = lpnft_c.denom().to_string();
193        let lpnft_c_2 = lpnft_c_string.parse().unwrap();
194        assert_eq!(lpnft_c, lpnft_c_2);
195
196        let lpnft_w0 = LpNft::new(position_id, State::Withdrawn { sequence: 0 });
197        let lpnft_w0_string = lpnft_w0.denom().to_string();
198        let lpnft_w0_2 = lpnft_w0_string.parse().unwrap();
199        assert_eq!(lpnft_w0, lpnft_w0_2);
200
201        let lpnft_w1 = LpNft::new(position_id, State::Withdrawn { sequence: 1 });
202        let lpnft_w1_string = lpnft_w1.denom().to_string();
203        let lpnft_w1_2 = lpnft_w1_string.parse().unwrap();
204        assert_eq!(lpnft_w1, lpnft_w1_2);
205    }
206}