penumbra_sdk_shielded_pool/
ics20_withdrawal.rs

1use ibc_types::core::{channel::ChannelId, channel::PortId, client::Height as IbcHeight};
2use penumbra_sdk_asset::{
3    asset::{self, Metadata},
4    Balance, Value,
5};
6use penumbra_sdk_keys::Address;
7use penumbra_sdk_num::Amount;
8use penumbra_sdk_proto::{
9    penumbra::core::component::ibc::v1::{self as pb, FungibleTokenPacketData},
10    DomainType,
11};
12use penumbra_sdk_txhash::{EffectHash, EffectingData};
13use serde::{Deserialize, Serialize};
14use std::str::FromStr;
15
16#[cfg(feature = "component")]
17use penumbra_sdk_ibc::component::packet::{IBCPacket, Unchecked};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(try_from = "pb::Ics20Withdrawal", into = "pb::Ics20Withdrawal")]
21pub struct Ics20Withdrawal {
22    // a transparent value consisting of an amount and a denom.
23    pub amount: Amount,
24    pub denom: asset::Metadata,
25    // the address on the destination chain to send the transfer to
26    pub destination_chain_address: String,
27    // a "sender" penumbra address to use to return funds from this withdrawal.
28    // this should be an ephemeral address
29    pub return_address: Address,
30    // the height (on Penumbra) at which this transfer expires (and funds are sent
31    // back to the return address?). NOTE: if funds are sent back to the sender,
32    // we MUST verify a nonexistence proof before accepting the timeout, to
33    // prevent relayer censorship attacks. The core IBC implementation does this
34    // in its handling of validation of timeouts.
35    pub timeout_height: IbcHeight,
36    // the timestamp at which this transfer expires, in nanoseconds after unix epoch.
37    pub timeout_time: u64,
38    // the source channel used for the withdrawal
39    pub source_channel: ChannelId,
40
41    // Whether to use a "compat" (bech32, non-m) address for the return address in the withdrawal,
42    // for compatability with chains that expect to be able to parse the return address as bech32.
43    pub use_compat_address: bool,
44
45    // Arbitrary string data to be included in the `memo` field
46    // of the ICS-20 FungibleTokenPacketData for this withdrawal.
47    // Commonly used for packet forwarding support, or other protocols that may support usage of the memo field.
48    pub ics20_memo: String,
49    // Whether to use a transparent address for the return address in the withdrawal.
50    pub use_transparent_address: bool,
51}
52
53#[cfg(feature = "component")]
54impl From<Ics20Withdrawal> for IBCPacket<Unchecked> {
55    fn from(withdrawal: Ics20Withdrawal) -> Self {
56        Self::new(
57            PortId::transfer(),
58            withdrawal.source_channel.clone(),
59            withdrawal.timeout_height,
60            withdrawal.timeout_time,
61            withdrawal.packet_data(),
62        )
63    }
64}
65
66impl Ics20Withdrawal {
67    pub fn value(&self) -> Value {
68        Value {
69            amount: self.amount,
70            asset_id: self.denom.id(),
71        }
72    }
73
74    pub fn balance(&self) -> Balance {
75        -Balance::from(self.value())
76    }
77
78    pub fn packet_data(&self) -> Vec<u8> {
79        let ftpd: FungibleTokenPacketData = self.clone().into();
80
81        // In violation of the ICS20 spec, ibc-go encodes transfer packets as JSON.
82        serde_json::to_vec(&ftpd).expect("can serialize FungibleTokenPacketData as JSON")
83    }
84
85    // stateless validation of an Ics20 withdrawal action.
86    pub fn validate(&self) -> anyhow::Result<()> {
87        if self.timeout_time == 0 {
88            anyhow::bail!("timeout time must be non-zero");
89        }
90
91        // in order to prevent clients from inadvertantly identifying themselves by their clock
92        // skew, enforce that timeout time is rounded to the nearest minute
93        if self.timeout_time % 60_000_000_000 != 0 {
94            anyhow::bail!(
95                "withdrawal timeout timestamp {} is not rounded to one minute",
96                self.timeout_time
97            );
98        }
99
100        // NOTE: we could validate the destination chain address as bech32 to prevent mistyped
101        // addresses, but this would preclude sending to chains that don't use bech32 addresses.
102
103        Ok(())
104    }
105}
106
107impl EffectingData for Ics20Withdrawal {
108    fn effect_hash(&self) -> EffectHash {
109        EffectHash::from_proto_effecting_data(&self.to_proto())
110    }
111}
112
113impl DomainType for Ics20Withdrawal {
114    type Proto = pb::Ics20Withdrawal;
115}
116
117#[allow(deprecated)]
118impl From<Ics20Withdrawal> for pb::Ics20Withdrawal {
119    fn from(w: Ics20Withdrawal) -> Self {
120        pb::Ics20Withdrawal {
121            amount: Some(w.amount.into()),
122            denom: Some(w.denom.base_denom().into()),
123            destination_chain_address: w.destination_chain_address,
124            return_address: Some(w.return_address.into()),
125            timeout_height: Some(w.timeout_height.into()),
126            timeout_time: w.timeout_time,
127            source_channel: w.source_channel.to_string(),
128            use_compat_address: w.use_compat_address,
129            ics20_memo: w.ics20_memo.to_string(),
130            use_transparent_address: w.use_transparent_address,
131        }
132    }
133}
134
135#[allow(deprecated)]
136impl TryFrom<pb::Ics20Withdrawal> for Ics20Withdrawal {
137    type Error = anyhow::Error;
138    fn try_from(s: pb::Ics20Withdrawal) -> Result<Self, Self::Error> {
139        Ok(Self {
140            amount: s
141                .amount
142                .ok_or_else(|| anyhow::anyhow!("missing amount"))?
143                .try_into()?,
144            denom: Metadata::default_for(
145                &s.denom
146                    .ok_or_else(|| anyhow::anyhow!("missing denom metadata"))?
147                    .try_into()?,
148            )
149            .ok_or_else(|| anyhow::anyhow!("could not generate default denom metadata"))?,
150            destination_chain_address: s.destination_chain_address,
151            return_address: s
152                .return_address
153                .ok_or_else(|| anyhow::anyhow!("missing sender"))?
154                .try_into()?,
155            timeout_height: s
156                .timeout_height
157                .ok_or_else(|| anyhow::anyhow!("missing timeout height"))?
158                .try_into()?,
159            timeout_time: s.timeout_time,
160            source_channel: ChannelId::from_str(&s.source_channel)?,
161            use_compat_address: s.use_compat_address,
162            ics20_memo: s.ics20_memo,
163            use_transparent_address: s.use_transparent_address,
164        })
165    }
166}
167
168impl From<Ics20Withdrawal> for pb::FungibleTokenPacketData {
169    fn from(w: Ics20Withdrawal) -> Self {
170        let ordinary_return_address = w.return_address.to_string();
171
172        let return_address = if w.use_transparent_address {
173            w.return_address
174                .encode_as_transparent_address()
175                .unwrap_or_else(|| ordinary_return_address)
176        } else {
177            ordinary_return_address
178        };
179
180        pb::FungibleTokenPacketData {
181            amount: w.value().amount.to_string(),
182            denom: w.denom.to_string(),
183            receiver: w.destination_chain_address,
184            sender: return_address,
185            memo: w.ics20_memo,
186        }
187    }
188}