diff --git a/chains/solana/contracts/programs/ccip-router/src/fee_quoter.rs b/chains/solana/contracts/programs/ccip-router/src/fee_quoter.rs index b55f706f3..2f507a87b 100644 --- a/chains/solana/contracts/programs/ccip-router/src/fee_quoter.rs +++ b/chains/solana/contracts/programs/ccip-router/src/fee_quoter.rs @@ -1,56 +1,226 @@ +use std::ops::AddAssign; + use anchor_lang::prelude::*; use anchor_spl::{token::spl_token::native_mint, token_interface}; use ethnum::U256; use solana_program::{program::invoke_signed, system_instruction}; use crate::{ - BillingTokenConfig, CcipRouterError, DestChain, Solana2AnyMessage, SolanaTokenAmount, - UnpackedDoubleU224, FEE_BILLING_SIGNER_SEEDS, + utils::{Exponential, Usd18Decimals}, + BillingTokenConfig, CcipRouterError, DestChain, PerChainPerTokenConfig, Solana2AnyMessage, + SolanaTokenAmount, UnpackedDoubleU224, CCIP_LOCK_OR_BURN_V1_RET_BYTES, + FEE_BILLING_SIGNER_SEEDS, }; -// TODO change args and implement +/// Any2EVMRampMessage struct has 10 fields, including 3 variable unnested arrays (data, receiver and tokenAmounts). +/// Each variable array takes 1 more slot to store its length. +/// When abi encoded, excluding array contents, +/// Any2EVMMessage takes up a fixed number of 13 slots, 32 bytes each. +/// For structs that contain arrays, 1 more slot is added to the front, reaching a total of 14. +/// The fixed bytes does not cover struct data (this is represented by ANY_2_EVM_MESSAGE_FIXED_BYTES_PER_TOKEN) +pub const ANY_2_EVM_MESSAGE_FIXED_BYTES: U256 = U256::new(32 * 14); + +/// Each token transfer adds 1 RampTokenAmount +/// RampTokenAmount has 5 fields, 2 of which are bytes type, 1 Address, 1 uint256 and 1 uint32. +/// Each bytes type takes 1 slot for length, 1 slot for data and 1 slot for the offset. +/// address +/// uint256 amount takes 1 slot. +/// uint32 destGasAmount takes 1 slot. +pub const ANY_2_EVM_MESSAGE_FIXED_BYTES_PER_TOKEN: U256 = U256::new(32 * ((2 * 3) + 3)); + pub fn fee_for_msg( _dest_chain_selector: u64, message: &Solana2AnyMessage, dest_chain: &DestChain, - token_config: &BillingTokenConfig, + fee_token_config: &BillingTokenConfig, + additional_token_configs: &[Option], + additional_token_configs_for_dest_chain: &[PerChainPerTokenConfig], ) -> Result { - // TODO: Add all validations from lib.rs over the message here as well - message.validate(dest_chain, token_config)?; - - let token = if message.fee_token == Pubkey::default() { + let fee_token = if message.fee_token == Pubkey::default() { native_mint::ID // Wrapped SOL } else { message.fee_token }; + require!( + additional_token_configs.len() == message.token_amounts.len(), + CcipRouterError::InvalidInputsMissingTokenConfig + ); + require!( + additional_token_configs_for_dest_chain.len() == message.token_amounts.len(), + CcipRouterError::InvalidInputsMissingTokenConfig + ); + message.validate(dest_chain, fee_token_config)?; + + let fee_token_price = get_validated_token_price(fee_token_config)?; + let PackedPrice { + execution_gas_price, + data_availability_gas_price, + } = get_validated_gas_price(dest_chain)?; + + let network_fee = network_fee( + message, + dest_chain, + additional_token_configs, + additional_token_configs_for_dest_chain, + )?; - let token_price = get_validated_token_price(token_config)?; - let _packed_gas_price = get_validated_gas_price(dest_chain)?; + // TODO consider extra args + let execution_gas = U256::new(dest_chain.config.dest_gas_overhead as u128) + + U256::new(message.data.len() as u128) + * U256::new(dest_chain.config.dest_gas_per_payload_byte as u128) + + network_fee.transfer_gas; - // TODO un-hardcode - let network_fee = U256::new(1); - let execution_cost = U256::new(1); - let data_availability_cost = U256::new(1); + let execution_cost = execution_gas_price + * execution_gas + * U256::new(dest_chain.config.gas_multiplier_wei_per_eth as u128); - let amount = (network_fee + execution_cost + data_availability_cost) / token_price; - let amount: u64 = amount - .try_into() - .map_err(|_| CcipRouterError::InvalidTokenPrice)?; + let data_availability_cost = data_availability_cost( + data_availability_gas_price, + message, + network_fee.transfer_bytes_overhead, + dest_chain, + ); - Ok(SolanaTokenAmount { amount, token }) + let premium_multiplier = U256::new(fee_token_config.premium_multiplier_wei_per_eth.into()); + let fee_token_value = + (network_fee.premium * premium_multiplier) + execution_cost + data_availability_cost; + SolanaTokenAmount::amount(fee_token, fee_token_value, fee_token_price) +} + +fn data_availability_cost( + data_availability_gas_price: Usd18Decimals, + message: &Solana2AnyMessage, + token_transfer_bytes_overhead: U256, + dest_chain: &DestChain, +) -> Usd18Decimals { + // Sums up byte lengths of fixed message fields and dynamic message fields. + // Fixed message fields do account for the offset and length slot of the dynamic fields. + let data_availability_length_bytes = ANY_2_EVM_MESSAGE_FIXED_BYTES + + U256::new(message.data.len() as u128) + + (U256::new(message.token_amounts.len() as u128) + * ANY_2_EVM_MESSAGE_FIXED_BYTES_PER_TOKEN) + + token_transfer_bytes_overhead; + + // dest_data_availability_overhead_gas is a separate config value for flexibility to be updated + // independently of message cost. Its value is determined by CCIP lane implementation, e.g. + // the overhead data posted for OCR. + let data_availability_gas = data_availability_length_bytes + * U256::new(dest_chain.config.dest_gas_per_data_availability_byte as u128) + + U256::new(dest_chain.config.dest_data_availability_overhead_gas as u128); + + // data_availability_gas_price is in 18 decimals, dest_data_availability_multiplier_bps is in 4 decimals + // We pad 14 decimals to bring the result to 36 decimals, in line with token bps and execution fee. + data_availability_gas_price + * data_availability_gas + * U256::new(dest_chain.config.dest_data_availability_multiplier_bps as u128) + * 1u32.e(14) +} + +#[derive(Clone, Default, Debug)] +struct NetworkFee { + premium: Usd18Decimals, + transfer_gas: U256, + transfer_bytes_overhead: U256, +} + +impl AddAssign for NetworkFee { + fn add_assign(&mut self, rhs: Self) { + self.premium += rhs.premium; + self.transfer_gas += rhs.transfer_gas; + self.transfer_bytes_overhead += rhs.transfer_bytes_overhead; + } +} + +fn network_fee( + message: &Solana2AnyMessage, + dest_chain: &DestChain, + token_configs: &[Option], + token_configs_for_dest_chain: &[PerChainPerTokenConfig], +) -> Result { + if message.token_amounts.is_empty() { + return Ok(NetworkFee { + premium: Usd18Decimals::from_usd_cents(dest_chain.config.network_fee_usdcents), + transfer_gas: U256::ZERO, + transfer_bytes_overhead: U256::ZERO, + }); + } + + let mut fee = NetworkFee::default(); + + for (i, token_amount) in message.token_amounts.iter().enumerate() { + let config_for_dest_chain = &token_configs_for_dest_chain[i]; + let token_network_fee = if config_for_dest_chain.billing.is_enabled { + token_network_fees(&token_configs[i], token_amount, config_for_dest_chain)? + } else { + // If the token has no specific overrides configured, we use the global defaults. + global_network_fees(dest_chain) + }; + + fee += token_network_fee; + } + + Ok(fee) +} + +fn token_network_fees( + billing_config: &Option, + token_amount: &SolanaTokenAmount, + config_for_dest_chain: &PerChainPerTokenConfig, +) -> Result { + let bps_fee = match billing_config { + Some(config) if config_for_dest_chain.billing.deci_bps > 0 => { + let token_price = get_validated_token_price(config)?; + // Calculate token transfer value, then apply fee ratio + // ratio represents multiples of 0.1bps, or 1e-5 + Usd18Decimals( + (token_amount.value(&token_price).0 + * U256::new(config_for_dest_chain.billing.deci_bps.into())) + / 1u32.e(5), + ) + } + _ => Usd18Decimals::ZERO, + }; + + let min_fee = Usd18Decimals::from_usd_cents(config_for_dest_chain.billing.min_fee_usdcents); + let max_fee = Usd18Decimals::from_usd_cents(config_for_dest_chain.billing.max_fee_usdcents); + let (premium, token_transfer_gas, token_transfer_bytes_overhead) = ( + bps_fee.clamp(min_fee, max_fee), + U256::new(config_for_dest_chain.billing.dest_gas_overhead.into()), + U256::new(config_for_dest_chain.billing.dest_bytes_overhead.into()), + ); + Ok(NetworkFee { + premium, + transfer_gas: token_transfer_gas, + transfer_bytes_overhead: token_transfer_bytes_overhead, + }) +} + +fn global_network_fees(dest_chain: &DestChain) -> NetworkFee { + let (premium, global_gas, global_overhead) = ( + Usd18Decimals::from_usd_cents(dest_chain.config.default_token_fee_usdcents.into()), + U256::new(dest_chain.config.default_token_dest_gas_overhead.into()), + U256::new(CCIP_LOCK_OR_BURN_V1_RET_BYTES.into()), + ); + NetworkFee { + premium, + transfer_gas: global_gas, + transfer_bytes_overhead: global_overhead, + } } #[allow(dead_code)] pub struct PackedPrice { - pub execution_cost: u128, - pub gas_price: u128, + // L1 gas price (encoded in the lower 112 bits) + pub execution_gas_price: Usd18Decimals, + // L2 gas price (encoded in the higher 112 bits) + pub data_availability_gas_price: Usd18Decimals, } impl From for PackedPrice { fn from(value: UnpackedDoubleU224) -> Self { Self { - execution_cost: value.low, - gas_price: value.high, + execution_gas_price: Usd18Decimals(value.low.into()), + data_availability_gas_price: Usd18Decimals(value.high.into()), } } } @@ -69,7 +239,7 @@ fn get_validated_gas_price(dest_chain: &DestChain) -> Result { Ok(price) } -fn get_validated_token_price(token_config: &BillingTokenConfig) -> Result { +fn get_validated_token_price(token_config: &BillingTokenConfig) -> Result { let timestamp = token_config.usd_per_token.timestamp; let price = token_config.usd_per_token.as_single(); @@ -81,7 +251,7 @@ fn get_validated_token_price(token_config: &BillingTokenConfig) -> Result CcipRouterError::InvalidTokenPrice ); - Ok(price) + Ok(Usd18Decimals(price)) } pub fn wrap_native_sol<'info>( @@ -148,7 +318,10 @@ mod tests { }; use super::*; - use crate::tests::{sample_billing_config, sample_dest_chain, sample_message}; + use crate::{ + tests::{sample_billing_config, sample_dest_chain, sample_message}, + TimestampedPackedU224, TokenBilling, + }; struct TestStubs; @@ -169,21 +342,302 @@ mod tests { &sample_message(), &sample_dest_chain(), &sample_billing_config(), + &[], + &[] ) .unwrap(), SolanaTokenAmount { token: native_mint::ID, - amount: 1 + amount: 995200000000000000 } ); } + #[test] + fn network_fee_config_is_reflected_on_fee_retrieval() { + set_syscall_stubs(Box::new(TestStubs)); + let mut chain = sample_dest_chain(); + chain.config.network_fee_usdcents *= 12; + assert_eq!( + fee_for_msg( + 0, + &sample_message(), + &chain, + &sample_billing_config(), + &[], + &[] + ) + .unwrap(), + SolanaTokenAmount { + token: native_mint::ID, + // Increases proportionally to the network fee component of the sum + amount: 4661866666666666666 + } + ); + } + + #[test] + fn network_fee_for_an_unsupported_token_fails() { + let mut message = sample_message(); + message.token_amounts = vec![SolanaTokenAmount { + token: Pubkey::new_unique(), + amount: 1, + }]; + set_syscall_stubs(Box::new(TestStubs)); + assert_eq!( + fee_for_msg( + 0, + &message, + &sample_dest_chain(), + &sample_billing_config(), + &[], + &[] + ) + .unwrap_err(), + CcipRouterError::InvalidInputsMissingTokenConfig.into() + ); + } + + #[test] + fn network_fee_for_a_supported_token_with_disabled_billing() { + let mut chain = sample_dest_chain(); + + // Will have no effect because we're not using the network fee + chain.config.network_fee_usdcents *= 0; + + let (token_config, mut per_chain_per_token) = sample_additional_token(); + + // Not enabled == no overrides + per_chain_per_token.billing.is_enabled = false; + + // Will have no effect since billing overrides are disabled + per_chain_per_token.billing.min_fee_usdcents = 0; + per_chain_per_token.billing.max_fee_usdcents = 0; + + let mut message = sample_message(); + message.token_amounts = vec![SolanaTokenAmount { + token: per_chain_per_token.mint, + amount: 1, + }]; + set_syscall_stubs(Box::new(TestStubs)); + assert_eq!( + fee_for_msg( + 0, + &message, + &chain, + &sample_billing_config(), + &[Some(token_config)], + &[per_chain_per_token] + ) + .unwrap(), + SolanaTokenAmount { + token: native_mint::ID, + amount: 1229866666666666666 + } + ); + } + + #[test] + fn network_fee_for_a_supported_token_with_enabled_billing() { + let mut chain = sample_dest_chain(); + + // Will have no effect because we're not using the network fee + chain.config.network_fee_usdcents *= 0; + let (another_token_config, mut another_per_chain_per_token_config) = + sample_additional_token(); + + another_per_chain_per_token_config.billing.min_fee_usdcents = 800; + another_per_chain_per_token_config.billing.max_fee_usdcents = 1600; + + let mut message = sample_message(); + message.token_amounts = vec![SolanaTokenAmount { + token: another_token_config.mint, + amount: 1, + }]; + set_syscall_stubs(Box::new(TestStubs)); + assert_eq!( + fee_for_msg( + 0, + &message, + &chain, + &sample_billing_config(), + &[Some(another_token_config)], + &[another_per_chain_per_token_config] + ) + .unwrap(), + SolanaTokenAmount { + token: native_mint::ID, + // Increases proportionally to the min_fee + amount: 3539733333333333333 + } + ); + } + + #[test] + fn network_fee_for_a_supported_token_with_bps() { + let mut chain = sample_dest_chain(); + + // Will have no effect because we're not using the network fee + chain.config.network_fee_usdcents *= 0; + let (another_token_config, mut another_per_chain_per_token_config) = + sample_additional_token(); + + another_per_chain_per_token_config.billing.deci_bps = 100; + + let mut message = sample_message(); + message.token_amounts = vec![SolanaTokenAmount { + token: another_token_config.mint, + amount: 15_000_000_000_000_000, + }]; + set_syscall_stubs(Box::new(TestStubs)); + assert_eq!( + fee_for_msg( + 0, + &message, + &chain, + &sample_billing_config(), + &[Some(another_token_config)], + &[another_per_chain_per_token_config] + ) + .unwrap(), + SolanaTokenAmount { + token: native_mint::ID, + amount: 923066666666666666 + } + ); + } + + #[test] + fn network_fee_for_a_supported_token_with_no_fee_token_config() { + let mut chain = sample_dest_chain(); + + chain.config.network_fee_usdcents *= 0; + let (_, mut another_per_chain_per_token_config) = sample_additional_token(); + + // Will have no effect, as we cannot know the price of the token + another_per_chain_per_token_config.billing.deci_bps = 100; + + let mut message = sample_message(); + message.token_amounts = vec![SolanaTokenAmount { + token: another_per_chain_per_token_config.mint, + amount: 15_000_000_000_000_000, + }]; + set_syscall_stubs(Box::new(TestStubs)); + assert_eq!( + fee_for_msg( + 0, + &message, + &chain, + &sample_billing_config(), + &[None], + &[another_per_chain_per_token_config.clone()] + ) + .unwrap(), + SolanaTokenAmount { + token: native_mint::ID, + amount: 906400000000000000 + } + ); + + // Will have no effect, as we cannot know the price of the token + another_per_chain_per_token_config.billing.deci_bps = 2500; + + assert_eq!( + fee_for_msg( + 0, + &message, + &chain, + &sample_billing_config(), + &[None], + &[another_per_chain_per_token_config.clone()] + ) + .unwrap(), + SolanaTokenAmount { + token: native_mint::ID, + amount: 906400000000000000 + } + ); + } + + #[test] + fn network_fee_for_multiple_tokens() { + let (tokens, per_chains): (Vec<_>, Vec<_>) = + (0..4).map(|_| sample_additional_token()).unzip(); + + let mut message = sample_message(); + message.token_amounts = tokens + .iter() + .map(|t| SolanaTokenAmount { + token: t.mint, + amount: 1, + }) + .collect(); + + let tokens: Vec<_> = tokens.into_iter().map(|t| Some(t)).collect(); + let per_chains: Vec<_> = per_chains.into_iter().collect(); + set_syscall_stubs(Box::new(TestStubs)); + assert_eq!( + fee_for_msg( + 0, + &message, + &sample_dest_chain(), + &sample_billing_config(), + &tokens, + &per_chains + ) + .unwrap(), + SolanaTokenAmount { + token: native_mint::ID, + // Increases proportionally to the number of tokens + amount: 1640000000000000000 + } + ); + } + + fn sample_additional_token() -> (BillingTokenConfig, PerChainPerTokenConfig) { + let mint = Pubkey::new_unique(); + let mut usd_per_token = [0u8; 28]; + usd_per_token.clone_from_slice(&1_000_000u32.e(16).to_be_bytes()[4..]); + ( + BillingTokenConfig { + enabled: true, + mint, + usd_per_token: TimestampedPackedU224 { + value: usd_per_token, + timestamp: 100, + }, + premium_multiplier_wei_per_eth: 1, + }, + PerChainPerTokenConfig { + version: 1, + chain_selector: 0, + mint, + billing: TokenBilling { + min_fee_usdcents: 10, + max_fee_usdcents: 20, + deci_bps: 0, + dest_gas_overhead: 0, + dest_bytes_overhead: 0, + is_enabled: true, + }, + }, + ) + } + #[test] fn fee_cannot_be_retrieved_when_token_price_is_not_timestamped() { let mut billing_config = sample_billing_config(); billing_config.usd_per_token.timestamp = 0; assert_eq!( - fee_for_msg(0, &sample_message(), &sample_dest_chain(), &billing_config).unwrap_err(), + fee_for_msg( + 0, + &sample_message(), + &sample_dest_chain(), + &billing_config, + &[], + &[] + ) + .unwrap_err(), CcipRouterError::InvalidTokenPrice.into() ); } @@ -193,7 +647,15 @@ mod tests { let mut billing_config = sample_billing_config(); billing_config.usd_per_token.value = [0u8; 28]; assert_eq!( - fee_for_msg(0, &sample_message(), &sample_dest_chain(), &billing_config).unwrap_err(), + fee_for_msg( + 0, + &sample_message(), + &sample_dest_chain(), + &billing_config, + &[], + &[] + ) + .unwrap_err(), CcipRouterError::InvalidTokenPrice.into() ); } @@ -207,7 +669,15 @@ mod tests { chain.state.usd_per_unit_gas.timestamp = -2 * chain.config.gas_price_staleness_threshold as i64; assert_eq!( - fee_for_msg(0, &sample_message(), &chain, &sample_billing_config()).unwrap_err(), + fee_for_msg( + 0, + &sample_message(), + &chain, + &sample_billing_config(), + &[], + &[] + ) + .unwrap_err(), CcipRouterError::StaleGasPrice.into() ); } diff --git a/chains/solana/contracts/programs/ccip-router/src/lib.rs b/chains/solana/contracts/programs/ccip-router/src/lib.rs index 28a730eeb..e12434426 100644 --- a/chains/solana/contracts/programs/ccip-router/src/lib.rs +++ b/chains/solana/contracts/programs/ccip-router/src/lib.rs @@ -33,6 +33,8 @@ use crate::ocr3base::*; mod fee_quoter; use crate::fee_quoter::*; +mod utils; + // Anchor discriminators for CPI calls const CCIP_RECEIVE_DISCRIMINATOR: [u8; 8] = [0x0b, 0xf4, 0x09, 0xf9, 0x2c, 0x53, 0x2f, 0xf5]; // ccip_receive const TOKENPOOL_LOCK_OR_BURN_DISCRIMINATOR: [u8; 8] = @@ -532,16 +534,19 @@ pub mod ccip_router { /// # Arguments /// /// * `ctx` - The context containing the accounts required for setting the token billing configuration. - /// * `_chain_selector` - The chain selector. - /// * `_mint` - The public key of the token mint. + /// * `chain_selector` - The chain selector. + /// * `mint` - The public key of the token mint. /// * `cfg` - The token billing configuration. pub fn set_token_billing( ctx: Context, - _chain_selector: u64, - _mint: Pubkey, + chain_selector: u64, + mint: Pubkey, cfg: TokenBilling, ) -> Result<()> { + ctx.accounts.per_chain_per_token_config.version = 1; // update this if we change the account struct ctx.accounts.per_chain_per_token_config.billing = cfg; + ctx.accounts.per_chain_per_token_config.chain_selector = chain_selector; + ctx.accounts.per_chain_per_token_config.mint = mint; Ok(()) } @@ -673,19 +678,58 @@ pub mod ccip_router { /// * `dest_chain_selector` - The chain selector for the destination chain. /// * `message` - The message to be sent. /// + /// # Additional accounts + /// + /// In addition to the fixed amount of accounts defined in the `GetFee` context, + /// the following accounts must be provided: + /// + /// * First, the billing token config accounts for each token sent with the message, sequentially. + /// For each token with no billing config account (i.e. tokens that cannot be possibly used as fee + /// tokens, which also have no BPS fees enabled) the ZERO address must be provided instead. + /// * Then, the per chain / per token config of every token sent with the message, sequentially + /// in the same order. + /// /// # Returns /// /// The fee amount in u64. - pub fn get_fee( - ctx: Context, + pub fn get_fee<'info>( + ctx: Context<'_, '_, 'info, 'info, GetFee>, dest_chain_selector: u64, message: Solana2AnyMessage, ) -> Result { + let remaining_accounts = &ctx.remaining_accounts; + let message = &message; + require_eq!( + remaining_accounts.len(), + 2 * message.token_amounts.len(), + CcipRouterError::InvalidInputsTokenAccounts + ); + + let (token_billing_config_accounts, per_chain_per_token_config_accounts) = + remaining_accounts.split_at(message.token_amounts.len()); + + let token_billing_config_accounts = token_billing_config_accounts + .iter() + .zip(message.token_amounts.iter()) + .map(|(a, SolanaTokenAmount { token, .. })| { + BillingTokenConfig::validated_try_from(a, *token) + }) + .collect::>>()?; + let per_chain_per_token_config_accounts = per_chain_per_token_config_accounts + .iter() + .zip(message.token_amounts.iter()) + .map(|(a, SolanaTokenAmount { token, .. })| { + PerChainPerTokenConfig::validated_try_from(a, *token, dest_chain_selector) + }) + .collect::>>()?; + Ok(fee_for_msg( dest_chain_selector, - &message, + message, &ctx.accounts.dest_chain_state, &ctx.accounts.billing_token_config.config, + &token_billing_config_accounts, + &per_chain_per_token_config_accounts, )? .amount) } @@ -758,8 +802,58 @@ pub mod ccip_router { let config = ctx.accounts.config.load()?; let dest_chain = &mut ctx.accounts.dest_chain_state; - let fee_token_config = &ctx.accounts.fee_token_config.config; - let fee = fee_for_msg(dest_chain_selector, &message, dest_chain, fee_token_config)?; + + let mut accounts_per_sent_token: Vec = vec![]; + + for (i, token_amount) in message.token_amounts.iter().enumerate() { + require!( + token_amount.amount != 0, + CcipRouterError::InvalidInputsTokenAmount + ); + + // Calculate the indexes for the additional accounts of the current token index `i` + let (start, end) = calculate_token_pool_account_indices( + i, + &message.token_indexes, + ctx.remaining_accounts.len(), + )?; + + let current_token_accounts = validate_and_parse_token_accounts( + ctx.accounts.authority.key(), + dest_chain_selector, + ctx.program_id.key(), + &ctx.remaining_accounts[start..end], + )?; + + accounts_per_sent_token.push(current_token_accounts); + } + + let token_billing_config_accounts = accounts_per_sent_token + .iter() + .map(|accs| { + BillingTokenConfig::validated_try_from(accs.fee_token_config, accs.mint.key()) + }) + .collect::>>()?; + + let per_chain_per_token_config_accounts = accounts_per_sent_token + .iter() + .map(|accs| { + PerChainPerTokenConfig::validated_try_from( + accs.token_billing_config, + accs.mint.key(), + dest_chain_selector, + ) + }) + .collect::>>()?; + + let fee = fee_for_msg( + dest_chain_selector, + &message, + dest_chain, + &ctx.accounts.fee_token_config.config, + &token_billing_config_accounts, + &per_chain_per_token_config_accounts, + )?; let is_paying_with_native_sol = message.fee_token == Pubkey::zeroed(); if is_paying_with_native_sol { @@ -828,31 +922,13 @@ pub mod ccip_router { }; let seeds = &[EXTERNAL_TOKEN_POOL_SEED, &[ctx.bumps.token_pools_signer]]; - for (i, token_amount) in message.token_amounts.iter().enumerate() { - require!( - token_amount.amount != 0, - CcipRouterError::InvalidInputsTokenAmount - ); - - // Calculate the indexes for the additional accounts of the current token index `i` - let (start, end) = calculate_token_pool_account_indices( - i, - &message.token_indexes, - ctx.remaining_accounts.len(), - )?; - - let current_token_accounts = validate_and_parse_token_accounts( - ctx.accounts.authority.key(), - dest_chain_selector, - ctx.program_id.key(), - &ctx.remaining_accounts[start..end], - )?; - + for (i, (current_token_accounts, token_amount)) in accounts_per_sent_token + .iter() + .zip(message.token_amounts.iter()) + .enumerate() + { let router_token_pool_signer = &ctx.accounts.token_pools_signer; - let _token_billing_config = ¤t_token_accounts._token_billing_config; - // TODO: Implement charging depending on the token transfer - // CPI: transfer token amount from user to token pool transfer_token( token_amount.amount, @@ -1673,6 +1749,10 @@ pub enum CcipRouterError { InsufficientLamports, #[msg("Insufficient funds")] InsufficientFunds, + #[msg("Unsupported token")] + UnsupportedToken, + #[msg("Inputs are missing token configuration")] + InvalidInputsMissingTokenConfig, } // TODO: Refactor this to use the same structure as messages: execution_report.validate(..) diff --git a/chains/solana/contracts/programs/ccip-router/src/messages.rs b/chains/solana/contracts/programs/ccip-router/src/messages.rs index 381153061..cbfc94851 100644 --- a/chains/solana/contracts/programs/ccip-router/src/messages.rs +++ b/chains/solana/contracts/programs/ccip-router/src/messages.rs @@ -412,8 +412,11 @@ impl Solana2AnyMessage { #[cfg(test)] pub(crate) mod tests { + use crate::utils::Exponential; + use super::*; use anchor_lang::solana_program::pubkey::Pubkey; + use anchor_spl::token::spl_token::native_mint; use bytemuck::Zeroable; /// Builds a message and hash it, it's compared with a known hash @@ -563,27 +566,32 @@ pub(crate) mod tests { pub fn sample_billing_config() -> BillingTokenConfig { let mut value = [0; 28]; - value[27] = 3; + value.clone_from_slice(&3u32.e(18).to_be_bytes()[4..]); BillingTokenConfig { enabled: true, - mint: Pubkey::new_unique(), + mint: native_mint::ID, usd_per_token: crate::TimestampedPackedU224 { value, timestamp: 100, }, - premium_multiplier_wei_per_eth: 0, + premium_multiplier_wei_per_eth: 1, } } pub fn sample_dest_chain() -> DestChain { + let mut value = [0; 28]; + // L1 gas price + value[0..14].clone_from_slice(&1u32.e(18).to_be_bytes()[18..]); + // L2 gas price + value[14..].clone_from_slice(&U256::new(22u128).to_be_bytes()[18..]); DestChain { version: 1, chain_selector: 1, state: crate::DestChainState { sequence_number: 0, usd_per_unit_gas: crate::TimestampedPackedU224 { - value: [0; 28], - timestamp: 0, + value, + timestamp: 100, }, }, config: crate::DestChainConfig { @@ -591,16 +599,16 @@ pub(crate) mod tests { max_number_of_tokens_per_msg: 5, max_data_bytes: 200, max_per_msg_gas_limit: 0, - dest_gas_overhead: 0, + dest_gas_overhead: 1, dest_gas_per_payload_byte: 0, dest_data_availability_overhead_gas: 0, - dest_gas_per_data_availability_byte: 0, - dest_data_availability_multiplier_bps: 0, - default_token_fee_usdcents: 0, + dest_gas_per_data_availability_byte: 1, + dest_data_availability_multiplier_bps: 1, + default_token_fee_usdcents: 100, default_token_dest_gas_overhead: 0, default_tx_gas_limit: 0, - gas_multiplier_wei_per_eth: 0, - network_fee_usdcents: 0, + gas_multiplier_wei_per_eth: 1, + network_fee_usdcents: 100, gas_price_staleness_threshold: 10, enforce_out_of_order: false, chain_family_selector: CHAIN_FAMILY_SELECTOR_EVM.to_be_bytes(), diff --git a/chains/solana/contracts/programs/ccip-router/src/pools.rs b/chains/solana/contracts/programs/ccip-router/src/pools.rs index c15985c85..fcc36d00a 100644 --- a/chains/solana/contracts/programs/ccip-router/src/pools.rs +++ b/chains/solana/contracts/programs/ccip-router/src/pools.rs @@ -1,6 +1,7 @@ use anchor_lang::prelude::*; use anchor_spl::{ associated_token::get_associated_token_address_with_program_id, + token::spl_token::native_mint, token_2022::spl_token_2022::{self, instruction::transfer_checked, state::Mint}, token_interface::TokenAccount, }; @@ -12,12 +13,13 @@ use solana_program::{program::get_return_data, program_pack::Pack}; use crate::{ CcipRouterError, ExternalExecutionConfig, TokenAdminRegistry, CCIP_TOKENPOOL_CONFIG, - CCIP_TOKENPOOL_SIGNER, TOKEN_ADMIN_REGISTRY_SEED, TOKEN_POOL_BILLING_SEED, - TOKEN_POOL_CONFIG_SEED, + CCIP_TOKENPOOL_SIGNER, FEE_BILLING_TOKEN_CONFIG, TOKEN_ADMIN_REGISTRY_SEED, + TOKEN_POOL_BILLING_SEED, TOKEN_POOL_CONFIG_SEED, }; pub const CCIP_POOL_V1_RET_BYTES: usize = 8; -const MIN_TOKEN_POOL_ACCOUNTS: usize = 11; // see TokenAccounts struct for all required accounts +pub const CCIP_LOCK_OR_BURN_V1_RET_BYTES: u32 = 32; +const MIN_TOKEN_POOL_ACCOUNTS: usize = 12; // see TokenAccounts struct for all required accounts pub fn calculate_token_pool_account_indices( i: usize, @@ -46,7 +48,7 @@ pub fn calculate_token_pool_account_indices( pub struct TokenAccounts<'a> { pub user_token_account: &'a AccountInfo<'a>, - pub _token_billing_config: &'a AccountInfo<'a>, + pub token_billing_config: &'a AccountInfo<'a>, pub pool_chain_config: &'a AccountInfo<'a>, pub pool_program: &'a AccountInfo<'a>, pub pool_config: &'a AccountInfo<'a>, @@ -54,6 +56,7 @@ pub struct TokenAccounts<'a> { pub pool_signer: &'a AccountInfo<'a>, pub token_program: &'a AccountInfo<'a>, pub mint: &'a AccountInfo<'a>, + pub fee_token_config: &'a AccountInfo<'a>, pub remaining_accounts: &'a [AccountInfo<'a>], } @@ -77,6 +80,7 @@ pub fn validate_and_parse_token_accounts<'info>( let (pool_signer, remaining_accounts) = remaining_accounts.split_first().unwrap(); let (token_program, remaining_accounts) = remaining_accounts.split_first().unwrap(); let (mint, remaining_accounts) = remaining_accounts.split_first().unwrap(); + let (fee_token_config, remaining_accounts) = remaining_accounts.split_first().unwrap(); // Account validations (using remaining_accounts does not facilitate built-in anchor checks) { @@ -106,6 +110,22 @@ pub fn validate_and_parse_token_accounts<'info>( CcipRouterError::InvalidInputsPoolAccounts ); + let (expected_fee_token_config, _) = Pubkey::find_program_address( + &[ + FEE_BILLING_TOKEN_CONFIG, + if mint.key() == Pubkey::default() { + native_mint::ID.as_ref() // pre-2022 WSOL + } else { + mint.key.as_ref() + }, + ], + &router, + ); + require!( + fee_token_config.key() == expected_fee_token_config, + CcipRouterError::InvalidInputsConfigAccounts + ); + // check token accounts require!( *mint.owner == token_program.key(), @@ -181,6 +201,7 @@ pub fn validate_and_parse_token_accounts<'info>( pool_signer.key(), token_program.key(), mint.key(), + fee_token_config.key(), ]; let mut remaining_keys: Vec = remaining_accounts.iter().map(|x| x.key()).collect(); expected_entries.append(&mut remaining_keys); @@ -196,7 +217,7 @@ pub fn validate_and_parse_token_accounts<'info>( Ok(TokenAccounts { user_token_account, - _token_billing_config: token_billing_config, + token_billing_config, pool_chain_config, pool_program, pool_config, @@ -204,6 +225,7 @@ pub fn validate_and_parse_token_accounts<'info>( pool_signer, token_program, mint, + fee_token_config, remaining_accounts, }) } diff --git a/chains/solana/contracts/programs/ccip-router/src/state.rs b/chains/solana/contracts/programs/ccip-router/src/state.rs index 707300510..7a75281f8 100644 --- a/chains/solana/contracts/programs/ccip-router/src/state.rs +++ b/chains/solana/contracts/programs/ccip-router/src/state.rs @@ -1,7 +1,10 @@ use anchor_lang::prelude::*; use ethnum::U256; -use crate::ocr3base::Ocr3Config; +use crate::{ + ocr3base::Ocr3Config, valid_version, CcipRouterError, FEE_BILLING_TOKEN_CONFIG, + MAX_TOKEN_AND_CHAIN_CONFIG_V, TOKEN_POOL_BILLING_SEED, +}; // zero_copy is used to prevent hitting stack/heap memory limits #[account(zero_copy)] @@ -170,7 +173,7 @@ impl TryFrom for MessageExecutionState { } #[account] -#[derive(InitSpace)] +#[derive(InitSpace, Debug)] pub struct PerChainPerTokenConfig { pub version: u8, // schema version pub chain_selector: u64, // remote chain @@ -179,7 +182,31 @@ pub struct PerChainPerTokenConfig { pub billing: TokenBilling, // EVM: configurable in router only by ccip admins } -#[derive(InitSpace, Clone, AnchorSerialize, AnchorDeserialize)] +impl PerChainPerTokenConfig { + pub fn validated_try_from<'info>( + account: &'info AccountInfo<'info>, + token: Pubkey, + dest_chain_selector: u64, + ) -> Result { + let (expected, _) = Pubkey::find_program_address( + &[ + TOKEN_POOL_BILLING_SEED, + dest_chain_selector.to_le_bytes().as_ref(), + token.key().as_ref(), + ], + &crate::ID, + ); + require_keys_eq!(account.key(), expected, CcipRouterError::InvalidInputs); + let account = Account::::try_from(account)?; + require!( + valid_version(account.version, MAX_TOKEN_AND_CHAIN_CONFIG_V), + CcipRouterError::InvalidInputs + ); + Ok(account.into_inner()) + } +} + +#[derive(InitSpace, Debug, Clone, AnchorSerialize, AnchorDeserialize)] pub struct TokenBilling { pub min_fee_usdcents: u32, // Minimum fee to charge per token transfer, multiples of 0.01 USD pub max_fee_usdcents: u32, // Maximum fee to charge per token transfer, multiples of 0.01 USD @@ -212,6 +239,29 @@ pub struct BillingTokenConfig { pub premium_multiplier_wei_per_eth: u64, } +impl BillingTokenConfig { + // Returns Ok(None) when parsing the ZERO address, which is a valid input from users + // specifying a token that has no Billing config. + pub fn validated_try_from<'info>( + account: &'info AccountInfo<'info>, + token: Pubkey, + ) -> Result> { + if account.key() == Pubkey::default() { + return Ok(None); + } + + let (expected, _) = + Pubkey::find_program_address(&[FEE_BILLING_TOKEN_CONFIG, token.as_ref()], &crate::ID); + require_keys_eq!(account.key(), expected, CcipRouterError::InvalidInputs); + let account = Account::::try_from(account)?; + require!( + valid_version(account.version, 1), + CcipRouterError::InvalidInputs + ); + Ok(Some(account.into_inner().config)) + } +} + #[account] #[derive(InitSpace, Debug)] pub struct BillingTokenConfigWrapper { diff --git a/chains/solana/contracts/programs/ccip-router/src/utils.rs b/chains/solana/contracts/programs/ccip-router/src/utils.rs new file mode 100644 index 000000000..02a8b2b78 --- /dev/null +++ b/chains/solana/contracts/programs/ccip-router/src/utils.rs @@ -0,0 +1,66 @@ +use std::ops::{Add, AddAssign, Mul}; + +use anchor_lang::prelude::*; +use ethnum::U256; + +use crate::{CcipRouterError, SolanaTokenAmount}; + +pub trait Exponential { + fn e(self, exponent: u8) -> U256; +} + +impl> Exponential for T { + fn e(self, exponent: u8) -> U256 { + U256::from(self.into()) * U256::new(10).pow(exponent as u32) + } +} + +// USD with 18 decimals (i.e. $8 -> 8e18) +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct Usd18Decimals(pub U256); + +impl Usd18Decimals { + pub const ZERO: Self = Self(U256::ZERO); + + pub fn from_usd_cents(cents: u32) -> Self { + Self(U256::new(cents.into()) * 1u32.e(16)) + } +} + +impl Add for Usd18Decimals { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl AddAssign for Usd18Decimals { + fn add_assign(&mut self, rhs: Self) { + self.0 += rhs.0 + } +} + +impl Mul for Usd18Decimals { + type Output = Self; + + fn mul(mut self, rhs: U256) -> Self::Output { + self.0 *= rhs; + self + } +} + +impl SolanaTokenAmount { + pub fn value(&self, price: &Usd18Decimals) -> Usd18Decimals { + Usd18Decimals((U256::new(self.amount.into()) * price.0) / 1u32.e(18)) + } + + pub fn amount(token: Pubkey, value: Usd18Decimals, price: Usd18Decimals) -> Result { + Ok(Self { + token, + amount: ((value.0 * 1u32.e(18)) / price.0) + .try_into() + .map_err(|_| CcipRouterError::InvalidTokenPrice)?, + }) + } +} diff --git a/chains/solana/contracts/target/idl/ccip_router.json b/chains/solana/contracts/target/idl/ccip_router.json index a04483977..99a9301de 100644 --- a/chains/solana/contracts/target/idl/ccip_router.json +++ b/chains/solana/contracts/target/idl/ccip_router.json @@ -747,8 +747,8 @@ "# Arguments", "", "* `ctx` - The context containing the accounts required for setting the token billing configuration.", - "* `_chain_selector` - The chain selector.", - "* `_mint` - The public key of the token mint.", + "* `chain_selector` - The chain selector.", + "* `mint` - The public key of the token mint.", "* `cfg` - The token billing configuration." ], "accounts": [ @@ -1020,6 +1020,17 @@ "* `dest_chain_selector` - The chain selector for the destination chain.", "* `message` - The message to be sent.", "", + "# Additional accounts", + "", + "In addition to the fixed amount of accounts defined in the `GetFee` context,", + "the following accounts must be provided:", + "", + "* First, the billing token config accounts for each token sent with the message, sequentially.", + "For each token with no billing config account (i.e. tokens that cannot be possibly used as fee", + "tokens, which also have no BPS fees enabled) the ZERO address must be provided instead.", + "* Then, the per chain / per token config of every token sent with the message, sequentially", + "in the same order.", + "", "# Returns", "", "The fee amount in u64." @@ -2844,6 +2855,12 @@ }, { "name": "InsufficientFunds" + }, + { + "name": "UnsupportedToken" + }, + { + "name": "InvalidInputsMissingTokenConfig" } ] } diff --git a/chains/solana/contracts/tests/ccip/ccip_router_test.go b/chains/solana/contracts/tests/ccip/ccip_router_test.go index b108e0765..1e34c932d 100644 --- a/chains/solana/contracts/tests/ccip/ccip_router_test.go +++ b/chains/solana/contracts/tests/ccip/ccip_router_test.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "encoding/hex" "fmt" + "math/big" "sort" "testing" @@ -58,15 +59,16 @@ func TestCCIPRouter(t *testing.T) { // billing type AccountsPerToken struct { - name string - program solana.PublicKey - mint solana.PublicKey - billingATA solana.PublicKey - userATA solana.PublicKey - anotherUserATA solana.PublicKey - tokenlessUserATA solana.PublicKey - billingConfigPDA solana.PublicKey - feeAggregatorATA solana.PublicKey + name string + program solana.PublicKey + mint solana.PublicKey + billingATA solana.PublicKey + userATA solana.PublicKey + anotherUserATA solana.PublicKey + tokenlessUserATA solana.PublicKey + billingConfigPDA solana.PublicKey + feeAggregatorATA solana.PublicKey + perChainPerTokenConfigPDA solana.PublicKey // add other accounts as needed } wsol := AccountsPerToken{name: "WSOL (pre-2022)"} @@ -106,8 +108,13 @@ func TestCCIPRouter(t *testing.T) { } getTokenConfigPDA := func(mint solana.PublicKey) solana.PublicKey { - tokenBillingPDA, _, _ := solana.FindProgramAddress([][]byte{[]byte("fee_billing_token_config"), mint.Bytes()}, config.CcipRouterProgram) - return tokenBillingPDA + tokenConfigPda, _, _ := solana.FindProgramAddress([][]byte{[]byte("fee_billing_token_config"), mint.Bytes()}, config.CcipRouterProgram) + return tokenConfigPda + } + + getPerChainPerTokenConfigBillingPDA := func(mint solana.PublicKey) solana.PublicKey { + tokenBillingPda, _, _ := solana.FindProgramAddress([][]byte{[]byte("ccip_tokenpool_billing"), binary.LittleEndian.AppendUint64([]byte{}, config.EvmChainSelector), mint.Bytes()}, config.CcipRouterProgram) + return tokenBillingPda } validSourceChainConfig := ccip_router.SourceChainConfig{ @@ -121,9 +128,12 @@ func TestCCIPRouter(t *testing.T) { DefaultTxGasLimit: 1, MaxPerMsgGasLimit: 100, MaxDataBytes: 32, - MaxNumberOfTokensPerMsg: 1, + MaxNumberOfTokensPerMsg: 5, // bytes4(keccak256("CCIP ChainFamilySelector EVM")) ChainFamilySelector: [4]uint8{40, 18, 213, 44}, + + DefaultTokenFeeUsdcents: 1, + NetworkFeeUsdcents: 1, } // Small enough to fit in u160, big enough to not fall in the precompile space. validReceiverAddress := [32]byte{} @@ -228,6 +238,8 @@ func TestCCIPRouter(t *testing.T) { require.NoError(t, aerr) wsolReceiver, _, rerr := tokens.FindAssociatedTokenAddress(solana.TokenProgramID, solana.SolMint, config.BillingSignerPDA) require.NoError(t, rerr) + wsolPerChainPerTokenConfigPDA, _, perr := solana.FindProgramAddress([][]byte{[]byte("ccip_tokenpool_billing"), binary.LittleEndian.AppendUint64([]byte{}, config.EvmChainSelector), solana.SolMint.Bytes()}, ccip_router.ProgramID) + require.NoError(t, perr) wsolUserATA, _, uerr := tokens.FindAssociatedTokenAddress(solana.TokenProgramID, solana.SolMint, user.PublicKey()) require.NoError(t, uerr) wsolAnotherUserATA, _, auerr := tokens.FindAssociatedTokenAddress(solana.TokenProgramID, solana.SolMint, anotherUser.PublicKey()) @@ -246,6 +258,7 @@ func TestCCIPRouter(t *testing.T) { wsol.tokenlessUserATA = wsolTokenlessUserATA wsol.billingATA = wsolReceiver wsol.feeAggregatorATA = wsolFeeAggregatorATA + wsol.perChainPerTokenConfigPDA = wsolPerChainPerTokenConfigPDA /////////////// // Token2022 // @@ -262,6 +275,8 @@ func TestCCIPRouter(t *testing.T) { token2022PDA, _, aerr := solana.FindProgramAddress([][]byte{config.BillingTokenConfigPrefix, mintPubK.Bytes()}, ccip_router.ProgramID) require.NoError(t, aerr) + token2022PerChainPerTokenConfigPDA, _, puerr := solana.FindProgramAddress([][]byte{[]byte("ccip_tokenpool_billing"), binary.LittleEndian.AppendUint64([]byte{}, config.EvmChainSelector), mintPubK.Bytes()}, ccip_router.ProgramID) + require.NoError(t, puerr) token2022Receiver, _, rerr := tokens.FindAssociatedTokenAddress(config.Token2022Program, mintPubK, config.BillingSignerPDA) require.NoError(t, rerr) token2022UserATA, _, uerr := tokens.FindAssociatedTokenAddress(config.Token2022Program, mintPubK, user.PublicKey()) @@ -282,6 +297,7 @@ func TestCCIPRouter(t *testing.T) { token2022.tokenlessUserATA = token2022TokenlessUserATA token2022.billingATA = token2022Receiver token2022.feeAggregatorATA = token2022FeeAggregatorATA + token2022.perChainPerTokenConfigPDA = token2022PerChainPerTokenConfigPDA }) t.Run("Commit price updates address lookup table", func(t *testing.T) { @@ -858,8 +874,10 @@ func TestCCIPRouter(t *testing.T) { // Any nonzero timestamp is valid (for now) validTimestamp := int64(100) - validPriceValue := [28]uint8{} - validPriceValue[27] = 3 + value := [28]uint8{} + bigNum, ok := new(big.Int).SetString("1000000000000000000000000000000", 10) + require.True(t, ok) + bigNum.FillBytes(value[:]) testTokens := []TestToken{ { @@ -868,10 +886,10 @@ func TestCCIPRouter(t *testing.T) { Enabled: true, Mint: solana.SolMint, UsdPerToken: ccip_router.TimestampedPackedU224{ - Value: validPriceValue, + Value: value, Timestamp: validTimestamp, }, - PremiumMultiplierWeiPerEth: 0, + PremiumMultiplierWeiPerEth: 1, }}, { Accounts: token2022, @@ -879,10 +897,10 @@ func TestCCIPRouter(t *testing.T) { Enabled: true, Mint: token2022.mint, UsdPerToken: ccip_router.TimestampedPackedU224{ - Value: validPriceValue, + Value: value, Timestamp: validTimestamp, }, - PremiumMultiplierWeiPerEth: 0, + PremiumMultiplierWeiPerEth: 1, }}, } @@ -985,11 +1003,19 @@ func TestCCIPRouter(t *testing.T) { }) t.Run("When admin adds token0 with valid input it is configured", func(t *testing.T) { + // Any nonzero timestamp is valid (for now) + validTimestamp := int64(100) + value := [28]uint8{} + big.NewInt(3e18).FillBytes(value[:]) + token0Config := ccip_router.BillingTokenConfig{ - Enabled: true, - Mint: token0.Mint.PublicKey(), - UsdPerToken: ccip_router.TimestampedPackedU224{}, - PremiumMultiplierWeiPerEth: 0, + Enabled: true, + Mint: token0.Mint.PublicKey(), + UsdPerToken: ccip_router.TimestampedPackedU224{ + Timestamp: validTimestamp, + Value: value, + }, + PremiumMultiplierWeiPerEth: 1, } token0BillingPDA := getTokenConfigPDA(token0.Mint.PublicKey()) @@ -1059,99 +1085,6 @@ func TestCCIPRouter(t *testing.T) { require.NotEqual(t, initial.Config.PremiumMultiplierWeiPerEth, final.Config.PremiumMultiplierWeiPerEth) // it was updated require.Equal(t, token0Config.PremiumMultiplierWeiPerEth, final.Config.PremiumMultiplierWeiPerEth) }) - - t.Run("Can remove token config", func(t *testing.T) { - token0BillingPDA := getTokenConfigPDA(token0.Mint.PublicKey()) - - var initial ccip_router.BillingTokenConfigWrapper - ierr := common.GetAccountDataBorshInto(ctx, solanaGoClient, token0BillingPDA, config.DefaultCommitment, &initial) - require.NoError(t, ierr) // it exists, initially - - receiver, _, aerr := tokens.FindAssociatedTokenAddress(token0.Program, token0.Mint.PublicKey(), config.BillingSignerPDA) - require.NoError(t, aerr) - - ixConfig, cerr := ccip_router.NewRemoveBillingTokenConfigInstruction( - config.RouterConfigPDA, - token0BillingPDA, - token0.Program, - token0.Mint.PublicKey(), - receiver, - config.BillingSignerPDA, - anotherAdmin.PublicKey(), - solana.SystemProgramID, - ).ValidateAndBuild() - require.NoError(t, cerr) - testutils.SendAndConfirm(ctx, t, solanaGoClient, []solana.Instruction{ixConfig}, anotherAdmin, config.DefaultCommitment) - - var final ccip_router.BillingTokenConfigWrapper - ferr := common.GetAccountDataBorshInto(ctx, solanaGoClient, token0BillingPDA, rpc.CommitmentProcessed, &final) - require.EqualError(t, ferr, "not found") // it no longer exists - }) - - t.Run("Can remove a pre-2022 token too", func(t *testing.T) { - mintPriv, kerr := solana.NewRandomPrivateKey() - require.NoError(t, kerr) - mint := mintPriv.PublicKey() - - // use old (pre-2022) token program - ixToken, terr := tokens.CreateToken(ctx, solana.TokenProgramID, mint, admin.PublicKey(), 9, solanaGoClient, config.DefaultCommitment) - require.NoError(t, terr) - testutils.SendAndConfirm(ctx, t, solanaGoClient, ixToken, admin, config.DefaultCommitment, common.AddSigners(mintPriv)) - - configPDA, _, perr := solana.FindProgramAddress([][]byte{config.BillingTokenConfigPrefix, mint.Bytes()}, ccip_router.ProgramID) - require.NoError(t, perr) - receiver, _, terr := tokens.FindAssociatedTokenAddress(solana.TokenProgramID, mint, config.BillingSignerPDA) - require.NoError(t, terr) - - tokenConfig := ccip_router.BillingTokenConfig{ - Enabled: true, - Mint: mint, - UsdPerToken: ccip_router.TimestampedPackedU224{}, - PremiumMultiplierWeiPerEth: 0, - } - - // add it first - ixConfig, cerr := ccip_router.NewAddBillingTokenConfigInstruction( - tokenConfig, - config.RouterConfigPDA, - configPDA, - solana.TokenProgramID, - mint, - receiver, - anotherAdmin.PublicKey(), - config.BillingSignerPDA, - tokens.AssociatedTokenProgramID, - solana.SystemProgramID, - ).ValidateAndBuild() - require.NoError(t, cerr) - - testutils.SendAndConfirm(ctx, t, solanaGoClient, []solana.Instruction{ixConfig}, anotherAdmin, config.DefaultCommitment) - - var tokenConfigAccount ccip_router.BillingTokenConfigWrapper - aerr := common.GetAccountDataBorshInto(ctx, solanaGoClient, configPDA, config.DefaultCommitment, &tokenConfigAccount) - require.NoError(t, aerr) - - require.Equal(t, tokenConfig, tokenConfigAccount.Config) - - // now, remove the added pre-2022 token, which has a balance of 0 in the receiver - ixConfig, cerr = ccip_router.NewRemoveBillingTokenConfigInstruction( - config.RouterConfigPDA, - configPDA, - solana.TokenProgramID, - mint, - receiver, - config.BillingSignerPDA, - anotherAdmin.PublicKey(), - solana.SystemProgramID, - ).ValidateAndBuild() - require.NoError(t, cerr) - - testutils.SendAndConfirm(ctx, t, solanaGoClient, []solana.Instruction{ixConfig}, anotherAdmin, config.DefaultCommitment) - - var final ccip_router.BillingTokenConfigWrapper - ferr := common.GetAccountDataBorshInto(ctx, solanaGoClient, configPDA, rpc.CommitmentProcessed, &final) - require.EqualError(t, ferr, "not found") // it no longer exists - }) }) }) @@ -1955,17 +1888,49 @@ func TestCCIPRouter(t *testing.T) { FeeToken: wsol.mint, } - billingTokenConfigPDA := getTokenConfigPDA(wsol.mint) - - instruction, err := ccip_router.NewGetFeeInstruction(config.EvmChainSelector, message, config.EvmDestChainStatePDA, billingTokenConfigPDA).ValidateAndBuild() + raw := ccip_router.NewGetFeeInstruction(config.EvmChainSelector, message, config.EvmDestChainStatePDA, wsol.billingConfigPDA) + instruction, err := raw.ValidateAndBuild() require.NoError(t, err) - result := testutils.SimulateTransaction(ctx, t, solanaGoClient, []solana.Instruction{instruction}, user) - require.NotNil(t, result) + feeResult := testutils.SendAndConfirm(ctx, t, solanaGoClient, []solana.Instruction{instruction}, user, config.DefaultCommitment) + require.NotNil(t, feeResult) + fee, _ := common.ExtractTypedReturnValue(ctx, feeResult.Meta.LogMessages, config.CcipRouterProgram.String(), binary.LittleEndian.Uint64) + t.Log(fee) + require.Greater(t, fee, uint64(0)) + }) + + t.Run("Fee is retrieved for a correctly formatted message containing a nonnative token", func(t *testing.T) { + message := ccip_router.Solana2AnyMessage{ + Receiver: validReceiverAddress[:], + FeeToken: wsol.mint, + TokenAmounts: []ccip_router.SolanaTokenAmount{{Token: token0.Mint.PublicKey(), Amount: 1}}, + } + + // Set some fees that will result in some appreciable change in the message fee + billing := ccip_router.TokenBilling{ + MinFeeUsdcents: 800, + MaxFeeUsdcents: 1600, + DeciBps: 0, + DestGasOverhead: 100, + DestBytesOverhead: 100, + IsEnabled: true, + } + token0BillingConfigPda := getTokenConfigPDA(token0.Mint.PublicKey()) + token0PerChainPerConfigPda := getPerChainPerTokenConfigBillingPDA(token0.Mint.PublicKey()) + ix, err := ccip_router.NewSetTokenBillingInstruction(config.EvmChainSelector, token0.Mint.PublicKey(), billing, config.RouterConfigPDA, token0PerChainPerConfigPda, anotherAdmin.PublicKey(), solana.SystemProgramID).ValidateAndBuild() + require.NoError(t, err) + testutils.SendAndConfirm(ctx, t, solanaGoClient, []solana.Instruction{ix}, anotherAdmin, config.DefaultCommitment) - returned, err := common.ExtractTypedReturnValue(ctx, result.Value.Logs, config.CcipRouterProgram.String(), binary.LittleEndian.Uint64) + raw := ccip_router.NewGetFeeInstruction(config.EvmChainSelector, message, config.EvmDestChainStatePDA, wsol.billingConfigPDA) + raw.AccountMetaSlice.Append(solana.Meta(token0BillingConfigPda)) + raw.AccountMetaSlice.Append(solana.Meta(token0PerChainPerConfigPda)) + instruction, err := raw.ValidateAndBuild() require.NoError(t, err) - require.Equal(t, uint64(1), returned) + + feeResult := testutils.SendAndConfirm(ctx, t, solanaGoClient, []solana.Instruction{instruction}, user, config.DefaultCommitment) + require.NotNil(t, feeResult) + fee, _ := common.ExtractTypedReturnValue(ctx, feeResult.Meta.LogMessages, config.CcipRouterProgram.String(), binary.LittleEndian.Uint64) + require.Greater(t, fee, uint64(0)) }) t.Run("Cannot get fee for message with invalid address", func(t *testing.T) { @@ -1982,9 +1947,9 @@ func TestCCIPRouter(t *testing.T) { Receiver: address[:], FeeToken: wsol.mint, } - billingTokenConfigPDA := getTokenConfigPDA(wsol.mint) - instruction, err := ccip_router.NewGetFeeInstruction(config.EvmChainSelector, message, config.EvmDestChainStatePDA, billingTokenConfigPDA).ValidateAndBuild() + raw := ccip_router.NewGetFeeInstruction(config.EvmChainSelector, message, config.EvmDestChainStatePDA, wsol.billingConfigPDA) + instruction, err := raw.ValidateAndBuild() require.NoError(t, err) result := testutils.SendAndFailWith(ctx, t, solanaGoClient, []solana.Instruction{instruction}, user, config.DefaultCommitment, []string{"Error Code: InvalidEVMAddress"}) @@ -2005,7 +1970,9 @@ func TestCCIPRouter(t *testing.T) { message := ccip_router.Solana2AnyMessage{ FeeToken: wsol.mint, Receiver: validReceiverAddress[:], + Data: []byte{4, 5, 6}, } + raw := ccip_router.NewCcipSendInstruction( destinationChainSelector, message, @@ -2024,6 +1991,7 @@ func TestCCIPRouter(t *testing.T) { ) raw.GetFeeTokenUserAssociatedAccountAccount().WRITE() instruction, err := raw.ValidateAndBuild() + require.NoError(t, err) result := testutils.SendAndFailWith(ctx, t, solanaGoClient, []solana.Instruction{instruction}, user, config.DefaultCommitment, []string{"Error Code: AccountNotInitialized"}) require.NotNil(t, result) @@ -2738,6 +2706,11 @@ func TestCCIPRouter(t *testing.T) { replaceWith: token1.PoolLookupTable, errorStr: ccip_router.InvalidInputsLookupTableAccounts_CcipRouterError, }, + { + name: "invalid fee token config", + index: 11, + errorStr: ccip_router.InvalidInputsConfigAccounts_CcipRouterError, + }, { name: "extra accounts not in lookup table", index: 1_000, // large number to indicate append @@ -2745,7 +2718,7 @@ func TestCCIPRouter(t *testing.T) { }, { name: "remaining accounts mismatch", - index: 11, // only works with token0 + index: 12, // only works with token0 errorStr: ccip_router.InvalidInputsLookupTableAccounts_CcipRouterError, }, } @@ -2800,15 +2773,14 @@ func TestCCIPRouter(t *testing.T) { Receiver: validReceiverAddress[:], Data: []byte{4, 5, 6}, } - // getFee - ix, ferr := ccip_router.NewGetFeeInstruction(config.EvmChainSelector, message, config.EvmDestChainStatePDA, token.billingConfigPDA).ValidateAndBuild() - require.NoError(t, ferr) + rawGetFeeIx := ccip_router.NewGetFeeInstruction(config.EvmChainSelector, message, config.EvmDestChainStatePDA, token.billingConfigPDA) + ix, err := rawGetFeeIx.ValidateAndBuild() + require.NoError(t, err) - feeResult := testutils.SimulateTransaction(ctx, t, solanaGoClient, []solana.Instruction{ix}, user) + feeResult := testutils.SendAndConfirm(ctx, t, solanaGoClient, []solana.Instruction{ix}, user, config.DefaultCommitment) require.NotNil(t, feeResult) - fee, err := common.ExtractTypedReturnValue(ctx, feeResult.Value.Logs, config.CcipRouterProgram.String(), binary.LittleEndian.Uint64) - require.NoError(t, err) - require.Equal(t, uint64(1), fee) + fee, _ := common.ExtractTypedReturnValue(ctx, feeResult.Meta.LogMessages, config.CcipRouterProgram.String(), binary.LittleEndian.Uint64) + require.Greater(t, fee, uint64(0)) initialBalance := getBalance(token.billingATA) @@ -2892,8 +2864,9 @@ func TestCCIPRouter(t *testing.T) { } // getFee - ix, ferr := ccip_router.NewGetFeeInstruction(config.EvmChainSelector, message, config.EvmDestChainStatePDA, wsol.billingConfigPDA).ValidateAndBuild() - require.NoError(t, ferr) + rawGetFeeIx := ccip_router.NewGetFeeInstruction(config.EvmChainSelector, message, config.EvmDestChainStatePDA, wsol.billingConfigPDA) + ix, err := rawGetFeeIx.ValidateAndBuild() + require.NoError(t, err) feeResult := testutils.SimulateTransaction(ctx, t, solanaGoClient, []solana.Instruction{ix}, user) require.NotNil(t, feeResult) @@ -5405,4 +5378,103 @@ func TestCCIPRouter(t *testing.T) { }) }) }) + + ////////////////////////// + // Cleanup tests // + ////////////////////////// + + t.Run("Cleanup", func(t *testing.T) { + t.Run("Can remove token config", func(t *testing.T) { + token0BillingPDA := getTokenConfigPDA(token0.Mint.PublicKey()) + + var initial ccip_router.BillingTokenConfigWrapper + ierr := common.GetAccountDataBorshInto(ctx, solanaGoClient, token0BillingPDA, config.DefaultCommitment, &initial) + require.NoError(t, ierr) // it exists, initially + + receiver, _, aerr := tokens.FindAssociatedTokenAddress(token0.Program, token0.Mint.PublicKey(), config.BillingSignerPDA) + require.NoError(t, aerr) + + ixConfig, cerr := ccip_router.NewRemoveBillingTokenConfigInstruction( + config.RouterConfigPDA, + token0BillingPDA, + token0.Program, + token0.Mint.PublicKey(), + receiver, + config.BillingSignerPDA, + anotherAdmin.PublicKey(), + solana.SystemProgramID, + ).ValidateAndBuild() + require.NoError(t, cerr) + testutils.SendAndConfirm(ctx, t, solanaGoClient, []solana.Instruction{ixConfig}, anotherAdmin, config.DefaultCommitment) + + var final ccip_router.BillingTokenConfigWrapper + ferr := common.GetAccountDataBorshInto(ctx, solanaGoClient, token0BillingPDA, rpc.CommitmentProcessed, &final) + require.EqualError(t, ferr, "not found") // it no longer exists + }) + + t.Run("Can remove a pre-2022 token too", func(t *testing.T) { + mintPriv, kerr := solana.NewRandomPrivateKey() + require.NoError(t, kerr) + mint := mintPriv.PublicKey() + + // use old (pre-2022) token program + ixToken, terr := tokens.CreateToken(ctx, solana.TokenProgramID, mint, admin.PublicKey(), 9, solanaGoClient, config.DefaultCommitment) + require.NoError(t, terr) + testutils.SendAndConfirm(ctx, t, solanaGoClient, ixToken, admin, config.DefaultCommitment, common.AddSigners(mintPriv)) + + configPDA, _, perr := solana.FindProgramAddress([][]byte{config.BillingTokenConfigPrefix, mint.Bytes()}, ccip_router.ProgramID) + require.NoError(t, perr) + receiver, _, terr := tokens.FindAssociatedTokenAddress(solana.TokenProgramID, mint, config.BillingSignerPDA) + require.NoError(t, terr) + + tokenConfig := ccip_router.BillingTokenConfig{ + Enabled: true, + Mint: mint, + UsdPerToken: ccip_router.TimestampedPackedU224{}, + PremiumMultiplierWeiPerEth: 0, + } + + // add it first + ixConfig, cerr := ccip_router.NewAddBillingTokenConfigInstruction( + tokenConfig, + config.RouterConfigPDA, + configPDA, + solana.TokenProgramID, + mint, + receiver, + anotherAdmin.PublicKey(), + config.BillingSignerPDA, + tokens.AssociatedTokenProgramID, + solana.SystemProgramID, + ).ValidateAndBuild() + require.NoError(t, cerr) + + testutils.SendAndConfirm(ctx, t, solanaGoClient, []solana.Instruction{ixConfig}, anotherAdmin, config.DefaultCommitment) + + var tokenConfigAccount ccip_router.BillingTokenConfigWrapper + aerr := common.GetAccountDataBorshInto(ctx, solanaGoClient, configPDA, config.DefaultCommitment, &tokenConfigAccount) + require.NoError(t, aerr) + + require.Equal(t, tokenConfig, tokenConfigAccount.Config) + + // now, remove the added pre-2022 token, which has a balance of 0 in the receiver + ixConfig, cerr = ccip_router.NewRemoveBillingTokenConfigInstruction( + config.RouterConfigPDA, + configPDA, + solana.TokenProgramID, + mint, + receiver, + config.BillingSignerPDA, + anotherAdmin.PublicKey(), + solana.SystemProgramID, + ).ValidateAndBuild() + require.NoError(t, cerr) + + testutils.SendAndConfirm(ctx, t, solanaGoClient, []solana.Instruction{ixConfig}, anotherAdmin, config.DefaultCommitment) + + var final ccip_router.BillingTokenConfigWrapper + ferr := common.GetAccountDataBorshInto(ctx, solanaGoClient, configPDA, rpc.CommitmentProcessed, &final) + require.EqualError(t, ferr, "not found") // it no longer exists + }) + }) } diff --git a/chains/solana/gobindings/ccip_router/GetFee.go b/chains/solana/gobindings/ccip_router/GetFee.go index 2442a459a..33aeacc54 100644 --- a/chains/solana/gobindings/ccip_router/GetFee.go +++ b/chains/solana/gobindings/ccip_router/GetFee.go @@ -18,6 +18,17 @@ import ( // * `dest_chain_selector` - The chain selector for the destination chain. // * `message` - The message to be sent. // +// # Additional accounts +// +// In addition to the fixed amount of accounts defined in the `GetFee` context, +// the following accounts must be provided: +// +// * First, the billing token config accounts for each token sent with the message, sequentially. +// For each token with no billing config account (i.e. tokens that cannot be possibly used as fee +// tokens, which also have no BPS fees enabled) the ZERO address must be provided instead. +// * Then, the per chain / per token config of every token sent with the message, sequentially +// in the same order. +// // # Returns // // The fee amount in u64. diff --git a/chains/solana/gobindings/ccip_router/SetTokenBilling.go b/chains/solana/gobindings/ccip_router/SetTokenBilling.go index 8511bb3d8..8db2355d0 100644 --- a/chains/solana/gobindings/ccip_router/SetTokenBilling.go +++ b/chains/solana/gobindings/ccip_router/SetTokenBilling.go @@ -17,8 +17,8 @@ import ( // # Arguments // // * `ctx` - The context containing the accounts required for setting the token billing configuration. -// * `_chain_selector` - The chain selector. -// * `_mint` - The public key of the token mint. +// * `chain_selector` - The chain selector. +// * `mint` - The public key of the token mint. // * `cfg` - The token billing configuration. type SetTokenBilling struct { ChainSelector *uint64 diff --git a/chains/solana/gobindings/ccip_router/instructions.go b/chains/solana/gobindings/ccip_router/instructions.go index 651bcbbfe..7173e3caf 100644 --- a/chains/solana/gobindings/ccip_router/instructions.go +++ b/chains/solana/gobindings/ccip_router/instructions.go @@ -229,8 +229,8 @@ var ( // # Arguments // // * `ctx` - The context containing the accounts required for setting the token billing configuration. - // * `_chain_selector` - The chain selector. - // * `_mint` - The public key of the token mint. + // * `chain_selector` - The chain selector. + // * `mint` - The public key of the token mint. // * `cfg` - The token billing configuration. Instruction_SetTokenBilling = ag_binary.TypeID([8]byte{225, 230, 37, 71, 131, 209, 54, 230}) @@ -280,6 +280,17 @@ var ( // * `dest_chain_selector` - The chain selector for the destination chain. // * `message` - The message to be sent. // + // # Additional accounts + // + // In addition to the fixed amount of accounts defined in the `GetFee` context, + // the following accounts must be provided: + // + // * First, the billing token config accounts for each token sent with the message, sequentially. + // For each token with no billing config account (i.e. tokens that cannot be possibly used as fee + // tokens, which also have no BPS fees enabled) the ZERO address must be provided instead. + // * Then, the per chain / per token config of every token sent with the message, sequentially + // in the same order. + // // # Returns // // The fee amount in u64. diff --git a/chains/solana/gobindings/ccip_router/types.go b/chains/solana/gobindings/ccip_router/types.go index bcb29f38d..e814a75f6 100644 --- a/chains/solana/gobindings/ccip_router/types.go +++ b/chains/solana/gobindings/ccip_router/types.go @@ -1963,6 +1963,8 @@ const ( StaleGasPrice_CcipRouterError InsufficientLamports_CcipRouterError InsufficientFunds_CcipRouterError + UnsupportedToken_CcipRouterError + InvalidInputsMissingTokenConfig_CcipRouterError ) func (value CcipRouterError) String() string { @@ -2035,6 +2037,10 @@ func (value CcipRouterError) String() string { return "InsufficientLamports" case InsufficientFunds_CcipRouterError: return "InsufficientFunds" + case UnsupportedToken_CcipRouterError: + return "UnsupportedToken" + case InvalidInputsMissingTokenConfig_CcipRouterError: + return "InvalidInputsMissingTokenConfig" default: return "" } diff --git a/chains/solana/utils/tokens/tokenpool.go b/chains/solana/utils/tokens/tokenpool.go index 3930ffe6a..f0ee0e8f8 100644 --- a/chains/solana/utils/tokens/tokenpool.go +++ b/chains/solana/utils/tokens/tokenpool.go @@ -15,8 +15,9 @@ import ( type TokenPool struct { // token details - Program solana.PublicKey - Mint solana.PrivateKey + Program solana.PublicKey + Mint solana.PrivateKey + FeeTokenConfig solana.PublicKey // admin registry PDA AdminRegistry solana.PublicKey @@ -47,6 +48,7 @@ func (tp TokenPool) ToTokenPoolEntries() []solana.PublicKey { tp.PoolSigner, tp.Program, tp.Mint.PublicKey(), + tp.FeeTokenConfig, } return append(list, tp.AdditionalAccounts...) } @@ -71,10 +73,15 @@ func NewTokenPool(program solana.PublicKey) (TokenPool, error) { if err != nil { return TokenPool{}, err } + tokenConfigPda, _, err := solana.FindProgramAddress([][]byte{[]byte("fee_billing_token_config"), mint.PublicKey().Bytes()}, config.CcipRouterProgram) + if err != nil { + return TokenPool{}, err + } p := TokenPool{ Program: program, Mint: mint, + FeeTokenConfig: tokenConfigPda, AdminRegistry: tokenAdminRegistryPDA, PoolLookupTable: solana.PublicKey{}, User: map[solana.PublicKey]solana.PublicKey{}, @@ -179,6 +186,7 @@ func ParseTokenLookupTable(ctx context.Context, client *rpc.Client, token TokenP solana.Meta(lookupTableEntries[5]), // PoolSigner solana.Meta(lookupTableEntries[6]), // TokenProgram solana.Meta(lookupTableEntries[7]).WRITE(), // Mint + solana.Meta(lookupTableEntries[8]), // FeeTokenConfig } for _, v := range token.AdditionalAccounts {