LogoLogo
  • Entangle
    • Overview
    • Security Audits
  • Universal Interoperability Protocol
    • Overview
    • Architecture
      • Scalability and Network Stability
        • L2 Utility Blockchains
        • Transmitter Groups
      • Security and Consensus Mechanism
      • Finality
      • Execution Latency
      • Compatibility and Interoperability
    • Developer Guides
      • Getting Started
      • Solidity
        • Simple Abstract Messenger Example
        • Deploying Your Custom EVM Protocol
        • Bridging Tokens with UIP
        • Become an EVM Transmitter
      • Solana
        • Simple Abstract Messenger Example
        • Deploying Your Custom Solana Protocol
        • Become a Solana Transmitter
      • Calculate Cross-Chain Transaction Cost
      • Customizable Message Transmission Options
      • How to Debug Sent Messages
      • SDK Setup
      • Revenue Sharing for Transmitters
      • How to Become a Super Transmitter
    • Endpoints
  • Universal Data Feeds
    • Overview
    • Architecture
      • Data Delivery Methods
        • Pull Model
        • Push Model
      • Oracle Contract & User Interaction
    • Developer Guides
      • Custom Data Feeds
      • Fetch Data via Pull Model (PAYG)
        • EVM Smart Contracts
        • Solana Smart Contracts
      • Fetch Data via Pull Model (Subscriptions)
        • EVM Smart Contracts
        • Solana Smart Contracts
      • Fetch Data via Push Model
        • EVM Smart Contracts
        • Solana Smart Contracts
    • User Guides
      • Accessing Feeds
      • Subscribe to a Data Feed
      • Check Subscription
      • Manage Subscription
      • Renew Subscription
    • Data Endpoints
  • Universal Token Standard
    • Overview
    • Architecture
      • Fee Components
    • Developer Guides
      • Manual Deploy
        • Token Deployment Types
        • Create a Custom Token
        • Factory Blueprint Deployment
        • Examples
          • Initial Setup
          • UTS Connector
            • Mint & Burn Connector Scheme
            • Lock & Unlock Connector Scheme
            • Connector Over Native Currency
          • UTS Token
            • Simple Token
            • Token with Messages
      • Bridge SDK
      • Token Verification
      • Fees Calculation & Gas Estimation Logic
      • Estimations
    • User Guides
      • Launch Universal Token
      • Create a Liquidity Pool
      • Expand Existing Token
      • Transfer Liquidity to Connector
      • Bridging
    • Contract Addresses
  • Entangle Interoperable Blockchain
    • Overview
    • Architecture
    • Developer Guides
      • Set up a Validator Node
      • Delegating to Validators
      • Undelegating from Validators
      • Supported Accounts
  • More
    • Media Kit
    • FAQ
    • Report an Issue
    • Become a Partner
Powered by GitBook
On this page
  • Prerequisites
  • Contract Dependencies
  • Sending a message
  • Receiving a message
  • Extension registration
  • Client-side scripts and testing
  • Testing your contract
  • Sending messages that don’t fit in a single Solana transaction

Was this helpful?

Export as PDF
  1. Universal Interoperability Protocol
  2. Developer Guides
  3. Solana

Deploying Your Custom Solana Protocol

PreviousSimple Abstract Messenger ExampleNextBecome a Solana Transmitter

Last updated 1 day ago

Was this helpful?

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 guide or reach out to us through our .

Prerequisites

Ensure you have the latest versions installed.

Before proceeding, ensure you are familiar with writing Solana programs in .

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 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.

If you are not using Anchor, you don’t need to enable the anchor-lang feature.

programs/*/Cargo.toml
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-sol-types = "0.7"

Sending a message

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 SDK

  • dest_chain_id - chain ID of the destination network

  • selector - in most situations it should be omitted

  • dest_addr - address of the destination contract. If you’re sending to an EVM chain, make sure you left pad it so it’s 32 bytes

  • payload - the payload, carries any data you want to send

  • proposal_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 compromised

  • custom_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>,
}

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

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,
});

To send a message you need to perform a to the endpoint.

The payer and the accounts that are specific to your protocol (specified in ) 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.

Then upload target/wasm32-wasip1/release/example_extension-optimized.wasm to IPFS, using a service such as (it’s free).

How to Debug Sent Messages
contact form
Rust
installation guide
alloy
CPI
Pinata
the extension