Skip to content

Commit

Permalink
feat: Solana CCM fallback (#5316)
Browse files Browse the repository at this point in the history
* We can now witness when Solana CCM transaction fails to execute.

The witnessed result is now part of the SolanaElections.
On Program execution error, fallback transfer is created.

WIP: Add tests and storage migration for SolanaBroadcast and Elections

* Added integration test for CCM fallback mechanism.

* Added storage migration for sol ccm success witnessing.
Clear all egress-success election votes
Migrated Broadcaster storage to newer version of ApiCall

* Corrected Migration in Runtime.

* Call SolanaBroadcaster::egress_success() regardless of if call execution
was successful. This ensures call data are cleared in the broadcaster pallet.

* Commented out a print statement in the unit test

* renamed a member field

* Fixed bouncer test for Solana CCMs

* Fixed bouncer lint

* Added upgrade condition so we only upgrade when there are no pending
solana calls to be threshold signed
  • Loading branch information
syan095 authored Oct 18, 2024
1 parent d9253e1 commit 48dcbad
Show file tree
Hide file tree
Showing 19 changed files with 556 additions and 163 deletions.
237 changes: 117 additions & 120 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions bouncer/shared/swapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ function newSolanaCfParameters(maxAccounts: number) {
cfReceiver.is_writable ? 1 : 0,
]);

const fallbackAddrBytes = new PublicKey(getContractAddress('Solana', 'FALLBACK')).toBytes();

const remainingAccounts = [];
const numRemainingAccounts = Math.floor(Math.random() * maxAccounts);

Expand All @@ -104,6 +106,7 @@ function newSolanaCfParameters(maxAccounts: number) {
// Inserted by the codec::Encode
4 * remainingAccounts.length,
...remainingAccounts.flatMap((account) => Array.from(account)),
...fallbackAddrBytes,
]);

return arrayToHexString(cfParameters);
Expand Down
10 changes: 8 additions & 2 deletions bouncer/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ export function getContractAddress(chain: Chain, contract: string): string {
return '8pBPaVfTAcjLeNfC187Fkvi9b1XEFhRNJ95BQXXVksmH';
case 'SWAP_ENDPOINT':
return '35uYgHdfZQT4kHkaaXQ6ZdCkK5LFrsk43btTLbGCRCNT';
case 'FALLBACK':
/// 3wDSVR6YSRDFiWdwsnZZRjAKHKvsmb4fouYVqoBt5yd4vSrY7aQdvtLJKMvEb3AMWGD5fxunfdotvwPwnSChWMWx
return 'CFf51BPWnybvgbZrxy61s4SCCvEohBC7achsPLuoACUG';
default:
throw new Error(`Unsupported contract: ${contract}`);
}
Expand Down Expand Up @@ -723,7 +726,7 @@ export async function observeSolanaCcmEvent(
const remainingAccountSize = publicKeySize + 1;

// Extra byte for the encoded length
const remainingAccountsBytes = cfParameters.slice(remainingAccountSize + 1);
const remainingAccountsBytes = cfParameters.slice(remainingAccountSize + 1, -publicKeySize);

const remainingAccounts = [];
const remainingIsWritable = [];
Expand All @@ -737,7 +740,10 @@ export async function observeSolanaCcmEvent(
remainingIsWritable.push(Boolean(isWritable));
}

return { remainingAccounts, remainingIsWritable };
// fallback account
const fallbackAccount = cfParameters.slice(-publicKeySize);

return { remainingAccounts, remainingIsWritable, fallbackAccount };
}

const connection = getSolConnection();
Expand Down
9 changes: 6 additions & 3 deletions engine/src/witness/sol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,12 @@ impl VoterApi<SolanaEgressWitnessing> for SolanaEgressWitnessingVoter {
<<SolanaEgressWitnessing as ElectoralSystem>::Vote as VoteStorage>::Vote,
anyhow::Error,
> {
Ok(TransactionSuccessDetails {
tx_fee: egress_witnessing::get_finalized_fee(&self.client, signature).await?,
})
egress_witnessing::get_finalized_fee_and_success_status(&self.client, signature)
.await
.map(|(tx_fee, transaction_successful)| TransactionSuccessDetails {
tx_fee,
transaction_successful,
})
}
}

Expand Down
21 changes: 11 additions & 10 deletions engine/src/witness/sol/egress_witnessing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ use anyhow::Result;
use cf_chains::sol::{SolSignature, LAMPORTS_PER_SIGNATURE};
use itertools::Itertools;

