Skip to content

Commit

Permalink
feat: add fallback logic to refund failed CCM transaction (#5650)
Browse files Browse the repository at this point in the history
* chore: add ci when PR against base branch

* feat: use Solana versioned transaction to build api calls (#5633)

* Added versioned transaction and versioned message support

* Added unit test for Versioned transactions with Address lookup table

* Moved (almost) everything in sol_tx_core into sol-prim
Moved ALT related stuff to its own file.

* Initial commit for changing Solana Transaction to VersionedTransaction

* chore: update bouncer to get versioned transactions

* Updated unit tests for transaction building
Removed all uses of Legacy message and Transaction.

* Fix broken unit test

* Make solana api calls more consistent with the use of ALT

---------

Co-authored-by: Daniel <daniel@chainflip.io>
Co-authored-by: albert <allimos3@gmail.com>

* Added versioned transaction and versioned message support

* Added unit test for Versioned transactions with Address lookup table

* Moved (almost) everything in sol_tx_core into sol-prim
Moved ALT related stuff to its own file.

* Initial commit for changing Solana Transaction to VersionedTransaction

* Make solana api calls more consistent with the use of ALT

* feat: update image with alt and add logic and migrations

* chore: rebase and update

* chore: run ci

* chore: fix clippy and try removing symlink

* chore: try running anza

* chore: upgrade to 2.1.13

* chore: update bouncer expected number of nonces

* chore: update compute units

* chore: restore ci#

* chore: fix ci

* chore: nit

* chore: update test

* chore: fix tests and nit

* chore: define MAX_CCM_USER_ALTS

* Minor improvements.
Fix build and tests

* Add a fallback mechanism where if CCM failed to build (for any reason)
the fund is returned to the "refund" address via a "Transfer" transaction.

* chore: nit

* chore: fix

* chore: revert source_address

* chore: clippy

---------

Co-authored-by: albert <allimos3@gmail.com>
Co-authored-by: Daniel <daniel@chainflip.io>
  • Loading branch information
3 people authored Feb 19, 2025
1 parent 0dd6b08 commit cb41ac5
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 17 deletions.
99 changes: 97 additions & 2 deletions state-chain/cf-integration-tests/src/solana.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ use cf_chains::{
assets::{any::Asset, sol::Asset as SolAsset},
ccm_checker::{CcmValidityError, VersionedSolanaCcmAdditionalData},
sol::{
api::{SolanaApi, SolanaEnvironment, SolanaTransactionBuildingError},
api::{
SolanaApi, SolanaEnvironment, SolanaTransactionBuildingError, SolanaTransactionType,
},
sol_tx_core::sol_test_values,
transaction_builder::SolanaTransactionBuilder,
SolAddress, SolCcmAccounts, SolCcmAddress, SolHash, SolPubkey, SolanaCrypto,
},
CcmChannelMetadata, CcmDepositMetadata, Chain, ChannelRefundParameters,
ExecutexSwapAndCallError, ForeignChainAddress, RequiresSignatureRefresh, SetAggKeyWithAggKey,
SetAggKeyWithAggKeyError, Solana, SwapOrigin, TransactionBuilder,
SetAggKeyWithAggKeyError, Solana, SwapOrigin, TransactionBuilder, TransferAssetParams,
};
use cf_primitives::{AccountRole, AuthorityCount, ForeignChain, SwapRequestId};
use cf_test_utilities::{assert_events_match, assert_has_matching_event};
Expand Down Expand Up @@ -771,3 +773,96 @@ fn solana_ccm_execution_error_can_trigger_fallback() {
assert!(!pallet_cf_broadcast::PendingApiCalls::<Runtime, SolanaInstance>::contains_key(ccm_broadcast_id));
});
}

