CCM Swaps to Solana
Solana Considerations
Besides the general implementation checklist, there are some specific considerations for Solana.
- The compute unit budget on Solana will be capped at 600k compute units.
- Some extra parameters are needed when initiating a CCM swap to Solana. See Solana parameters.
- There is a limit on the message length and the number of accounts that can be passed in a CCM swap as explained in transaction length limitation.
- The receiver program on the destination chain must implement the described interface.
- If the amount of computes units requested is not enough to cover the costs of the receiver's logic on the destination chain, the transaction will revert on-chain. An example of a compute unit estimation is provided below.
- While Chainflip will do its best to successfully execute any swap with cross-chain messaging, CCM transactions that revert on Solana will result in funds not being egressed. In that case the protocol will then send the funds to the fallback address as explained in the Fallback mechanism section.
Fallback mechanism
A CCM transaction can revert for multiple reasons, including but not limited to having insufficient compute units or the user logic reverting on the receiver program. In these scenarios, assets wouldn't be sent to the destination address.
Unlike EVM chains, Solana does not support any try-catch mechanisms. Thus, a Fallback mechanism is implemented to ensure funds are safely sent to a user-provided fallback address in case a CCM transaction reverts.
A fallback address is provided as part of the CCM Additional Data and funds from failed CCM egresses will be automatically sent there via a simple "Transfer" instruction. This ensures the protection of user's funds, giving users the peace of mind when using our CCM feature.
Receive call and asset on the receiver program
Chainflip's Vault will transfer the destination asset's amount to the specified address in the instruction previous to the one calling the receiver program. Then it will call the receiver program's address with the following parameters:
Param | Description | Data type |
---|---|---|
source_chain | Source chain ID for the swap. | u32 |
source_address | Address that initiated the swap on the source chain. Addresses are encoded into a Vec<u8> type | Vec<u8> |
message | Message passed to the receiver program. | Vec<u8> |
amount | Amount of the destination asset transferred to the receiver address in the previous instruction. | uint |
pub fn cf_receive_token<'c: 'info, 'info>(
ctx: Context<'_, '_, 'c, 'info, CfReceiveToken<'info>>,
source_chain: u32,
source_address: Vec<u8>,
message: Vec<u8>,
amount: u64,
) -> Result<()>;
#[derive(Accounts)]
pub struct CfReceiveToken<'info> {
#[account(mut, token::mint = mint)]
pub receiver_token_account: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
pub mint: Account<'info, Mint>,
#[account(address = sysvar::instructions::ID)]
pub instruction_sysvar: UncheckedAccount<'info>,
};
pub fn cf_receive_native<'c: 'info, 'info>(
ctx: Context<'_, '_, 'c, 'info, CfReceiveNative<'info>>,
source_chain: u32,
source_address: Vec<u8>,
message: Vec<u8>,
amount: u64,
) -> Result<()>;
#[derive(Accounts)]
pub struct CfReceiveNative<'info> {
#[account(mut)]
pub receiver_native: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
#[account(address = sysvar::instructions::id())]
instruction_sysvar: UncheckedAccount<'info>,
};
Notice that the receiver interface is written using the Anchor framework (opens in a new tab).
Safety considerations on the user's logic
There is some safety considerations around the receiver program's logic that should be taken into account. In CCM swaps to Solana the receiver program is called on the receiver's chain with the expected cf_receive_* interface. As described, the funds will be transferred in one instruction and the CPI call will be done in the following one.
Those receiver functions could be called by a malicious actor and therefore it's safety needs to be considered. For some applications that don't hold any funds and atomically move them upon receival, such as DEX Aggregators, there is no real necessity to check that the caller is Chainflip or that the funds have been transferred correctly to the receiver as the call will revert otherwise. However, some applications might want to check that it's not a malicious attacker
that is calling the cf_receive_*
function. If so, there are two main ways to mitigate this. Either (or both) can be used:
- Do like DEX aggregators and ensure that the CPI call will revert if no funds are being transferred. For instance, that can be done by having a program that doesn't hold any funds and atomically checks, fetches or moves them upon receiving a CPI call. That will inherently make sure that the caller has sent them as otherwise it will revert.
- The receiver can check that the call comes from Chainflip using instruction introspection as shown in the code below called in the
check_chainflip_cpi
:
#[program]
pub mod user_program {
use super::*;
pub fn cf_receive_native<'c: 'info, 'info>(
ctx: Context<'_, '_, 'c, 'info, CfReceiveNative<'info>>,
source_chain: u32,
source_address: Vec<u8>,
message: Vec<u8>,
amount: u64,
) -> Result<()> {
check_chainflip_cpi(&sysvar_instructions_account, ctx.program_id)?;
// Continue with user code
...
}
}
fn check_chainflip_cpi(
sysvar_instructions_account: &AccountInfo,
program_id: &Pubkey,
) -> Result<()> {
// Check the cpi call comes from the CF_VAULT
let current_index =
sysvar::instructions::load_current_index_checked(sysvar_instructions_account)? as usize;
let current_ixn = sysvar::instructions::load_instruction_at_checked(
current_index,
sysvar_instructions_account,
)?;
// cf_receiver is in the third positions of the ccm call's context
if current_ixn.program_id != Pubkey::from_str(CF_VAULT_ID).unwrap()
|| current_ixn.accounts[3].pubkey != *program_id
{
return Err(ProgramError::InvalidInstructionData.into());
}
Ok(())
}
Compute budget estimation
The user-provided gas budget value is the amount of compute units alllocated for the user logic on the CCM egress transaction in the destination chain.
Requesting an insufficient amount of compute units will result in the transaction reverting on-chain triggering the Fallback mechanism.
The compute unit cost in a Solana transaction is computed as follows, given that Chainflip transactions only contain one signature:
compute_unit_limit = chainflip_compute_unit_overhead + user_gas_budget
transaction_cost = LAMPORTS_PER_SIGNATURE + prioritization_fee * compute_unit_limit
where
LAMPORTS_PER_SIGNATURE
is 5000 lamports.chainflip_compute_unit_overhead
is the compute unit overhead of the Chainflip's Vault program logic. It's computed by the protocolprioritization_fee
is the current median priority fee of the network with a minimum of 10 lamports per compute unit. It's computed by the protocol
Given that it's important for the CCM transfer transactions to succeed and that transactions in Solana are extremely cheap, it's recommended to overestimate the gas budget to ensure the transaction will not revert due to insufficient compute units. The compute unit limit is capped at 600k compute units.
Transaction length limitation
A Solana transaction is limited to 1232 bytes. That includes all instructions, accounts, data, signatures etc... From the remaining bytes, some are used for the asset transfer and CPI call to the receiver program and the rest are left for the message and the accounts list in a CCM swap. Therefore, the message and the accounts list are limited and depend on the egress asset.
const MAX_USER_CCM_BYTES_SOL = 814;
const MAX_USER_CCM_BYTES_USDC = 725;
Chainflip supports Versioned Transactions and allows the user to pass a list of Address Lookup Tables (ALT) to be used in the transaction to minimize the transaction length. This might be necessary if trying to execute complex logic that requires many accounts on Solana.
Without Address Lookup Tables
Each account takes 32 bytes in the transaction plus one per reference in the instruction.
number_of_accounts * 33 + message_length <= MAX_USER_CCM_BYTES_*
If that condition is not met, the opening of a deposit channel and the encoding of a Vault Swap will fail.
Using Address Lookup Tables
Only the ALT addresses are passed by the user, not their content. The protocol will fetch the content of the ALTs and add it to the transaction appropriately.
The ALTs don't need to be controlled by the user and can be another protocol's ALTs, such as Jupiter's. The only limitation is that an ALT must not be deactivated nor in the cool-down period preceeding its deactivation.
The number of lookup tables that can be passed by the user is currently limited to 3. Each lookup table requires 34 bytes. Then each account referenced in the transaction that is in the lookup table will only take 1 byte.
number_of_alts * 34 + number_of_accounts_in_alt * 1 + number_of_accounts_not_in_alt * 33 + message_length <= MAX_USER_CCM_BYTES_*
Chainflip can't know in advance the content of an ALT and therefore can't exactly check whether this length condition is met at the time of opening a deposit channel or encoding a Vault swap. However, if at the time of building the transaction the condition is not met, Chainflip will send the funds with a normal transfer to the fallback address.
Solana CCM Additional Data
When doing a CCM swap to Solana the ccm_additional_data
is required. This requirement is unique to the Solana chain only. This is because in Solana, all accounts that might be accessed in a transaction need to be specified in an account list. Therefore, users need to specify the accounts that the receiver program will access in the transaction so Chainflip can add those accounts to the list.
In Solana a program can't hold the receiving asset in the same address. That applies to the program receiving the Cross Program Invocation from Chainflip. Therefore, the program receiver's address must be passed separately.
As described above Chainflip supports using Address Lookup Tables to minimize the transaction length. Those also need to be passed in the ccm_additional_data
parameter.
To accommodate for that, a list of accounts needs to be passed as ccm_additional_data
when opening a deposit channel or using the Broker API to encode a Vault swap (otherwise encoded into the cf_parameters: Vec<u8>
parameter for low level CCM Vault swaps). That list must contain:
- The receiver program's address.
- The accounts that the receiver program will access in the transaction.
- The fallback address the funds to be transferred to if the Ccm call reverted.
- The list of Address Lookup Tables (ALT) to be used in the transaction.
This check is done when verifying the validity of the CCM message, at the following places:
- When requesting Chainflip to build Vault Swap call
- When the Vault Swap call is being witnessed by the Chainflip Protocol
- When a deposit channel is being requested for a Swap
For CCM swaps to Solana the ccm_additional_data
must be correctly encoded as described below. For CCM swaps to EVM chains (Ethereum or Arbitrum) it must be left empty.
If the CCM is invalid the swap request will be rejected.
Encoding the CCM Additional Data
The ccm_additional_data
is expected to be the solVersionedCcmAdditionalData
, encoded into bytes with Parity's Scale codec with the following parameters
Param | Description | Data Type |
---|---|---|
cf_receiver | Program address to receive the cf_receive_* call from Chainflip protocol. | Pubkey |
additional_accounts | Destination asset ID. | Pubkey[] |
fallback_address | The fallback_address" used for the fallback mechanism. | Pubkey |
alts | The ALTs to be used in the egress transaction to minimize transaction length. | Pubkey[] |
For reference, the definition are as follows:
// Pubkey type is raw byte array with a length of 32, representing a base-58 decoded Solana address
pub struct Pubkey([u8; 32]);
/*Encoded into:*/ [u8; 32]
// CcmAddress type is 32 bytes or address + 1 byte representing a bool
pub struct CcmAddress {
pub pubkey: Pubkey,
pub is_writable: bool,
}
/*Encoded into:*/ {pubkey: [u8; 32], is_writable: u8}
pub struct CcmAccounts {
pub cf_receiver: CcmAddress,
pub additional_accounts: Vec<CcmAddress>,
pub fallback_address: Pubkey,
}
pub enum VersionedSolanaCcmAdditionalData {
V0(CcmAccounts),
V1 { ccm_accounts: SolCcmAccounts, alts: Vec<SolAddress> },
}
/*Encoded into:*/ {
V0: {
cf_receiver: { pubkey: [u8; 32], is_writable: u8 },
additional_accounts: Array<{ pubkey: [u8; 32], is_writable: u8 }>,
fallback_address: [u8; 32]
}
V1: {
ccm_accounts: {
cf_receiver: { pubkey: [u8; 32], is_writable: u8 },
additional_accounts: Array<{ pubkey: [u8; 32], is_writable: u8 }>,
fallback_address: [u8; 32]
},
alts: Array<[u8; 32]>
}
}
If the list is invalid or the encoding is incorrect the opening of a deposit channel or the Vault Swap will fail.
The following Typescript code contains logic you can use to encode accounts into the correct encoding to build CCM for the Solana chain. You will need to add @polkadot/util
, @solana/web3.js
, rpc-websockets
(required by @solana/web3.js
), and scale-ts
as dependencies into your package.json
file.
import { Vector, bool, Struct, Bytes as TsBytes, Enum } from 'scale-ts';
import { PublicKey } from '@solana/web3.js';
import { u8aToHex } from '@polkadot/util';
const solCcmAccountsCodec = Struct({
cf_receiver: Struct({
pubkey: TsBytes(32),
is_writable: bool,
}),
additional_accounts: Vector(
Struct({
pubkey: TsBytes(32),
is_writable: bool,
}),
),
fallback_address: TsBytes(32),
});
const solCcmAltAccountsCodec = Struct({
ccm_accounts: solCcmAccountsCodec,
alts: Vector(TsBytes(32)),
});
const solVersionedCcmAdditionalDataCodec = Enum({
V0: solCcmAccountsCodec,
V1: solCcmAltAccountsCodec,
});
function newSolanaVersionedCcmAdditionalData() {
// User receiver program address. Replace with your address
const cfReceiverAddress = {
pubkey: new PublicKey('8pBPaVfTAcjLeNfC187Fkvi9b1XEFhRNJ95BQXXVksmH').toBytes(),
is_writable: false,
};
// User additional accounts that the receiver program will access. Replace with you addresses
const remainingAccounts = [
{
pubkey: new PublicKey('CFp37nEY6E9byYHiuxQZg6vMCnzwNrgiF9nFGT6Zwcnx').toBytes(),
is_writable: false,
},
];
// Replace with your address
const fallbackAddress = new PublicKey('AkYRjwVHBCcE1HsjZaTFr5SrTNHPRX7PtwZxdSDMcTvb').toBytes();
const solCcmAccounts = {
cf_receiver: cfReceiverAddress,
additional_accounts: remainingAccounts,
fallback_address: fallbackAddress,
};
// ALTs to use in the egress transaction. Replace with you addresses. It can be an empty array.
const ccmAltAccounts = [new PublicKey('AkYRjwVHBCcE1HsjZaTFr5SrTNHPRX7PtwZxdSDMcTvb').toBytes()]
const ccmAltAdditionalData = {
ccm_accounts: solCcmAccounts,
alts: ccmAltAccounts,
};
return u8aToHex(
solVersionedCcmAdditionalDataCodec.enc({
tag: 'V1',
value: ccmAltAdditionalData,
}),
);
}
console.log(newSolanaVersionedCcmAdditionalData());
If you run the test, the encoded data will be printed into the console.
0x017417da8b99d7748127a76b03d61fee69c80dfef73ad2d5503737beedc5a9ed480004a73bdf31e341218a693b8772c43ecfcecd4cf35fada09a87ea0f860d028168e50090e0b0f5b60147b325842c1fc68f6c90fe26419ea7c4afeb982f71f1f54b5b440490e0b0f5b60147b325842c1fc68f6c90fe26419ea7c4afeb982f71f1f54b5b44
Then the encoded data can be passed as a parameter into the RPC call.
Example of creating a Vault swap call with CCM to Solana
curl -H "Content-Type: application/json" -d '{
"id": 1,
"jsonrpc": "2.0",
"method": "broker_request_swap_parameter_encoding",
"params": [
{ "chain": "Ethereum", "asset": "ETH" },
{ "chain": "Solana", "asset": "SOL" },
"BdyHK5DckpAFGcbZveGLPjjMEaADGfNeqcRXKoyd33kA",
0,
{
"chain": "Ethereum",
"input_amount": 1000,
"refund_parameters": {
"retry_duration": 10,
"refund_address": "0xc64722AD9613851b10E26fF8118A7696A0f956f2",
"min_price": "0x0"
}
},
{
"message": "0x0011223344556677",
"gas_budget": 1000,
"ccm_additional_data": "0x017417da8b99d7748127a76b03d61fee69c80dfef73ad2d5503737beedc5a9ed480004a73bdf31e341218a693b8772c43ecfcecd4cf35fada09a87ea0f860d028168e50090e0b0f5b60147b325842c1fc68f6c90fe26419ea7c4afeb982f71f1f54b5b440490e0b0f5b60147b325842c1fc68f6c90fe26419ea7c4afeb982f71f1f54b5b44"
}
]
}' http://my-broker-api:9944
Which will yield the response:
{
"jsonrpc": "2.0",
"result": {
"chain": "Ethereum",
"calldata": "0x07933dd2000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000009000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000209e0d6a70e12d54edf90971cc977fa26a1d3bb4b0b26e72470171c36b0006b01f0000000000000000000000000000000000000000000000000000000000000008001122334455667700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e5001502017417da8b99d7748127a76b03d61fee69c80dfef73ad2d5503737beedc5a9ed480004a73bdf31e341218a693b8772c43ecfcecd4cf35fada09a87ea0f860d028168e50090e0b0f5b60147b325842c1fc68f6c90fe26419ea7c4afeb982f71f1f54b5b440490e0b0f5b60147b325842c1fc68f6c90fe26419ea7c4afeb982f71f1f54b5b440a000000c64722ad9613851b10e26ff8118a7696a0f956f2000000000000000000000000000000000000000000000000000000000000000000009059e6d854b769a505d01148af212bf8cb7f8469a7153edce8dcaedd9d299125000000000000000000000000000000000000000000000000000000000000",
"value": "0x3e8",
"to": "0xb7a5bd0345ef1cc5e66bf61bdec17d2461fbd968"
},
"id": 1
}
This Ethereum transaction can then be constructed signed and sent to the Chainflip's Vault smart contract as described in the EVM Vault Swap docs.