pub async fn get_finalized_fee(
pub async fn get_finalized_fee_and_success_status(
sol_client: &SolRetryRpcClient,
signature: SolSignature,
) -> Result<u64> {
) -> Result<(u64, bool)> {
match sol_client
.get_signature_statuses(&[signature], false)
.await
Expand Down Expand Up @@ -46,12 +46,12 @@ pub async fn get_finalized_fee(
.meta;

Ok(match transaction_meta {
Some(meta) => meta.fee,
// This shouldn't happen. Want to avoid Erroring. We either default to
// 5000 or return OK(()) so we don't submit transaction_succeeded and
// retry again later. Defaulting to avoid potentially getting stuck not
// witness something because no meta is returned.
None => LAMPORTS_PER_SIGNATURE,
Some(meta) => (meta.fee, meta.err.is_none()),
// This shouldn't happen. We want to avoid Erroring.
// Therefore we return default value (5000, true) so we don't submit
// transaction_succeeded and retry again later. Also avoids potentially getting
// stuck not witness something because no meta is returned.
None => (LAMPORTS_PER_SIGNATURE, true),
})
},
Some(TransactionStatus { confirmation_status: other_status, .. }) => Err(anyhow::anyhow!(
Expand Down Expand Up @@ -102,10 +102,11 @@ mod tests {
SolSignature::from_str(
"4udChXyRXrqBxUTr9F3nbTcPyvteLJtFQ3wM35J53NdP4GWwUp2wBwdTJEYs2aiNz7DyCqitok6ci7qqHPkRByb2").unwrap();

let fee = get_finalized_fee(&client, monitored_tx_signature).await.unwrap();
let (fee, tx_successful) = get_finalized_fee_and_success_status(&client, monitored_tx_signature).await.unwrap();

println!("{:?}", fee);
println!("{:?}", (fee, tx_successful));
assert_eq!(fee, LAMPORTS_PER_SIGNATURE);
assert!(tx_successful);

Ok(())
}
Expand Down
139 changes: 134 additions & 5 deletions state-chain/cf-integration-tests/src/solana.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#![cfg(test)]

use std::marker::PhantomData;
use std::{collections::BTreeMap, marker::PhantomData};

use super::*;
use cf_chains::{
Expand All @@ -26,15 +26,23 @@ use frame_support::{
assert_err,
traits::{OnFinalize, UnfilteredDispatchable},
};
use pallet_cf_ingress_egress::DepositWitness;
use pallet_cf_elections::{
electoral_system::{ElectionIdentifierOf, ElectoralSystem},
vote_storage::{composite::tuple_6_impls::CompositeVote, AuthorityVote, VoteStorage},
MAXIMUM_VOTES_PER_EXTRINSIC,
};
use pallet_cf_ingress_egress::{DepositWitness, FetchOrTransfer};
use pallet_cf_validator::RotationPhase;
use sp_core::ConstU32;
use sp_runtime::BoundedBTreeMap;
use state_chain_runtime::{
chainflip::{
address_derivation::AddressDerivation, ChainAddressConverter, SolEnvironment,
address_derivation::AddressDerivation, solana_elections::TransactionSuccessDetails,
ChainAddressConverter, SolEnvironment,
SolanaTransactionBuilder as RuntimeSolanaTransactionBuilder,
},
Runtime, RuntimeCall, RuntimeEvent, SolanaIngressEgress, SolanaInstance, SolanaThresholdSigner,
Swapping,
Runtime, RuntimeCall, RuntimeEvent, SolanaElections, SolanaIngressEgress, SolanaInstance,
SolanaThresholdSigner, Swapping,
};

use crate::{
Expand All @@ -48,6 +56,16 @@ const ALICE: AccountId = AccountId::new([0x33; 32]);
const BOB: AccountId = AccountId::new([0x44; 32]);

const DEPOSIT_AMOUNT: u64 = 5_000_000_000u64; // 5 Sol
const FALLBACK_ADDRESS: SolAddress = SolAddress([0xf0; 32]);

type SolanaElectionVote = BoundedBTreeMap::<
ElectionIdentifierOf<<Runtime as pallet_cf_elections::Config<SolanaInstance>>::ElectoralSystem>,
AuthorityVote<
<<<Runtime as pallet_cf_elections::Config<SolanaInstance>>::ElectoralSystem as ElectoralSystem>::Vote as VoteStorage>::PartialVote,
<<<Runtime as pallet_cf_elections::Config<SolanaInstance>>::ElectoralSystem as ElectoralSystem>::Vote as VoteStorage>::Vote,
>,
ConstU32<MAXIMUM_VOTES_PER_EXTRINSIC>,
>;

fn setup_sol_environments() {
// Environment::SolanaApiEnvironment
Expand Down Expand Up @@ -449,6 +467,7 @@ fn solana_ccm_fails_with_invalid_input() {
SolCcmAddress { pubkey: SolPubkey([0x01; 32]), is_writable: false },
SolCcmAddress { pubkey: SolPubkey([0x02; 32]), is_writable: false },
],
fallback_address: FALLBACK_ADDRESS.into(),
}
.encode()
.try_into()
Expand Down Expand Up @@ -631,3 +650,113 @@ fn solana_resigning() {
}
});
}

#[test]
fn solana_ccm_execution_error_can_trigger_fallback() {
const EPOCH_BLOCKS: u32 = 100;
const MAX_AUTHORITIES: AuthorityCount = 10;
super::genesis::with_test_defaults()
.blocks_per_epoch(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();

// Trigger a CCM swap
let ccm = CcmDepositMetadata {
source_chain: ForeignChain::Ethereum,
source_address: Some(ForeignChainAddress::Eth([0xff; 20].into())),
channel_metadata: CcmChannelMetadata {
message: vec![0u8, 1u8, 2u8, 3u8].try_into().unwrap(),
gas_budget: 1_000_000_000u128,
cf_parameters: SolCcmAccounts {
cf_receiver: SolCcmAddress { pubkey: SolPubkey([0x10; 32]), is_writable: true },
remaining_accounts: vec![
SolCcmAddress { pubkey: SolPubkey([0x01; 32]), is_writable: false },
SolCcmAddress { pubkey: SolPubkey([0x02; 32]), is_writable: false },
],
fallback_address: FALLBACK_ADDRESS.into(),
}
.encode()
.try_into()
.unwrap(),
},
};
witness_call(RuntimeCall::SolanaIngressEgress(
pallet_cf_ingress_egress::Call::contract_ccm_swap_request {
source_asset: Asset::Sol,
deposit_amount: 1_000_000_000_000u128,
destination_asset: Asset::SolUsdc,
destination_address: EncodedAddress::Sol([1u8; 32]),
deposit_metadata: ccm,
tx_hash: Default::default(),
},
));

// Wait for the swaps to complete and call broadcasted.
testnet.move_forward_blocks(5);

// Get the broadcast ID for the ccm. There should be only one broadcast pending.
assert_eq!(pallet_cf_broadcast::PendingBroadcasts::<Runtime, SolanaInstance>::get().len(), 1);
let ccm_broadcast_id = pallet_cf_broadcast::PendingBroadcasts::<Runtime, SolanaInstance>::get().into_iter().next().unwrap();

// Get the election identifier of the Solana egress.
let election_id = SolanaElections::with_electoral_access_and_identifiers(
|_, election_identifiers| {
Ok(election_identifiers.last().cloned().unwrap())
},
).unwrap();

// Submit vote to witness: transaction success, but execution failure
let vote: SolanaElectionVote = BTreeMap::from_iter([(election_id,
AuthorityVote::Vote(CompositeVote::EE(TransactionSuccessDetails {
tx_fee: 1_000,
transaction_successful: false,
}))
)]).try_into().unwrap();

for v in Validator::current_authorities() {
assert_ok!(SolanaElections::stop_ignoring_my_votes(
RuntimeOrigin::signed(v.clone()),
));

assert_ok!(SolanaElections::vote(
RuntimeOrigin::signed(v),
vote.clone()
));
}

// Egress queue should be empty
assert_eq!(pallet_cf_ingress_egress::ScheduledEgressFetchOrTransfer::<Runtime, SolanaInstance>::decode_len(), Some(0));

// on_finalize: reach consensus on the egress vote and trigger the fallback mechanism.
SolanaElections::on_finalize(System::block_number() + 1);
assert_eq!(pallet_cf_ingress_egress::ScheduledEgressFetchOrTransfer::<Runtime, SolanaInstance>::decode_len(), Some(1));
assert!(matches!(pallet_cf_ingress_egress::ScheduledEgressFetchOrTransfer::<Runtime, SolanaInstance>::get()[0],
FetchOrTransfer::Transfer {
egress_id: (ForeignChain::Solana, 2),
asset: cf_chains::assets::sol::Asset::SolUsdc,
destination_address: FALLBACK_ADDRESS,
..
}
));

// Ensure the previous broadcast data has been cleaned up.
assert!(!pallet_cf_broadcast::PendingBroadcasts::<Runtime, SolanaInstance>::get().contains(&ccm_broadcast_id));
assert!(!pallet_cf_broadcast::AwaitingBroadcast::<Runtime, SolanaInstance>::contains_key(ccm_broadcast_id));
assert!(!pallet_cf_broadcast::TransactionOutIdToBroadcastId::<Runtime, SolanaInstance>::iter_values().any(|(broadcast_id, _)|broadcast_id == ccm_broadcast_id));
assert!(!pallet_cf_broadcast::PendingApiCalls::<Runtime, SolanaInstance>::contains_key(ccm_broadcast_id));
});
}
8 changes: 8 additions & 0 deletions state-chain/chains/src/ccm_checker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ mod test {
cf_parameters: SolCcmAccounts {
cf_receiver: SolCcmAddress { pubkey: SolPubkey([0x01; 32]), is_writable: true },
remaining_accounts: vec![],
fallback_address: SolPubkey([0xf0; 32]),
}
.encode()
.try_into()
Expand All @@ -146,6 +147,7 @@ mod test {
pubkey: SolPubkey([0x01; 32]),
is_writable: true,
}],
fallback_address: SolPubkey([0xf0; 32]),
}
.encode()
.try_into()
Expand All @@ -163,6 +165,7 @@ mod test {
gas_budget: 0,
cf_parameters: SolCcmAccounts {
cf_receiver: SolCcmAddress { pubkey: SolPubkey([0x01; 32]), is_writable: true },
fallback_address: SolPubkey([0xf0; 32]),
remaining_accounts: vec![],
}
.encode()
Expand All @@ -186,6 +189,7 @@ mod test {
pubkey: SolPubkey([0x01; 32]),
is_writable: true,
}],
fallback_address: SolPubkey([0xf0; 32]),
}
.encode()
.try_into()
Expand Down Expand Up @@ -259,6 +263,7 @@ mod test {
SolCcmAddress { pubkey: crate::sol::SolPubkey([0x01; 32]), is_writable: false },
SolCcmAddress { pubkey: crate::sol::SolPubkey([0x02; 32]), is_writable: false },
],
fallback_address: SolPubkey([0xf0; 32]),
};
assert_err!(
check_ccm_for_blacklisted_accounts(&ccm_accounts, blacklisted_accounts()),
Expand All @@ -277,6 +282,7 @@ mod test {
},
SolCcmAddress { pubkey: crate::sol::SolPubkey([0x02; 32]), is_writable: false },
],
fallback_address: SolPubkey([0xf0; 32]),
};
assert_err!(
check_ccm_for_blacklisted_accounts(&ccm_accounts, blacklisted_accounts()),
Expand All @@ -293,6 +299,7 @@ mod test {
SolCcmAddress { pubkey: crate::sol::SolPubkey([0x01; 32]), is_writable: false },
SolCcmAddress { pubkey: crate::sol::SolPubkey([0x02; 32]), is_writable: false },
],
fallback_address: SolPubkey([0xf0; 32]),
};
assert_err!(
check_ccm_for_blacklisted_accounts(&ccm_accounts, blacklisted_accounts()),
Expand All @@ -308,6 +315,7 @@ mod test {
SolCcmAddress { pubkey: sol_test_values::agg_key().into(), is_writable: false },
SolCcmAddress { pubkey: crate::sol::SolPubkey([0x02; 32]), is_writable: false },
],
fallback_address: SolPubkey([0xf0; 32]),
};
assert_err!(
check_ccm_for_blacklisted_accounts(&ccm_accounts, blacklisted_accounts()),
Expand Down
10 changes: 8 additions & 2 deletions state-chain/chains/src/sol/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ pub enum SolanaTransactionType {
BatchFetch,
Transfer,
RotateAggKey,
CcmTransfer,
CcmTransfer { fallback: TransferAssetParams<Solana> },
SetGovKeyWithAggKey,
}

Expand Down Expand Up @@ -302,6 +302,12 @@ impl<Environment: SolanaEnvironment> SolanaApi<Environment> {
let compute_price = Environment::compute_price()?;
let durable_nonce = Environment::nonce_account()?;

let fallback = TransferAssetParams {
asset: transfer_param.asset,
amount: transfer_param.amount,
to: ccm_accounts.fallback_address.into(),
};

// Build the transaction
let transaction = match transfer_param.asset {
SolAsset::Sol => SolanaTransactionBuilder::ccm_transfer_native(
Expand Down Expand Up @@ -363,7 +369,7 @@ impl<Environment: SolanaEnvironment> SolanaApi<Environment> {
})?;

Ok(Self {
call_type: SolanaTransactionType::CcmTransfer,
call_type: SolanaTransactionType::CcmTransfer { fallback },
transaction,
signer: None,
_phantom: Default::default(),
Expand Down
Loading

0 comments on commit 48dcbad

Please sign in to comment.