#[test]
fn solana_failed_ccm_can_trigger_refund_transfer() {
const EPOCH_BLOCKS: u32 = 100;
const MAX_AUTHORITIES: AuthorityCount = 10;
super::genesis::with_test_defaults()
.epoch_duration(EPOCH_BLOCKS)
.max_authorities(MAX_AUTHORITIES)
.with_additional_accounts(&[
(DORIS, AccountRole::LiquidityProvider, 5 * FLIPPERINOS_PER_FLIP),
(ZION, AccountRole::Broker, 5 * FLIPPERINOS_PER_FLIP),
])
.build()
.execute_with(|| {
setup_sol_environments();

let (mut testnet, _, _) = network::fund_authorities_and_join_auction(MAX_AUTHORITIES);
assert_ok!(RuntimeCall::SolanaVault(
pallet_cf_vaults::Call::<Runtime, SolanaInstance>::initialize_chain {}
)
.dispatch_bypass_filter(pallet_cf_governance::RawOrigin::GovernanceApproval.into()));
setup_pool_and_accounts(vec![Asset::Sol, Asset::SolUsdc], OrderType::LimitOrder);
testnet.move_to_the_next_epoch();

let destination_address = SolAddress([0xcf; 32]);
let asset = SolAsset::Sol;
let amount = 1_000_000_000_000u64;

// Construct a Ccm that when built will exceed the maximum length.
const NUM_ACCOUNTS: u8 = 40u8;
let ccm = CcmChannelMetadata {
message: vec![0u8, 1u8, 2u8, 3u8].try_into().unwrap(),
gas_budget: 1_000_000_000u128,
ccm_additional_data: VersionedSolanaCcmAdditionalData::V1{ccm_accounts: SolCcmAccounts {
cf_receiver: SolCcmAddress { pubkey: SolPubkey([0x10; 32]), is_writable: true },
additional_accounts: (0..NUM_ACCOUNTS).map(|i|SolCcmAddress { pubkey: SolPubkey([i; 32]), is_writable: false }).collect::<Vec<_>>(),
fallback_address: FALLBACK_ADDRESS.into(),
}, alts: vec![]}
.encode()
.try_into()
.unwrap(),
};

// This Ccm will exceed maximum size when built, triggering the fallback refund mechanism.
assert_eq!(cf_chains::sol::api::SolanaApi::<SolEnvironment>::ccm_transfer(
TransferAssetParams {
asset,
amount,
to: destination_address,
},
ForeignChain::Ethereum,
None,
ccm.gas_budget,
ccm.message.clone().to_vec(),
ccm.ccm_additional_data.clone().to_vec(),
Default::default(),
), Err(SolanaTransactionBuildingError::InvalidCcm(CcmValidityError::CcmIsTooLong)));

// Directly insert a CCM to be ingressed.
pallet_cf_ingress_egress::ScheduledEgressCcm::<Runtime, SolanaInstance>::append(pallet_cf_ingress_egress::CrossChainMessage {
egress_id: (ForeignChain::Solana, 1u64),
asset: SolAsset::Sol,
amount: 1_000_000_000_000u64,
destination_address,
message: ccm.message,
source_chain: ForeignChain::Ethereum,
source_address: None,
ccm_additional_data: ccm.ccm_additional_data,
gas_budget: ccm.gas_budget,
swap_request_id: SwapRequestId(1u64),
});

testnet.move_forward_blocks(1);

// When CCM transaction building failed, fallback to refund the asset via Transfer instead.
assert!(assert_events_match!(Runtime, RuntimeEvent::SolanaIngressEgress(pallet_cf_ingress_egress::Event::<Runtime, SolanaInstance>::InvalidCcmRefunded {
asset,
destination_address,
..
}) if asset == SolAsset::Sol && destination_address == FALLBACK_ADDRESS => true));

// Give enough time to schedule, egress and threshold-sign the transfer transaction.
testnet.move_forward_blocks(4);
let broadcast_id = pallet_cf_broadcast::BroadcastIdCounter::<Runtime, SolanaInstance>::get();

// Transfer transaction should be created against the refund address.
assert!(pallet_cf_broadcast::PendingBroadcasts::<Runtime, SolanaInstance>::get().contains(&broadcast_id));
assert!(matches!(pallet_cf_broadcast::PendingApiCalls::<Runtime, SolanaInstance>::get(broadcast_id), Some(SolanaApi {
call_type: SolanaTransactionType::Transfer,
..
})));
});
}
48 changes: 39 additions & 9 deletions state-chain/chains/src/ccm_checker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::{
},
SolAddress, SolAsset, SolCcmAccounts, SolPubkey, MAX_CCM_BYTES_SOL, MAX_CCM_BYTES_USDC,
},
CcmChannelMetadata,
CcmAdditionalData, CcmChannelMetadata, Chain, ForeignChainAddress,
};
use cf_primitives::{Asset, ForeignChain};
use codec::{Decode, Encode};
Expand Down Expand Up @@ -52,19 +52,34 @@ pub trait CcmValidityCheck {
}

