From 64d90cd338309484602f40113bf4240eef343bb4 Mon Sep 17 00:00:00 2001 From: Dhruv D Jain Date: Fri, 1 Nov 2024 22:17:11 +0530 Subject: [PATCH] IBC Hooks: add hooks to call bridge escrow program (#403) A message is sent through IBC to unlock the funds of the solver. To achieve this, the solana-ibc program needs to forward the memo to the `bridge-escrow` program so that it can release the funds to the solver. Since anybody can send memo, we execute the hook only if the transferred token is the token owned by the `bridge-escrow` on the counterparty chain (ethereum). This allows us to verify that the message to unlock the funds originated from the right contract and it is not spoofed. --------- Co-authored-by: Michal Nazarewicz --- Cargo.lock | 1 + .../solana-ibc/programs/solana-ibc/Cargo.toml | 5 + .../solana-ibc/programs/solana-ibc/src/lib.rs | 9 +- .../programs/solana-ibc/src/storage.rs | 5 + .../programs/solana-ibc/src/transfer/mod.rs | 174 +++++++++++++++++- 5 files changed, 186 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8084abd5..790e3a0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5724,6 +5724,7 @@ dependencies = [ "serde", "serde_json", "solana-allocator", + "solana-program 1.17.31", "solana-signature-verifier", "solana-trie", "solana-write-account", diff --git a/solana/solana-ibc/programs/solana-ibc/Cargo.toml b/solana/solana-ibc/programs/solana-ibc/Cargo.toml index 63771bae..e0251617 100644 --- a/solana/solana-ibc/programs/solana-ibc/Cargo.toml +++ b/solana/solana-ibc/programs/solana-ibc/Cargo.toml @@ -35,6 +35,11 @@ primitive-types.workspace = true prost.workspace = true serde.workspace = true serde_json.workspace = true +# We normally access solana_program via anchor_lang but to support +# pubkey! macro we need to have solana_program as direct dependency. +# TODO(mina86): Remove this once we upgrade Anchor to version with its +# own pubkey! macro. +solana-program.workspace = true spl-associated-token-account.workspace = true spl-token.workspace = true strum.workspace = true diff --git a/solana/solana-ibc/programs/solana-ibc/src/lib.rs b/solana/solana-ibc/programs/solana-ibc/src/lib.rs index 49f8fb36..a110f788 100644 --- a/solana/solana-ibc/programs/solana-ibc/src/lib.rs +++ b/solana/solana-ibc/programs/solana-ibc/src/lib.rs @@ -35,6 +35,11 @@ pub const WSOL_ADDRESS: &str = "So11111111111111111111111111111111111111112"; pub const MINIMUM_FEE_ACCOUNT_BALANCE: u64 = solana_program::native_token::LAMPORTS_PER_SOL; +pub const BRIDGE_ESCROW_PROGRAM_ID: Pubkey = + solana_program::pubkey!("AhfoGVmS19tvkEG2hBuZJ1D6qYEjyFmXZ1qPoFD6H4Mj"); +pub const HOOK_TOKEN_ADDRESS: &str = + "0x36dd1bfe89d409f869fabbe72c3cf72ea8b460f6"; + declare_id!("2HLLVco5HvwWriNbUhmVwA2pCetRkpgrqwnjcsZdyTKT"); #[cfg(not(feature = "mocks"))] @@ -472,8 +477,8 @@ pub mod solana_ibc { /// doesnt exists. /// /// Would panic if it doesnt match the one that is in the packet - pub fn send_transfer( - ctx: Context, + pub fn send_transfer<'a, 'info>( + ctx: Context<'a, 'a, 'a, 'info, SendTransfer<'info>>, hashed_full_denom: CryptoHash, msg: ibc::MsgTransfer, ) -> Result<()> { diff --git a/solana/solana-ibc/programs/solana-ibc/src/storage.rs b/solana/solana-ibc/programs/solana-ibc/src/storage.rs index 1180f864..f9d1d60a 100644 --- a/solana/solana-ibc/programs/solana-ibc/src/storage.rs +++ b/solana/solana-ibc/programs/solana-ibc/src/storage.rs @@ -473,6 +473,9 @@ pub struct TransferAccounts<'a> { pub mint_authority: Option>, pub token_program: Option>, pub fee_collector: Option>, + /// Contains the list of accounts required for the hooks + /// if present + pub remaining_accounts: Vec>, } #[derive(Debug)] @@ -544,6 +547,7 @@ macro_rules! from_ctx { }; ($ctx:expr, with accounts) => {{ let accounts = &$ctx.accounts; + let remaining_accounts = &$ctx.remaining_accounts; let accounts = TransferAccounts { sender: Some(accounts.sender.as_ref().to_account_info()), receiver: accounts @@ -574,6 +578,7 @@ macro_rules! from_ctx { .fee_collector .as_deref() .map(ToAccountInfo::to_account_info), + remaining_accounts: remaining_accounts.to_vec() }; $crate::storage::from_ctx!($ctx, accounts = accounts) }}; diff --git a/solana/solana-ibc/programs/solana-ibc/src/transfer/mod.rs b/solana/solana-ibc/programs/solana-ibc/src/transfer/mod.rs index 9cfb3562..eb9434fe 100644 --- a/solana/solana-ibc/programs/solana-ibc/src/transfer/mod.rs +++ b/solana/solana-ibc/programs/solana-ibc/src/transfer/mod.rs @@ -1,13 +1,15 @@ use std::result::Result; -use std::str; +use std::str::{self, FromStr}; use anchor_lang::prelude::*; use serde::{Deserialize, Serialize}; +use spl_token::solana_program::instruction::Instruction; +use spl_token::solana_program::program::invoke; -use crate::ibc; use crate::ibc::apps::transfer::types::packet::PacketData; use crate::ibc::apps::transfer::types::proto::transfer::v2::FungibleTokenPacketData; use crate::storage::IbcStorage; +use crate::{ibc, BRIDGE_ESCROW_PROGRAM_ID, HOOK_TOKEN_ADDRESS}; pub(crate) mod impls; @@ -142,13 +144,34 @@ impl ibc::Module for IbcStorage<'_, '_> { .into_bytes(), ..packet.clone() }; - let (extras, ack) = ibc::apps::transfer::module::on_recv_packet_execute( - self, - &maybe_ft_packet, - ); + let (extras, mut ack) = + ibc::apps::transfer::module::on_recv_packet_execute( + self, + &maybe_ft_packet, + ); let ack_status = str::from_utf8(ack.as_bytes()) .expect("Invalid acknowledgement string"); msg!("ibc::Packet acknowledgement: {}", ack_status); + + let status = + serde_json::from_str::(ack_status); + let success = if let Ok(status) = status { + status.is_successful() + } else { + let status = ibc::TokenTransferError::AckDeserialization.into(); + ack = ibc::AcknowledgementStatus::error(status).into(); + false + }; + + if success { + let store = self.borrow(); + let accounts = &store.accounts.remaining_accounts; + let result = call_bridge_escrow(accounts, &maybe_ft_packet.data); + if let Err(status) = result { + ack = status.into(); + } + } + (extras, ack) } @@ -365,3 +388,142 @@ impl From for FungibleTokenPacketData { } } } + +/// Calls bridge escrow after receiving packet if necessary. +/// +/// If the packet is for a [`HOOK_TOKEN_ADDRESS`] token, parses the transfer +/// memo and invokes bridge escrow contract with instruction encoded in it. +/// (see [`parse_bridge_memo`] for format of the memo). +fn call_bridge_escrow( + accounts: &[AccountInfo], + data: &[u8], +) -> Result<(), ibc::AcknowledgementStatus> { + // Perform hooks + let data = serde_json::from_slice::(data).map_err(|_| { + ibc::AcknowledgementStatus::error( + ibc::TokenTransferError::PacketDataDeserialization.into(), + ) + })?; + + // The hook would only be called if the transferred token is the one we are + // interested in + if data.token.denom.base_denom.as_str() != HOOK_TOKEN_ADDRESS { + return Ok(()); + } + + // The memo is a string and the structure is as follow: + // ", ..... ,," + // + // The relayer would parse the memo and pass the relevant accounts. + // + // The intent_id and memo needs to be stripped so that it can be sent to the + // bridge escrow contract. + let (intent_id, memo) = + parse_bridge_memo(data.memo.as_ref()).ok_or_else(|| { + let err = ibc::TokenTransferError::Other("Invalid memo".into()); + ibc::AcknowledgementStatus::error(err.into()) + })?; + + // This is the 8 byte discriminant since the program is written in + // anchor. it is hash of ":" which is + // "global:on_receive_transfer" in our case. + const INSTRUCTION_DISCRIMINANT: [u8; 8] = + [149, 112, 68, 208, 4, 206, 248, 125]; + + // Serialize the intent id and memo with borsh since the destination contract + // is written with anchor and expects the data to be in borsh encoded. + let instruction_data = [ + &INSTRUCTION_DISCRIMINANT[..], + &intent_id.try_to_vec().unwrap(), + &memo.try_to_vec().unwrap(), + ] + .concat(); + + let account_metas = accounts + .iter() + .map(|account| AccountMeta { + pubkey: *account.key, + is_signer: account.is_signer, + is_writable: account.is_writable, + }) + .collect(); + let instruction = Instruction::new_with_bytes( + BRIDGE_ESCROW_PROGRAM_ID, + &instruction_data, + account_metas, + ); + + invoke(&instruction, accounts).map_err(|err| { + ibc::AcknowledgementStatus::error( + ibc::TokenTransferError::Other(err.to_string()).into(), + ) + })?; + msg!("Hook: Bridge escrow call successful"); + Ok(()) +} + +/// Parses memo of a transaction directed at the bridge escrow. +/// +/// Memo is comma separated list of the form +/// `N,account-0,account-1,...,account-N-1,intent-id,embedded-memo`. Embedded +/// memo can contain commas. Returns `intent-id` and `embedded-memo` or `None` +/// if the memo does not conform to this format. Note that no validation on +/// accounts is performed. +fn parse_bridge_memo(memo: &str) -> Option<(&str, &str)> { + let (count, mut memo) = memo.split_once(',')?; + // Skip accounts + for _ in 0..usize::from_str(count).ok()? { + let (_, rest) = memo.split_once(',')?; + memo = rest + } + memo.split_once(',') +} + +#[test] +fn test_parse_bridge_memo() { + for (intent, memo, data) in [ + ("intent", "memo", "0,intent,memo"), + ("intent", "memo,with,comma", "0,intent,memo,with,comma"), + ("intent", "memo", "1,account0,intent,memo"), + ("intent", "memo", "3,account0,account1,account2,intent,memo"), + ("intent", "memo,comma", "1,account0,intent,memo,comma"), + ("intent", "", "1,account0,intent,"), + ("", "memo", "1,account0,,memo"), + ("", "", "1,account0,,"), + ] { + assert_eq!( + Some((intent, memo)), + parse_bridge_memo(data), + "memo: {data}" + ); + } + + for data in [ + "-1,intent,memo", + "foo,intent,memo", + ",intent,memo", + "1,account0,intent", + ] { + assert!(parse_bridge_memo(data).is_none(), "memo: {data}"); + } +} + +#[test] +fn test_memo() { + let memo = "8,WdFwv2TiGksf6x5CCwC6Svrz6JYzgCw4P1MC4Kcn3UE,\ + 7BgBvyjrZX1YKz4oh9mjb8ZScatkkwb8DzFx7LoiVkM3,\ + XSUoLRkKahnVkrVteuJuLcPuhn2uPecFHM3zCcgsAQs,\ + 8q4qp8hMSfUZZcetiJrW7jD9n4pWmSA8ua19CcdT6p3H,\ + Sysvar1nstructions1111111111111111111111111,\ + TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA,\ + H77KMAJhXEq82LmCNckaUHmXXU1RTUh5FePLVD9UAHUh,\ + FFFhqkq4DKhdeGeLqsi72u7g8GqdgQyrqu4mdRo9kKDt,100000,false,\ + 0x0362110922F923B57b7EfF68eE7A51827b2dF4b4,\ + 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,\ + 0xd41fb9e1dA5255dD994b029bC3C7e06ea8105BF3,1000000"; + let (intent_id, memo) = parse_bridge_memo(memo).unwrap(); + println!("intent_id: {intent_id}"); + println!("memo: {memo}"); + let parts: Vec<&str> = memo.split(',').collect(); + println!("parts: {:?}", parts.len()); +}