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
43fn 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 let packet: IBCPacket<Unchecked> = withdrawal.clone().into();
82
83 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 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 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 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#[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 Ok(())
248 }
249
250 async fn chan_close_confirm_check<S: StateRead>(
251 _state: S,
252 _msg: &MsgChannelCloseConfirm,
253 ) -> Result<()> {
254 Ok(())
256 }
257
258 async fn chan_close_init_check<S: StateRead>(
259 _state: S,
260 _msg: &MsgChannelCloseInit,
261 ) -> Result<()> {
262 anyhow::bail!("ics20 always aborts on close init");
264 }
265
266 async fn recv_packet_check<S: StateRead>(_state: S, _msg: &MsgRecvPacket) -> Result<()> {
267 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 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
303async fn recv_transfer_packet_inner<S: StateWrite>(
305 mut state: S,
306 msg: &MsgRecvPacket,
307) -> Result<()> {
308 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 if is_source(
332 &msg.packet.port_on_a,
333 &msg.packet.chan_on_a,
334 &packet_denom,
335 false,
336 ) {
337 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 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 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 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 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 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 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 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
478async 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 .denom
487 .as_str()
488 .try_into()
489 .context("couldn't decode denom in ics20 transfer timeout")?;
490 let amount: Amount = packet_data
492 .amount
493 .try_into()
494 .context("couldn't decode amount in ics20 transfer timeout")?;
495
496 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 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 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, receiver: packet_data.receiver.clone(),
548 reason,
549 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 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, receiver: packet_data.receiver.clone(),
590 reason,
591 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#[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 let ack: Vec<u8> = match recv_transfer_packet_inner(&mut state, msg).await {
616 Ok(_) => {
617 TokenTransferAcknowledgement::success().into()
619 }
620 Err(e) => {
621 tracing::debug!("couldnt execute transfer: {:#}", e);
622 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 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 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 {}