fn decode_unchecked(
_ccm: &CcmChannelMetadata,
_ccm: CcmAdditionalData,
_chain: ForeignChain,
) -> Result<DecodedCcmAdditionalData, CcmValidityError> {
Ok(DecodedCcmAdditionalData::NotRequired)
}
}

#[derive(Clone, Debug, Decode, PartialEq, Eq)]
#[derive(Clone, Debug, Encode, Decode, PartialEq, Eq)]
pub enum DecodedCcmAdditionalData {
NotRequired,
Solana(VersionedSolanaCcmAdditionalData),
}

impl DecodedCcmAdditionalData {
/// Attempt to extract the fallback address from the decoded ccm additional data.
/// Will only return Some(addr) if fallback address exists and matches the target `Chain`.
pub fn refund_address<C: Chain>(&self) -> Option<C::ChainAccount> {
match self {
DecodedCcmAdditionalData::Solana(additional_data) => ForeignChainAddress::from(
SolAddress::from(additional_data.ccm_accounts().fallback_address),
)
.try_into()
.ok(),
_ => None,
}
}
}

#[derive(Clone, Debug, Encode, Decode, PartialEq, Eq)]
pub enum VersionedSolanaCcmAdditionalData {
V0(SolCcmAccounts),
Expand Down Expand Up @@ -141,6 +156,7 @@ impl CcmValidityCheck for CcmValidityChecker {
let mut accounts_length =
ccm_accounts.additional_accounts.len() * ACCOUNT_REFERENCE_LENGTH_IN_TRANSACTION;

// TODO PRO-2046: we should not check the length here?
for ccm_address in &ccm_accounts.additional_accounts {
if seen_addresses.insert(ccm_address.pubkey.into()) {
accounts_length += ACCOUNT_KEY_LENGTH_IN_TRANSACTION;
Expand Down Expand Up @@ -169,12 +185,14 @@ impl CcmValidityCheck for CcmValidityChecker {
}
}

/// Decodes the `ccm_additional_data` without any additional checks.
/// Only fail if given bytes cannot be decoded into `VersionedSolanaCcmAdditionalData`.
fn decode_unchecked(
ccm: &CcmChannelMetadata,
ccm_additional_data: CcmAdditionalData,
chain: ForeignChain,
) -> Result<DecodedCcmAdditionalData, CcmValidityError> {
if chain == ForeignChain::Solana {
VersionedSolanaCcmAdditionalData::decode(&mut &ccm.ccm_additional_data.clone()[..])
VersionedSolanaCcmAdditionalData::decode(&mut &ccm_additional_data[..])
.map(DecodedCcmAdditionalData::Solana)
.map_err(|_| CcmValidityError::CannotDecodeCcmAdditionalData)
} else {
Expand Down Expand Up @@ -596,19 +614,31 @@ mod test {
#[test]
fn can_decode_unchecked() {
let ccm = sol_test_values::ccm_parameter().channel_metadata;
assert_ok!(CcmValidityChecker::decode_unchecked(&ccm, ForeignChain::Solana));
assert_ok!(CcmValidityChecker::decode_unchecked(
ccm.ccm_additional_data.clone(),
ForeignChain::Solana
));
assert_eq!(
CcmValidityChecker::decode_unchecked(&ccm, ForeignChain::Ethereum),
CcmValidityChecker::decode_unchecked(
ccm.ccm_additional_data.clone(),
ForeignChain::Ethereum
),
Ok(DecodedCcmAdditionalData::NotRequired)
);
}

#[test]
fn can_decode_unchecked_ccm_v1() {
let ccm = sol_test_values::ccm_parameter_v1().channel_metadata;
assert_ok!(CcmValidityChecker::decode_unchecked(&ccm, ForeignChain::Solana));
assert_ok!(CcmValidityChecker::decode_unchecked(
ccm.ccm_additional_data.clone(),
ForeignChain::Solana
));
assert_eq!(
CcmValidityChecker::decode_unchecked(&ccm, ForeignChain::Ethereum),
CcmValidityChecker::decode_unchecked(
ccm.ccm_additional_data.clone(),
ForeignChain::Ethereum
),
Ok(DecodedCcmAdditionalData::NotRequired)
);
}
Expand Down
4 changes: 2 additions & 2 deletions state-chain/chains/src/sol/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -663,8 +663,8 @@ impl<Env: 'static + SolanaEnvironment> ExecutexSwapAndCall<Solana> for SolanaApi
Self::ccm_transfer(
transfer_param,
source_chain,
// Hardcoding this to None to gain extra bytes in Solana.
// Revert this when we implement versioned Transactions.
// Hardcoding this to None to gain extra bytes in Solana. Consider
// reverting this when we implement versioned Transactions. PRO-2046.
None,
gas_budget,
message,
Expand Down
42 changes: 38 additions & 4 deletions state-chain/pallets/cf-ingress-egress/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,11 @@ pub mod pallet {
NetworkFeeDeductionFromBoostSet {
deduction_percent: Percent,
},
InvalidCcmRefunded {
asset: TargetChainAsset<T, I>,
amount: TargetChainAmount<T, I>,
destination_address: TargetChainAccount<T, I>,
},
}

#[derive(CloneNoBound, PartialEqNoBound, EqNoBound)]
Expand Down Expand Up @@ -1761,10 +1766,39 @@ impl<T: Config<I>, I: 'static> Pallet<T, I> {
egress_id: ccm.egress_id,
});
},
Err(error) => Self::deposit_event(Event::<T, I>::CcmEgressInvalid {
egress_id: ccm.egress_id,
error,
}),
Err(error) => {
log::warn!("Failed to construct CCM. Fund will be refunded to the fallback refund address. swap_request_id: {:?}, Error: {:?}", ccm.swap_request_id, error);

Self::deposit_event(Event::<T, I>::CcmEgressInvalid {
egress_id: ccm.egress_id,
error,
});

if let Ok(decoded_data) = T::CcmValidityChecker::decode_unchecked(
ccm.ccm_additional_data.clone(),
T::TargetChain::get(),
) {
if let Some(fallback_address) =
decoded_data.refund_address::<T::TargetChain>()
{
match Self::schedule_egress(
ccm.asset,
ccm.amount,
fallback_address.clone(),
None,
) {
Ok(egress_details) => Self::deposit_event(Event::<T, I>::InvalidCcmRefunded {
asset: ccm.asset,
amount: egress_details.egress_amount,
destination_address: fallback_address,
}),
Err(e) => log::warn!("Cannot refund failed Ccm: failed to Egress. swap_request_id: {:?}, Error: {:?}", ccm.swap_request_id, e),
};
}
} else {
log::warn!("Cannot refund failed Ccm: failed to decode `ccm_additional_data`. swap_request_id: {:?}", ccm.swap_request_id);
}
},
};
}
}
Expand Down

0 comments on commit cb41ac5

Please sign in to comment.