Skip to content

Commit

Permalink
IBC Hooks: add hooks to call bridge escrow program (#403)
Browse files Browse the repository at this point in the history
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 <mina86@mina86.com>
  • Loading branch information
dhruvja and mina86 authored Nov 1, 2024
1 parent ba1046e commit 64d90cd
Show file tree
Hide file tree
Showing 5 changed files with 186 additions and 8 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions solana/solana-ibc/programs/solana-ibc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions solana/solana-ibc/programs/solana-ibc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))]
Expand Down Expand Up @@ -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<SendTransfer>,
pub fn send_transfer<'a, 'info>(
ctx: Context<'a, 'a, 'a, 'info, SendTransfer<'info>>,
hashed_full_denom: CryptoHash,
msg: ibc::MsgTransfer,
) -> Result<()> {
Expand Down
5 changes: 5 additions & 0 deletions solana/solana-ibc/programs/solana-ibc/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,9 @@ pub struct TransferAccounts<'a> {
pub mint_authority: Option<AccountInfo<'a>>,
pub token_program: Option<AccountInfo<'a>>,
pub fee_collector: Option<AccountInfo<'a>>,
/// Contains the list of accounts required for the hooks
/// if present
pub remaining_accounts: Vec<AccountInfo<'a>>,
}

#[derive(Debug)]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}};
Expand Down
174 changes: 168 additions & 6 deletions solana/solana-ibc/programs/solana-ibc/src/transfer/mod.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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::<ibc::AcknowledgementStatus>(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)
}

Expand Down Expand Up @@ -365,3 +388,142 @@ impl From<FtPacketData> 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::<PacketData>(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:
// "<accounts count>,<AccountKey1> ..... <AccountKeyN>,<intent_id>,<memo>"
//
// 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 "<namespace>:<function_name>" 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());
}

0 comments on commit 64d90cd

Please sign in to comment.