Deploying Your Custom Solana Protocol
This guide provides step-by-step instructions on how to deploy your custom Solana protocol. By following these steps, you can successfully deploy your custom Solana protocol across multiple chains. If you encounter issues, refer to the How to Debug Sent Messages guide or reach out to us through our contact form.
Prerequisites
Before proceeding, ensure you are familiar with writing Solana programs in Rust.
In this guide we'll be using anchor as the framework of choice, but native programs will work as well. You can follow the anchor installation guide if it's your first time using it.
Before proceeding make sure you have setup your environment first, as shown below.
agave-install init 2.1.0 # solana
avm use 0.31.0 # anchor
Contract Dependencies
To streamline development, start by adding the UIP Solana SDK dependency to the configuration file.
uip-solana-sdk = { version = "0.13", default-features = false, features = ["anchor-lang"] }
Then, if you are developing an EVM to non-EVM protocol, you will likely need to use a library for EVM ABI encoding, we recommend using alloy:
alloy-sol-types = "0.7"
Sending a message
To send a message you need to perform a CPI to the endpoint.
First, you need to declare the following accounts:
use uip_solana_sdk::UipEndpoint;
#[derive(Accounts)]
pub struct SendMessage<'info> {
#[account(mut)]
sender: Signer<'info>,
/// CHECK: checked in the CPI
endpoint_config: AccountInfo<'info>,
/// CHECK: checked in the CPI
#[account(mut)]
uts_connector: AccountInfo<'info>,
system_program: Program<'info, System>,
uip_program: Program<'info, UipEndpoint>,
}
sender
is the payer for the operation. endpoint_config
anduts_connector
can be retrieved using the ENDPOINT_CONFIG
constant and the fetchUtsConnector
function from the TypeScript package.
Now, in the instruction, you can perform the Propose
CPI:
use uip_solana_sdk::{chains::*, Commitment, UipEndpoint};
UipEndpoint::propose()
.payer(ctx.accounts.sender.to_account_info())
.endpoint_config(ctx.accounts.endpoint_config.to_account_info())
.uts_connector(ctx.accounts.uts_connector.to_account_info())
.system_program(ctx.accounts.system_program.to_account_info())
.total_fee(uip_fee)
.dest_chain_id(dest_chain_id)
.dest_addr(&dest_addr)
.payload(&payload)
.custom_gas_limit(custom_gas_limit)
.proposal_commitment(Commitment::Confirmed)
.call()?;
Notice the following parameters:
total_fee
- it’s the fee paid for the message, usually calculated using the TypeScript UIP SDKdest_chain_id
- chain ID of the destination networkselector
- in most situations it should be omitteddest_addr
- address of the destination contract. If you’re sending to an EVM chain, make sure you left pad it so it’s 32 bytespayload
- the payload, carries any data you want to sendproposal_commitment
- whether transmitters should wait for your proposal transaction to be finalized or confirmed. Skipping the finalization will reduce the transmission time by about 15 seconds, but could theoretically pose a risk if the Solana blockchain is compromisedcustom_gas_limit
- used on the EVM destination to limit gas usage, must not be zero
Receiving a message
First, import some helper methods.
use uip_solana_sdk::{chains::*, parse_uip_message, route_instruction, MessageDataRef};
When a message is received, the endpoint will invoke a special instruction on your contract with a specific discriminator: uip_solana_sdk::EXECUTE_DISCRIMINATOR
. When using the Anchor framework, it can be declared like so:
#[instruction(discriminator = uip_solana_sdk::EXECUTE_DISCRIMINATOR)]
pub fn execute<'info>(ctx: Context<'_, '_, 'info, 'info, Execute>) -> Result<()> {
// your code
}
If you are not using Anchor, make sure that the first 8 bytes of instruction data match uip_solana_sdk::EXECUTE_DISCRIMINATOR
.
Now, the Execute
instruction should take no arguments and require a single account, the UIP message:
#[derive(Accounts)]
pub struct Execute<'info> {
/// CHECK: It's checked in `parse_uip_message`.
uip_msg: AccountInfo<'info>,
}
The payer and the accounts that are specific to your protocol (specified in the extension) will come after uip_msg
. If you only need a single set of accounts for all possible payloads, you can specify these accounts in the same structure after uip_msg
. Otherwise, they will be put in ctx.remaining_accounts
for further routing.
In the function body, you should parse the incoming message:
let uip_msg_data = ctx.accounts.uip_msg.try_borrow_data()?;
let MessageDataRef {
payload,
sender_addr,
src_chain_id,
msg_hash,
..
} = parse_uip_message(&ctx.accounts.uip_msg, &uip_msg_data, &crate::ID)?;
First, you should validate that src_chain_id
and sender_addr
match one of the smart contracts that your protocol supports.
Then, decode the payload
for further processing.
If you are using Anchor and expect different accounts for different types of messages, you can use the helper route_instruction
function that can make it appear as if you are routing a real instruction, for example:
if some_condition {
let ix_data = MyIxData {
//
};
let params = MyIxParams {
//
};
route_instruction(
&crate::ID,
my_ix,
ctx.remaining_accounts,
ix_data,
params,
)?;
} else {
// route another instruction
}
Here, my_ix
is defined as any other instruction, but is not made public. The first account must be payer, which is the UIP executor. The following accounts must be the custom accounts that you need. Don’t forget to perform all the required checks for these accounts.
/// Data for use in the anchor `instruction` attribute.
#[derive(AnchorSerialize, AnchorDeserialize)]
struct MyIxData {}
/// Input for the instruction function.
struct MyIxParams {}
#[derive(Accounts)]
#[instruction(ix_data: MyIxData)]
struct MyIx<'info> {
#[account(mut)]
payer: Signer<'info>,
// other accounts
}
fn my_ix(ctx: Context<MyIx>, params: MyIxParams) -> Result<()> {
// your implementation
}
Lastly, for the Execute
instruction to be connected to the UIP network, you need to register your protocol extension.
Extension registration
Solana is different to other networks in that apart from function parameters it needs accounts to be specified. In order for the executor to know what accounts need to be passed, we utilize something called extensions, compiled to WASM and stored on IPFS. Basically, it’s a separate library that describes your Execute
instruction interface.
To implement it, initialize a new library and specify the following in Cargo.toml
:
[lib]
crate-type = ["cdylib"]
[dependencies]
solana-program = ">=2.0,<2.2"
uip-solana-sdk = { version = "0.13", default-features = false, features = ["extension"] }
Then, you need to write a function called get_api_version
that returns the version of the extension API that your extension supports. Right now, 1 is the highest supported version.
Then, you need to write the get_instruction_info
function with a specific signature, that takes MessageData
and writes the required accounts, compute units, and heap frame back into InstructionInfo
. If your program needs more heap than the default, you can request heap frame (it must be a multiple of 1024 less than 256 KiB), otherwise leave it at 0.
Extensions can request at most 57 accounts for execution.
Here’s an example of an extension:
use solana_program::{instruction::AccountMeta, pubkey, pubkey::Pubkey, system_program};
use uip_solana_sdk::{
extension::{HostCallContext, InstructionInfo},
MessageDataRef,
};
#[no_mangle]
pub extern "C" fn get_api_version() -> u32 {
1
}
#[no_mangle]
pub unsafe extern "C" fn get_instruction_info(
msg_data_ptr: *const u8,
msg_data_len: usize,
result: &mut InstructionInfo,
) {
let MessageDataRef {
payload, msg_hash, ..
} = MessageDataRef::load(msg_data_ptr, msg_data_len);
let mut ctx = HostCallContext::new(msg_data_ptr, msg_data_len);
// host call example
let accounts =
ctx.get_multiple_accounts(&[pubkey!("So11111111111111111111111111111111111111112")]);
let (example_pda, _) = Pubkey::find_program_address(&[b"EXAMPLE"], &my_protocol::ID);
result.push_account(AccountMeta::new(example_pda, false)).unwrap();
result.push_account(AccountMeta::new_readonly(system_program::ID, false)).unwrap();
result.compute_units = 50_000;
result.heap_frame = 0;
}
Extensions run in an isolated environment, so they are limited in what they can do and how much CPU/memory they can use. To provide a way to communicate with the outside world, a host call mechanism is implemented. In particular, it allows extensions to fetch on-chain account data via HostCallContext::get_multiple_accounts
.
The extension needs to be compiled to the WASI target:
cargo build --target wasm32-wasip1 --release -p example-extension
wasm-opt -O4 target/wasm32-wasip1/release/example_extension.wasm -o target/wasm32-wasip1/release/example_extension-optimized.wasm
Then upload target/wasm32-wasip1/release/example_extension-optimized.wasm
to IPFS, using a service such as Pinata (it’s free).
After that, record the CID of the uploaded file. You need to convert it to 36 bytes, it can be done using the multiformats
package:
import { CID } from "multiformats";
Array.from(CID.parse(ipfsCid).toV1().bytes)
Your program needs to have an instruction that registers the extension using a CPI call. We recommend gating it with access control, so that only an admin or multisig can call it. You can run the instruction during initialization or upgrade of your program.
use uip_solana_sdk::UipEndpoint;
UipEndpoint::register_extension()
.payer(ctx.accounts.payer.to_account_info())
.extension(ctx.accounts.extension.to_account_info())
.program_signer(ctx.accounts.program_signer.to_account_info())
.system_program(ctx.accounts.system_program.to_account_info())
.program_signer_bump(ctx.bumps.program_signer)
.program_id(&crate::ID)
.ipfs_cid(ipfs_cid)
.call()?;
program_signer
needs to be a special PDA with the following seeds: [b"UIP_SIGNER"]
. The required extension
can be found using the findExtension
function from the Typescript SDK.
After performing RegisterExtension
, your contract can now receive UIP messages!
Client-side scripts and testing
The Solana endpoint has a TypeScript package to help with client-side interaction and testing. To use it, specify the following dependency in package.json
:
"@entangle-labs/uip-solana-sdk": "^0.6.2"
Testing your contract
In order to test execution of your messages, you can mimic the cross-chain message execution on localnet. For this, you can use a mock UIP config with a predefined signer configuration:
{
"pubkey": "CMFjqmzBd59mHnHZgGz9c1ppZPN8VFnWZ8UtxPVUEJLq",
"account": {
"lamports": 1000000000,
"data": [
"Y29uZmlnAADJAd8re5StLtozqnHQTIUxBPzcP1D/0BcF5yammv9nTQ2e/M9oMSShewHJlfygI6T5KqWd24VF/aRvU3Qlr6VzAAAQYy1ex2sFAAAAAAAAAG2BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACY+ZK1fNWkTftaMdnFKgY/2Z5kggAAAAAAAAAAAAAAALdwH8xglWoFfkgKJ0m/FQO4fdawHgAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAC8sz2vhLHvUETFLNZB2x1X18k/xAEAAADGRxLEtG/v6UJahvO6/n/u9lZC2gEAAADJAd8re5StLtozqnHQTIUxBPzcP1D/0BcF5yammv9nTQ==",
"base64"
],
"owner": "uipby67GWuDDt1jZTWFdXNrsSu83kcxt9r5CLPTKGhX",
"executable": false,
"rentEpoch": 1844674407
}
}
You can put it in tests/accounts/uip_config.json
. Then, you can configure Anchor to load the mock account and the other accounts necessary for UIP endpoint execution. Add this to Anchor.toml
:
[test.validator]
url = "https://api.devnet.solana.com"
[[test.validator.clone]]
# UIP program
address = "uipby67GWuDDt1jZTWFdXNrsSu83kcxt9r5CLPTKGhX"
[[test.validator.clone]]
# UTS config
address = "CTspuKSu7eRXzKqtYzR83H5VCZMWVRC4uRLfrA5Cy8WX"
[[test.validator.clone]]
# UTS connector
address = "vAukQz25gyuAHbdzEQS9GxMVZipVFu18MUoayKpETJz"
[[test.validator.account]]
address = "CMFjqmzBd59mHnHZgGz9c1ppZPN8VFnWZ8UtxPVUEJLq"
filename = "tests/accounts/uip_config.json"
Here’s an example of sending a message and intercepting the UIP proposal event.
import {
checkConsensus,
encodeTransmitterParams,
execute,
fetchExtension,
findExtension,
findMessage,
loadMessage,
msgHashFull,
onMessageProposed,
sendSimulateExecuteLite,
setProvider as setEndpointProvider,
signMsg,
unloadMessage,
} from "@entangle-labs/uip-solana-sdk";
const eventPromise: Promise<void> = new Promise((resolve, reject) => {
onMessageProposed((event) => {
try {
selector = event.selector;
payload = event.payload;
resolve();
} catch (error) {
reject(error);
}
});
setTimeout(() => {
reject(new Error("Event did not fire within timeout"));
}, 12000);
});
// send message via your protocol
await eventPromise;
And here’s an example of executing a message.
import { privateKeyToAccount } from "viem/accounts";
const transmitterParams = {
proposalCommitment: { confirmed: {} },
customGasLimit: new BN(2),
};
const transmitterParamsEncoded = encodeTransmitterParams(transmitterParams);
const signer = privateKeyToAccount(
"0x74e3ffad2b87174dc1d806edf1a01e3b017cf1be05d1894d329826f10fa1d72f",
);
const superSigner = privateKeyToAccount(
"0xf496bcca0a4896011dbdbe2ec80417ed759a6a9cc72477b3a65b8d99b066b150",
);
const msgData = {
initialProposal: {
senderAddr,
destAddr,
ccmFee,
payload,
reserved: Buffer.from([]),
transmitterParams: transmitterParamsEncoded,
selector,
},
srcChainData: {
srcBlockNumber,
srcChainId,
srcOpTxId,
},
};
const signatures = [await signMsg(signer, msgData)];
const superSignatures = [
await signMsg({ signer: superSigner, msgData, solanaChainId }),
];
const message = findMessage(msgData, solanaChainId);
await sendTx([
await loadMessage({
executor: executor.publicKey,
msgData,
solanaChainId,
}),
await checkConsensus({
executor: executor.publicKey,
message,
signatures,
superSignatures,
}),
await execute({
executor: executor.publicKey,
accounts,
spendingLimit: new BN(2_000_000),
deadline: new BN(Math.floor(Date.now() / 1000) + 10),
destinationComputeUnits: 30_000,
dstProgram: msgData.initialProposal.destAddr,
message,
}),
], [executor]);
await sendIx(await unloadMessage({ executor: executor.publicKey, message }), [
executor,
]);
Sending messages that don’t fit in a single Solana transaction
We have developed a utility program to allow loading data in chunks to later be used to invoke a Solana instruction. It allows passing up to 10KB of data to an instruction, bypassing the normal limit of around 1KB per instruction.
It will be automatically used by executors when a large message is passed to Solana from another chain. However, if you want to send a large transaction from Solana, you can also use the Chunk Loader program TypeScript SDK:
"@lincot/solana-chunk-loader": "^0.4.4",
In order to pass large data to your function, you need to manually encode the function selector and arguments to a buffer, and then pass it by chunks to the Chunk Loader program. The loadByChunks
instructions can be sent concurrently, and then followed by the passToCpi
instruction.
const chunkHolderId = randomInt(1 << 19);
const preInstructions = await loadByChunks({
owner: sender,
data,
chunkHolderId,
});
const instruction = await passToCpi({
owner: sender,
program: YOUR_PROGRAM_ID,
chunkHolderId,
accounts: [
{ pubkey: sender, isSigner: true, isWritable: true },
{ pubkey: ENDPOINT_CONFIG, isSigner: false, isWritable: false },
{
pubkey: await fetchUtsConnector(connection),
isSigner: false,
isWritable: true,
},
{
pubkey: SystemProgram.programId,
isSigner: false,
isWritable: false,
},
{
pubkey: ENDPOINT_PROGRAM_ID,
isSigner: false,
isWritable: false,
},
],
cpiComputeUnits: 30_000,
});
Last updated
Was this helpful?