penumbra_sdk_shielded_pool/component/
transfer.rs

1use std::str::FromStr;
2
3use crate::{
4    component::{AssetRegistry, NoteManager},
5    event::{self, FungibleTokenTransferPacketMetadata},
6    Ics20Withdrawal,
7};
8use anyhow::{Context, Result};
9use async_trait::async_trait;
10use cnidarium::{StateRead, StateWrite};
11use ibc_types::core::channel::Packet;
12use ibc_types::{
13    core::channel::{
14        channel::Order as ChannelOrder,
15        msgs::{
16            MsgAcknowledgement, MsgChannelCloseConfirm, MsgChannelCloseInit, MsgChannelOpenAck,
17            MsgChannelOpenConfirm, MsgChannelOpenInit, MsgChannelOpenTry, MsgRecvPacket,
18            MsgTimeout,
19        },
20        ChannelId, PortId, Version,
21    },
22    transfer::acknowledgement::TokenTransferAcknowledgement,
23};
24use penumbra_sdk_asset::{asset, asset::Metadata, Value};
25use penumbra_sdk_ibc::component::ChannelStateReadExt;
26use penumbra_sdk_keys::Address;
27use penumbra_sdk_num::Amount;
28use penumbra_sdk_proto::{
29    penumbra::core::component::ibc::v1::FungibleTokenPacketData, DomainType as _, StateReadProto,
30    StateWriteProto,
31};
32use penumbra_sdk_sct::CommitmentSource;
33
34use penumbra_sdk_ibc::component::{
35    app_handler::{AppHandler, AppHandlerCheck, AppHandlerExecute},
36    packet::{
37        IBCPacket, SendPacketRead as _, SendPacketWrite as _, Unchecked, WriteAcknowledgement as _,
38    },
39    state_key,
40};
41use tendermint::Time;
42
43// returns a bool indicating if the provided denom was issued locally or if it was bridged in.
44// this logic is a bit tricky, and adapted from https://github.com/cosmos/ibc/tree/main/spec/app/ics-020-fungible-token-transfer (sendFungibleTokens).
45//
46// what we want to do is to determine if the denom being withdrawn is a native token (one
47// that originates from Penumbra) or a bridged token (one that was sent into penumbra from
48// IBC).
49//
50// A simple way of doing this is by parsing the denom, looking for a prefix that is only
51// appended in the case of a bridged token. That is what this logic does.
52//
53// note that in the case of a refund, eg. when this function is called from `onTimeoutPacket`,
54// the logic is inverted, as a prefix will only be prepended in the case the token is bridged in.
55fn is_source(
56    source_port: &PortId,
57    source_channel: &ChannelId,
58    denom: &Metadata,
59    is_refund: bool,
60) -> bool {
61    let prefix = format!("{source_port}/{source_channel}/");
62
63    if is_refund {
64        !denom.starts_with(&prefix)
65    } else {
66        denom.starts_with(&prefix)
67    }
68}
69
70#[derive(Clone)]
71pub struct Ics20Transfer {}
72
73#[async_trait]
74pub trait Ics20TransferReadExt: StateRead {
75    async fn withdrawal_check(
76        &self,
77        withdrawal: &Ics20Withdrawal,
78        current_block_time: Time,
79    ) -> Result<()> {
80        // create packet
81        let packet: IBCPacket<Unchecked> = withdrawal.clone().into();
82
83        // send packet
84        self.send_packet_check(packet, current_block_time).await?;
85
86        Ok(())
87    }
88}
89
90impl<T: StateRead + ?Sized> Ics20TransferReadExt for T {}
91
92#[async_trait]
93pub trait Ics20TransferWriteExt: StateWrite {
94    async fn withdrawal_execute(&mut self, withdrawal: &Ics20Withdrawal) -> Result<()> {
95        // create packet, assume it's already checked since the component caller contract calls `check` before `execute`
96        let checked_packet = IBCPacket::<Unchecked>::from(withdrawal.clone()).assume_checked();
97
98        let prefix = format!("transfer/{}/", &withdrawal.source_channel);
99        if !withdrawal.denom.starts_with(&prefix) {
100            // we are the source. add the value balance to the escrow channel.
101            let existing_value_balance: Amount = self
102                .get(&state_key::ics20_value_balance::by_asset_id(
103                    &withdrawal.source_channel,
104                    &withdrawal.denom.id(),
105                ))
106                .await
107                .expect("able to retrieve value balance in ics20 withdrawal! (execute)")
108                .unwrap_or_else(Amount::zero);
109
110            let new_value_balance = existing_value_balance
111                .checked_add(&withdrawal.amount)
112                .ok_or_else(|| {
113                    anyhow::anyhow!("overflow adding value balance in ics20 withdrawal")
114                })?;
115            self.put(
116                state_key::ics20_value_balance::by_asset_id(
117                    &withdrawal.source_channel,
118                    &withdrawal.denom.id(),
119                ),
120                new_value_balance,
121            );
122            self.record_proto(
123                event::EventOutboundFungibleTokenTransfer {
124                    value: Value {
125                        amount: withdrawal.amount,
126                        asset_id: withdrawal.denom.id(),
127                    },
128                    sender: withdrawal.return_address.clone(),
129                    receiver: withdrawal.destination_chain_address.clone(),
130                    meta: FungibleTokenTransferPacketMetadata {
131                        channel: withdrawal.source_channel.0.clone(),
132                        sequence: self
133                            .get_send_sequence(
134                                &withdrawal.source_channel,
135                                &checked_packet.source_port(),
136                            )
137                            .await?,
138                    },
139                }
140                .to_proto(),
141            );
142        } else {
143            // receiver is the source, burn utxos
144
145            // double check the value balance here.
146            //
147            // for assets not originating from Penumbra, never transfer out more tokens than were
148            // transferred in. (Our counterparties should be checking this anyways, since if we
149            // were Byzantine we could lie to them).
150            let value_balance: Amount = self
151                .get(&state_key::ics20_value_balance::by_asset_id(
152                    &withdrawal.source_channel,
153                    &withdrawal.denom.id(),
154                ))
155                .await?
156                .unwrap_or_else(Amount::zero);
157
158            if value_balance < withdrawal.amount {
159                anyhow::bail!("insufficient balance to withdraw tokens");
160            }
161
162            let new_value_balance =
163                value_balance
164                    .checked_sub(&withdrawal.amount)
165                    .ok_or_else(|| {
166                        anyhow::anyhow!("underflow subtracting value balance in ics20 withdrawal")
167                    })?;
168            self.put(
169                state_key::ics20_value_balance::by_asset_id(
170                    &withdrawal.source_channel,
171                    &withdrawal.denom.id(),
172                ),
173                new_value_balance,
174            );
175            self.record_proto(
176                event::EventOutboundFungibleTokenTransfer {
177                    value: Value {
178                        amount: withdrawal.amount,
179                        asset_id: withdrawal.denom.id(),
180                    },
181                    sender: withdrawal.return_address.clone(),
182                    receiver: withdrawal.destination_chain_address.clone(),
183                    meta: FungibleTokenTransferPacketMetadata {
184                        channel: withdrawal.source_channel.0.clone(),
185                        sequence: self
186                            .get_send_sequence(
187                                &withdrawal.source_channel,
188                                &checked_packet.source_port(),
189                            )
190                            .await?,
191                    },
192                }
193                .to_proto(),
194            );
195        }
196
197        self.send_packet_execute(checked_packet).await;
198
199        Ok(())
200    }
201}
202
203impl<T: StateWrite + ?Sized> Ics20TransferWriteExt for T {}
204
205// see: https://github.com/cosmos/ibc/tree/master/spec/app/ics-020-fungible-token-transfer
206#[async_trait]
207impl AppHandlerCheck for Ics20Transfer {
208    async fn chan_open_init_check<S: StateRead>(_state: S, msg: &MsgChannelOpenInit) -> Result<()> {
209        if msg.ordering != ChannelOrder::Unordered {
210            anyhow::bail!("channel order must be unordered for Ics20 transfer");
211        }
212        let ics20_version = Version::new("ics20-1".to_string());
213        if msg.version_proposal != ics20_version {
214            anyhow::bail!("channel version must be ics20 for Ics20 transfer");
215        }
216
217        Ok(())
218    }
219
220    async fn chan_open_try_check<S: StateRead>(_state: S, msg: &MsgChannelOpenTry) -> Result<()> {
221        if msg.ordering != ChannelOrder::Unordered {
222            anyhow::bail!("channel order must be unordered for Ics20 transfer");
223        }
224        let ics20_version = Version::new("ics20-1".to_string());
225
226        if msg.version_supported_on_a != ics20_version {
227            anyhow::bail!("counterparty version must be ics20-1 for Ics20 transfer");
228        }
229
230        Ok(())
231    }
232
233    async fn chan_open_ack_check<S: StateRead>(_state: S, msg: &MsgChannelOpenAck) -> Result<()> {
234        let ics20_version = Version::new("ics20-1".to_string());
235        if msg.version_on_b != ics20_version {
236            anyhow::bail!("counterparty version must be ics20-1 for Ics20 transfer");
237        }
238
239        Ok(())
240    }
241
242    async fn chan_open_confirm_check<S: StateRead>(
243        _state: S,
244        _msg: &MsgChannelOpenConfirm,
245    ) -> Result<()> {
246        // accept channel confirmations, port has already been validated, version has already been validated
247        Ok(())
248    }
249
250    async fn chan_close_confirm_check<S: StateRead>(
251        _state: S,
252        _msg: &MsgChannelCloseConfirm,
253    ) -> Result<()> {
254        // no action necessary
255        Ok(())
256    }
257
258    async fn chan_close_init_check<S: StateRead>(
259        _state: S,
260        _msg: &MsgChannelCloseInit,
261    ) -> Result<()> {
262        // always abort transaction
263        anyhow::bail!("ics20 always aborts on close init");
264    }
265
266    async fn recv_packet_check<S: StateRead>(_state: S, _msg: &MsgRecvPacket) -> Result<()> {
267        // all checks on recv_packet done in execute
268        Ok(())
269    }
270
271    async fn timeout_packet_check<S: StateRead>(state: S, msg: &MsgTimeout) -> Result<()> {
272        let packet_data: FungibleTokenPacketData =
273            serde_json::from_slice(msg.packet.data.as_slice())?;
274        let denom: asset::Metadata = packet_data.denom.as_str().try_into()?;
275
276        if is_source(&msg.packet.port_on_a, &msg.packet.chan_on_a, &denom, true) {
277            // check if we have enough balance to refund tokens to sender
278            let value_balance: Amount = state
279                .get(&state_key::ics20_value_balance::by_asset_id(
280                    &msg.packet.chan_on_a,
281                    &denom.id(),
282                ))
283                .await?
284                .unwrap_or_else(Amount::zero);
285
286            let amount_penumbra: Amount = packet_data.amount.try_into()?;
287            if value_balance < amount_penumbra {
288                anyhow::bail!("insufficient balance to refund tokens to sender");
289            }
290        }
291
292        Ok(())
293    }
294
295    async fn acknowledge_packet_check<S: StateRead>(
296        _state: S,
297        _msg: &MsgAcknowledgement,
298    ) -> Result<()> {
299        Ok(())
300    }
301}
302
303// the main entry point for ICS20 transfer packet handling
304async fn recv_transfer_packet_inner<S: StateWrite>(
305    mut state: S,
306    msg: &MsgRecvPacket,
307) -> Result<()> {
308    // parse if we are source or dest, and mint or burn accordingly
309    //
310    // see this part of the spec for this logic:
311    //
312    // https://github.com/cosmos/ibc/tree/main/spec/app/ics-020-fungible-token-transfer (onRecvPacket)
313    //
314    // NOTE: spec says proto but this is actually JSON according to the ibc-go implementation
315    let packet_data: FungibleTokenPacketData = serde_json::from_slice(msg.packet.data.as_slice())
316        .with_context(|| "failed to decode FTPD packet")?;
317    let packet_denom: asset::Metadata = packet_data
318        .denom
319        .as_str()
320        .try_into()
321        .context("couldnt decode denom in ICS20 transfer")?;
322    let receiver_amount: Amount = packet_data
323        .amount
324        .try_into()
325        .context("couldnt decode amount in ICS20 transfer")?;
326    let receiver_address = Address::from_str(&packet_data.receiver)?;
327
328    // NOTE: here we assume we are chain A.
329
330    // 2. check if we are the source chain for the denom.
331    if is_source(
332        &msg.packet.port_on_a,
333        &msg.packet.chan_on_a,
334        &packet_denom,
335        false,
336    ) {
337        // mint tokens to receiver in the amount of packet_data.amount in the denom of denom (with
338        // the source removed, since we're the source)
339        let prefix = format!(
340            "{source_port}/{source_chan}/",
341            source_port = msg.packet.port_on_a,
342            source_chan = msg.packet.chan_on_a
343        );
344
345        let denom: asset::Metadata = packet_data
346            .denom
347            .strip_prefix(&prefix)
348            .context(format!(
349                "denom in packet didn't begin with expected prefix {}",
350                prefix
351            ))?
352            .try_into()
353            .context("couldnt decode denom in ICS20 transfer")?;
354
355        let value: Value = Value {
356            amount: receiver_amount,
357            asset_id: denom.id(),
358        };
359
360        // assume AppHandlerCheck has already been called, and we have enough balance to mint tokens to receiver
361        // check if we have enough balance to unescrow tokens to receiver
362        let value_balance: Amount = state
363            .get(&state_key::ics20_value_balance::by_asset_id(
364                &msg.packet.chan_on_b,
365                &denom.id(),
366            ))
367            .await?
368            .unwrap_or_else(Amount::zero);
369
370        if value_balance < receiver_amount {
371            // error text here is from the ics20 spec
372            anyhow::bail!("transfer coins failed");
373        }
374
375        state
376            .mint_note(
377                value,
378                &receiver_address,
379                CommitmentSource::Ics20Transfer {
380                    packet_seq: msg.packet.sequence.0,
381                    // We are chain A
382                    channel_id: msg.packet.chan_on_a.0.clone(),
383                    sender: packet_data.sender.clone(),
384                },
385            )
386            .await
387            .context("unable to mint note when receiving ics20 transfer packet")?;
388
389        // update the value balance
390        // note: this arithmetic was checked above, but we do it again anyway.
391        let new_value_balance = value_balance
392            .checked_sub(&receiver_amount)
393            .context("underflow subtracing value balance in ics20 transfer")?;
394        state.put(
395            state_key::ics20_value_balance::by_asset_id(&msg.packet.chan_on_b, &denom.id()),
396            new_value_balance,
397        );
398        state.record_proto(
399            event::EventInboundFungibleTokenTransfer {
400                value,
401                sender: packet_data.sender.clone(),
402                receiver: receiver_address,
403                meta: FungibleTokenTransferPacketMetadata {
404                    channel: msg.packet.chan_on_a.0.clone(),
405                    sequence: msg.packet.sequence.0,
406                },
407            }
408            .to_proto(),
409        );
410    } else {
411        // create new denom:
412        //
413        // prefix = "{packet.destPort}/{packet.destChannel}/"
414        // prefixedDenomination = prefix + data.denom
415        //
416        // then mint that denom to packet_data.receiver in packet_data.amount
417        let prefixed_denomination = format!(
418            "{}/{}/{}",
419            msg.packet.port_on_b, msg.packet.chan_on_b, packet_data.denom
420        );
421
422        let denom: asset::Metadata = prefixed_denomination
423            .as_str()
424            .try_into()
425            .context("unable to parse denom in ics20 transfer as DenomMetadata")?;
426        state.register_denom(&denom).await;
427
428        let value = Value {
429            amount: receiver_amount,
430            asset_id: denom.id(),
431        };
432
433        state
434            .mint_note(
435                value,
436                &receiver_address,
437                CommitmentSource::Ics20Transfer {
438                    packet_seq: msg.packet.sequence.0,
439                    // We are chain A
440                    channel_id: msg.packet.chan_on_a.0.clone(),
441                    sender: packet_data.sender.clone(),
442                },
443            )
444            .await
445            .context("failed to mint notes in ibc transfer")?;
446
447        // update the value balance
448        let value_balance: Amount = state
449            .get(&state_key::ics20_value_balance::by_asset_id(
450                &msg.packet.chan_on_b,
451                &denom.id(),
452            ))
453            .await?
454            .unwrap_or_else(Amount::zero);
455
456        let new_value_balance = value_balance.saturating_add(&value.amount);
457        state.put(
458            state_key::ics20_value_balance::by_asset_id(&msg.packet.chan_on_b, &denom.id()),
459            new_value_balance,
460        );
461        state.record_proto(
462            event::EventInboundFungibleTokenTransfer {
463                value,
464                sender: packet_data.sender.clone(),
465                receiver: receiver_address,
466                meta: FungibleTokenTransferPacketMetadata {
467                    channel: msg.packet.chan_on_a.0.clone(),
468                    sequence: msg.packet.sequence.0,
469                },
470            }
471            .to_proto(),
472        );
473    }
474
475    Ok(())
476}
477
478// see: https://github.com/cosmos/ibc/blob/8326e26e7e1188b95c32481ff00348a705b23700/spec/app/ics-020-fungible-token-transfer/README.md?plain=1#L297
479async fn refund_tokens<S: StateWrite>(
480    mut state: S,
481    packet: &Packet,
482    reason: event::FungibleTokenRefundReason,
483) -> Result<()> {
484    let packet_data: FungibleTokenPacketData = serde_json::from_slice(packet.data.as_slice())?;
485    let denom: asset::Metadata = packet_data // CRITICAL: verify that this denom is validated in upstream timeout handling
486        .denom
487        .as_str()
488        .try_into()
489        .context("couldn't decode denom in ics20 transfer timeout")?;
490    // receiver was source chain, mint vouchers back to sender
491    let amount: Amount = packet_data
492        .amount
493        .try_into()
494        .context("couldn't decode amount in ics20 transfer timeout")?;
495
496    // packet_data.sender is the original sender for this packet that was not committed on the
497    // other chain but was sent from penumbra. so, the penumbra refund receiver address is the
498    // sender
499    let receiver = Address::from_str(&packet_data.sender)
500        .context("couldn't decode receiver address in ics20 timeout")?;
501
502    let value: Value = Value {
503        amount,
504        asset_id: denom.id(),
505    };
506
507    if is_source(&packet.port_on_a, &packet.chan_on_a, &denom, true) {
508        // sender was source chain, unescrow tokens back to sender
509        let value_balance: Amount = state
510            .get(&state_key::ics20_value_balance::by_asset_id(
511                &packet.chan_on_a,
512                &denom.id(),
513            ))
514            .await?
515            .unwrap_or_else(Amount::zero);
516
517        if value_balance < amount {
518            anyhow::bail!("couldn't return coins in timeout: not enough value balance");
519        }
520
521        state
522            .mint_note(
523                value,
524                &receiver,
525                CommitmentSource::Ics20Transfer {
526                    packet_seq: packet.sequence.0,
527                    channel_id: packet.chan_on_a.0.clone(),
528                    sender: packet_data.sender.clone(),
529                },
530            )
531            .await
532            .context("couldn't mint note in timeout_packet_inner")?;
533
534        // update the value balance
535        // note: this arithmetic was checked above, but we do it again anyway.
536        let new_value_balance = value_balance
537            .checked_sub(&amount)
538            .context("underflow in ics20 timeout packet value balance subtraction")?;
539        state.put(
540            state_key::ics20_value_balance::by_asset_id(&packet.chan_on_a, &denom.id()),
541            new_value_balance,
542        );
543        state.record_proto(
544            event::EventOutboundFungibleTokenRefund {
545                value,
546                sender: receiver, // note, this comes from packet_data.sender
547                receiver: packet_data.receiver.clone(),
548                reason,
549                // Use the destination channel, i.e. our name for it, to be consistent across events.
550                meta: FungibleTokenTransferPacketMetadata {
551                    channel: packet.chan_on_b.0.clone(),
552                    sequence: packet.sequence.0,
553                },
554            }
555            .to_proto(),
556        );
557    } else {
558        let value_balance: Amount = state
559            .get(&state_key::ics20_value_balance::by_asset_id(
560                &packet.chan_on_a,
561                &denom.id(),
562            ))
563            .await?
564            .unwrap_or_else(Amount::zero);
565
566        state
567            .mint_note(
568                value,
569                &receiver,
570                // NOTE: should this be Ics20TransferTimeout?
571                CommitmentSource::Ics20Transfer {
572                    packet_seq: packet.sequence.0,
573                    channel_id: packet.chan_on_a.0.clone(),
574                    sender: packet_data.sender.clone(),
575                },
576            )
577            .await
578            .context("failed to mint return voucher in ics20 transfer timeout")?;
579
580        let new_value_balance = value_balance.saturating_add(&value.amount);
581        state.put(
582            state_key::ics20_value_balance::by_asset_id(&packet.chan_on_a, &denom.id()),
583            new_value_balance,
584        );
585        state.record_proto(
586            event::EventOutboundFungibleTokenRefund {
587                value,
588                sender: receiver, // note, this comes from packet_data.sender
589                receiver: packet_data.receiver.clone(),
590                reason,
591                // Use the destination channel, i.e. our name for it, to be consistent across events.
592                meta: FungibleTokenTransferPacketMetadata {
593                    channel: packet.chan_on_b.0.clone(),
594                    sequence: packet.sequence.0,
595                },
596            }
597            .to_proto(),
598        );
599    }
600
601    Ok(())
602}
603
604// NOTE: should these be fallible, now that our enclosing state machine is fallible in execution?
605#[async_trait]
606impl AppHandlerExecute for Ics20Transfer {
607    async fn chan_open_init_execute<S: StateWrite>(_state: S, _msg: &MsgChannelOpenInit) {}
608    async fn chan_open_try_execute<S: StateWrite>(_state: S, _msg: &MsgChannelOpenTry) {}
609    async fn chan_open_ack_execute<S: StateWrite>(_state: S, _msg: &MsgChannelOpenAck) {}
610    async fn chan_open_confirm_execute<S: StateWrite>(_state: S, _msg: &MsgChannelOpenConfirm) {}
611    async fn chan_close_confirm_execute<S: StateWrite>(_state: S, _msg: &MsgChannelCloseConfirm) {}
612    async fn chan_close_init_execute<S: StateWrite>(_state: S, _msg: &MsgChannelCloseInit) {}
613    async fn recv_packet_execute<S: StateWrite>(mut state: S, msg: &MsgRecvPacket) -> Result<()> {
614        // recv packet should never fail a transaction, but it should record a failure acknowledgement.
615        let ack: Vec<u8> = match recv_transfer_packet_inner(&mut state, msg).await {
616            Ok(_) => {
617                // record packet acknowledgement without error
618                TokenTransferAcknowledgement::success().into()
619            }
620            Err(e) => {
621                tracing::debug!("couldnt execute transfer: {:#}", e);
622                // record packet acknowledgement with error
623                TokenTransferAcknowledgement::Error(e.to_string()).into()
624            }
625        };
626
627        state
628            .write_acknowledgement(&msg.packet, &ack)
629            .await
630            .context("able to write acknowledgement")?;
631
632        Ok(())
633    }
634
635    async fn timeout_packet_execute<S: StateWrite>(mut state: S, msg: &MsgTimeout) -> Result<()> {
636        // timeouts may fail due to counterparty chains sending transfers of u128-1
637        refund_tokens(
638            &mut state,
639            &msg.packet,
640            event::FungibleTokenRefundReason::Timeout,
641        )
642        .await
643        .context("able to timeout packet")?;
644
645        Ok(())
646    }
647
648    async fn acknowledge_packet_execute<S: StateWrite>(
649        mut state: S,
650        msg: &MsgAcknowledgement,
651    ) -> Result<()> {
652        let ack: TokenTransferAcknowledgement =
653            serde_json::from_slice(msg.acknowledgement.as_slice())?;
654        if !ack.is_successful() {
655            // in the case where a counterparty chain acknowledges a packet with an error,
656            // for example due to a middleware processing issue or other behavior,
657            // the funds should be unescrowed back to the packet sender.
658            refund_tokens(
659                &mut state,
660                &msg.packet,
661                event::FungibleTokenRefundReason::Error,
662            )
663            .await
664            .context("unable to refund packet acknowledgement")?;
665        }
666
667        Ok(())
668    }
669}
670
671impl AppHandler for Ics20Transfer {}