DEV Community

Cover image for Building a CPI-Enabled Flip Program with Solidity on Solana
Shivam Soni
Shivam Soni

Posted on • Updated on

Building a CPI-Enabled Flip Program with Solidity on Solana

Introduction

Greetings to the third instalment of our series! Here, we're continuing our exploration of making Solana programs. We're still using the Solidity language, which compiles by the Solang compiler.

In previous articles, we built the foundation by explaining how Solana's high-performance blockchain combines with the familiar solidity syntax. We learned how to build smart contracts and make fungible tokens.

In this article, we're going to delve into a fascinating idea called program composition on Solana. We will discuss cross-program invocation, an advanced technique that allows different Solana programs to interact. Our primary goal is to explain Solana program composition using various program examples and coordinating many Solana programs.

By the end of this tutorial, you will gain a comprehensive understanding of Solana programming's composability concept. We will explore how programs interact, share data, and collaborate, providing you with valuable insights into the Solana ecosystem.

This tutorial contains two-part

  1. Theory part of composability(Table of contents 1-7)
  2. Practical part of cross-program invocation(Table of contents 8-12)

Prerequisite

Before proceeding, it would be beneficial if you have reviewed the previous articles in this series. You can find them at this link:solana-solang-guide.

The code for the project is located here

Here's what you're gonna learn:

Table Of Content

  1. Recap Of Previous Articles
  2. Composability
  3. Composability of programs(within Solana and Ethereum)
    • Composibility of programs in solana via CPI and PDA
    • Composability of contracts in ethereum
  4. Implementing the invoke cpi technique.
  5. System setup
  6. Program Design
  7. Writing program
  8. Writing test
  9. Setting configuration (for building, deploying and testing program)
  10. Building
  11. Deploying
  12. Testing
  13. Conclusion
  14. Further resources

Recap Of Previous Articles

In our earlier articles, we covered topics like Solana programs, accounts, instructions, transactions, SPL tokens, and JSON RPC calls. We trust these pieces helped you grasp the process of building Solana programs.

In Solana, accounts are at the core of everything.

In our upcoming articles, we'll dive into the idea of composability in Solana programs. We'll talk about development techniques like cross-program invocation and the use of program-derived addresses(PDA). To make things clearer, We'll handle business using Solana accounts to create sample programs that show these concepts.

These articles aim to provide you with practical exposure to the composability aspect of Solana programs.

Now, let's delve deeper into the composability of Solana programs in this article.

Composability

Composability is a concept that might ring a bell for Web2 developers. It's all about using software parts that fit into various apps. This concept has existed since the advent of open-source technology and held significant importance during the early days of the Internet. While Web2 got a boost from this, Web3 relies on it even more to improve user experiences and spark fresh ideas.

In this article, we're discussing how composability functions and why it's beneficial for Web3. Think of it as building with blocks – developers can combine different pieces to make something new without starting from scratch.

Web3, the updated Internet version, is reintroducing composability. It allows various blockchain apps, such as trading platforms and decentralized apps, to collaborate. It's like having different tools in a single toolbox.

In Web3, composability occurs when parts of blockchain apps link up, either via smart contracts or project assembly.

Composibility Of Programs(within solana and ethereum)

In Solana, think of composability as being like Functional Programming (FP). It's like having separate roles for a smart contract and the data it uses. Unlike Ethereum, where each token has its special contract, Solana uses something called spl-token to control all tokens in one place.

Ethereum's composability is akin to interface-based inheritance in traditional Object Oriented Programming (OOP). This means that if something acts like a particular entity, it's treated as such. In Ethereum, you expand on established standards like ERC-20 and implement the necessary functions.

When dealing with Solana, transactions centre around Solana accounts, which form the core of the entire Solana development Ecosystem..

On Ethereum, data lives right inside the smart contract. But in Solana, data is spread out across many atomic accounts. Even though it might sound a bit complex, this setup lets Solana do many things at the same time. Transactions that need to look at an account, without changing it, can all happen together.

In Solana's programming universe, different programs can talk to each other using something called cross-program invocation and PDA. One program can tell another program what to do. The starting program takes a little break while the other program does its thing.

In Ethereum When you call a contract code from another contract, the msg.sender(who is calling this contract) will refer to the address of our contract.

Here's a simplified comparison table between Solana and Ethereum composability:

Aspect Solana Ethereum
Composability Approach Like Functional Programming (FP) Interface-based Inheritance (OOP)
Token Management Centralized through spl-token Each token has its own contract (e.g., ERC-20)
Transaction Foundation Centered around Solana accounts Focuses on smart contract interaction
Data Storage Data distributed across atomic accounts Data stored within the smart contract
Concurrency Advantage Supports simultaneous task execution Limited parallel processing
Inter-program Comm. Cross-program invocation and PDA Interface and inheritance

Composibility of programs in solana via CPI and PDA

Cross-Program Invocation (CPI) is key to ensuring Solana programs collaborate , this enables the composability of Solana programs. It enables one program to call another, establishing a smooth connection. It's like how any user can communicate with a program using JSON RPC. This intelligent feature simplifies the expansive Solana system into a unified, tool for developers.

Calling Programs with CPIs

A Cross-Program Invocation (CPI) is when one program calls another program, aiming at a certain instruction in that program. CPIs allow the calling program to extend its signer privileges to the callee program.
This is achieved using instructions like invoke or invoke_signed. The latter is employed when programs have to give their signatures for Program-Derived Addresses (PDAs, which we'll cover in this series).

CPIs are what make Solana programs work together . Any public instruction of a program can be triggered by another program using a CPI. This makes different programs within the Solana system work together like clockwork.

Even though we can't control the accounts and data that are sent to a program, we should always double-check the information we pass into a CPI. This is crucial for keeping the program secure and working as expected.

Don't worry if the concept of one program calling another seems complex – it's quite straightforward. Think of it as giving directions using an "Instruction."

To make a CPI, you must specify and construct an instruction on the program being invoked and supply a list of accounts necessary for that instruction. If a PDA is required as a signer, the signers_seeds must also be provided when using invoke_signed.

To make this work, the Instruction needs three things: the program ID of the program you're calling, information(data) that the program can understand, and a list of "AccountInfos." These AccountInfos are like the accounts the program you're calling will use see diagram below.

Image description

Here's the neat part: the calling program receives these AccountInfos from the system at the beginning. This means that any account the program you're calling requires must also be needed by the calling program. It's like a chain reaction – one thing leads to another.

Oh, and that program ID we mentioned? The program doing the calling needs to get it in the same way. It's a rule everyone follows.

Imagine this instruction as a recipe – you gather these elements, and then something incredible unfolds. It's like a sort of magic that's built into the Solana system.

In this tutorial, you'll discover how to use invoke CPI in Solang when a program-derived address (PDA) isn't required for the signer.

Composability of contracts in Ethereum

Smart contracts on Ethereum work like public APIs – they're like building blocks that can be used without starting from scratch. For instance, you can use the ready-made smart contracts from projects like Uniswap, a decentralized exchange, to simplify tasks in your app.

How Composability Works in Ethereum

Ethereum's smart contracts are open to everyone, enhancing app development. Composability relies on three aspects:

  1. Modularity: Each smart contract has a specific role.
  2. Autonomy: Smart contracts can function .
  3. Discoverability: Anyone can use and modify smart contracts.

Advantages of Composability:

  • Quicker Development: Developers avoid starting from scratch.
  • Enhanced Innovation: Experimenting with new ideas becomes simpler.
  • User-Friendly: Apps collaborate seamlessly.

An Example:

Imagine a situation where a token has a higher value on one exchange than another. If you have enough money, you can buy it on the cheaper exchange and sell it on the more expensive one to make a profit. But if you don't have enough money, you can use something called a flash loan. This kind of loan lets you borrow assets without having to put up anything as security. With flash loans, you can do complicated things in one move, like buying and selling tokens to make money. And all this is possible because smart contracts are working together.

Using invoke cpi

How to use invoke to make cpi

The invoke function is used when making a CPI that does not need any PDAs to act as signers. When making CPIs, the Solana runtime extends the original signature passed into a program to the callee program.

When you're calling a program, you might find a library with helpful functions to create the Instruction. People and groups often make these libraries available, making it easier to call their programs.

The definition of the Instruction type required for a CPI includes:

  • invoke() is built into Solana's runtime and handles routing the given instruction to another program via the instruction's program_id field.
  • accounts - a list of AccountInfo corresponding to all the accounts accessed by the other program
  • data - instruction data that will be understood by the other program

To use invoke and invoke_signed, a list of account_infos is also required. Like the list of AccountMeta in the instructions, you need to include all the AccountInfo of each account that the program you're calling will read from or write to.

Since programs can only get AccountInfo values from the runtime at the program's entry point, any account that the called program needs must be needed by the calling program and provided by its caller. This also applies to the program ID of the called program.

In below code block see how cpi are creating to leverProgram by calling their switchPower function

function pullLever(address dataAccount, string name) public {
// The account required by the switchPower instruction.
// This is the data account created by the lever program (not this program), which stores the state of the switch.
AccountMeta[1] metas = [
AccountMeta({pubkey: dataAccount, is_writable: true, is_signer: false})
];

    // The data required by the switchPower instruction.
    string instructionData = name;

    // Invoke the switchPower instruction on the lever program.
    leverProgram.switchPower{accounts: metas}(instructionData);
}
Enter fullscreen mode Exit fullscreen mode

In this section of code, we're making a pullLever function that takes dataAccount and name as inputs. This function sets up the accountMeta for the data account of the lever program. It then puts the name into the instruction Data. Finally, we use cpi (cross-program-invocation) to call the switchPower method in the leverProgram(then a CPI is constructed, and the compiler will generate a invoke()), providing the necessary accountInfos and instructionData.

When creating a CPI, use the following syntax to specify the AccountMeta for each account:

  • AccountMeta::new - indicates writable
  • AccountMeta::new_readonly - indicates not writable
  • (pubkey, true) - indicates account is signer
  • (pubkey, false) - indicates the account is not signer

You usually make the Instruction within the calling program, but you can also get it from an external source through deserialization.

Shared data validation

The AccountInfo structures given to this function hold data that the runtime uses . This data is copied to and from the memory space of the program being called.

Call Depth

Cross-program invocations let programs call other programs , but currently, this is limited to a depth of 4.

Reentrancy

Currently, reentrancy is only allowed in cases of direct self-recursion, and it's limited to a set depth. This limitation is in place to prevent scenarios where a program could call another program from an interim state without knowing it might be called back. Direct recursion ensures that the program has complete control over its state when it's called back.

System setup

First note: We are utilizing the same system setup of our project dependency from previous articles
that is located here

We're kicking off by using a project initialization command.

anchor init cpiinvoke --solidity
Enter fullscreen mode Exit fullscreen mode

After going to the project directory

cd cpiinvoke
Enter fullscreen mode Exit fullscreen mode

Run the following command to open the vscode

code .
Enter fullscreen mode Exit fullscreen mode

We already discussed all project files in our previous article
so go to the solidity directory and make two files
hand.sol(this is the caller)
and
lever.sol(this is callee)

Program Design

We're making a flip program that can turn on or off power using a boolean type. To do this, we need to create two programs: Program A (the caller) and Program B (the callee).
Program A is in charge of calling Program B using cross-program invocation (CPI). Program B is the one being called from Program A.

Writing program

Now, let's start by creating the program that will be called. Navigate to the solidity directory within our project and open a created file called lever.sol. Then, insert the following code into this file:

@program_id("J5J3mD4ABcRtB7YcgrJBgugiQAP3DfcZi5bSAdiMAZWh")
contract lever {
    // Switch state
    bool private isOn = true;

    @payer(payer) // payer for the data account
    constructor() {}

    // Switch the power on or off
    function switchPower(string name) public {
        // Flip the switch
        isOn = !isOn;

        // Print the name of the person who pulled the switch
        print("{:} is pulling the power switch!".format(name));

        // Print the current state of the switch
        if (isOn){
            print("The power is now on.");
        } else {
            print("The power is now off!");
        }
    }

    // Get the current state of the switch
    function get() public view returns (bool) {
        return isOn;
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's break this down:

We're familiar with the program ID, payer, contract, and construction annotation from the previous articles in this series.

Now, we'll begin by defining the switch state.

 // Switch state
    bool private isOn = true;

Enter fullscreen mode Exit fullscreen mode

Here, bool is the data type, private is used to set access limits, and isOn is the name of a variable with the value true.

Next, we create a function called switchPower.

function switchPower(string name) public {
        // Flip the switch
        isOn = !isOn;

        // Print the name of the person who pulled the switch
        print("{:} is pulling the power switch!".format(name));

        // Print the current state of the switch
        if (isOn){
            print("The power is now on.");
        } else {
            print("The power is now off!");
        }
    }
Enter fullscreen mode Exit fullscreen mode

This function takes a parameter called name, which is of string type, and it's visible to the public. Inside the function, it flips the value of isOn – if it was true, now it becomes false, and vice versa. After that, it prints the name of the person who triggered the switch.

The function uses a basic programming concept, a conditional statement with an if-else block, to check if the power is on (true) or off (false) and prints .

// Print the current state of the switch
        if (isOn){
            print("The power is now on.");
        } else {
            print("The power is now off!");
        }
Enter fullscreen mode Exit fullscreen mode

We expand our contract by adding another function. This function's role is to fetch the present state of the switch. It does this by having a view visibility, allowing anyone to read from it.

// Get the current state of the switch
    function get() public view returns (bool) {
        return isOn;
    }
Enter fullscreen mode Exit fullscreen mode

That's it. This is our callee program, which receives cross-program invocation (CPI) instructions from the caller program. It then calls the switchPower function to handle and update the state of the switch.

Let's now switch to our caller program.

In the solidity directory, open a created file called hand.sol and copy-paste this block of code.

import "solana";

// Interface to the lever program.
leverInterface constant leverProgram = leverInterface(address'J5J3mD4ABcRtB7YcgrJBgugiQAP3DfcZi5bSAdiMAZWh');
interface leverInterface {
    function switchPower(string name) external;
}

@program_id("7zJ7E8uZccmCH89eykH8yZsB6G685nbz7sSq8N9sZTtq")
contract hand {

    // Creating a data account is required by Solang, but the account is not used in this example.
    // We only interact with the lever program.
    @payer(payer) // payer for the data account
    constructor() {}

    // "Pull the lever" by calling the switchPower instruction on the lever program via a Cross Program Invocation.

}
Enter fullscreen mode Exit fullscreen mode

Let's break this down:

We start by importing the Solana library.

Then, we build an interface for our callee (lever, or Program B) within our caller program (hand, or Program A).

Interface

When you flip a light switch, the light goes on.
You do not care how the switch turns the light on.
You just care that “it does turn the light on”.

Next, we're required to perform a Cross-Program Invocation (CPI) to the lever program, live at address J5J3mD4ABcRtB7YcgrJBgugiQAP3DfcZi5bSAdiMAZWh. This process leads us to create the interface for the callee program.

interface leverInterface {
    function switchPower(string name) external;
}
Enter fullscreen mode Exit fullscreen mode

In this step, we're crafting an interface for the lever program. We're including the switchPower function's entry point into this interface as an external function.

Afterwards, we set the program ID. In Solana contract development, programs are typically deployed to specific accounts. This account can be indicated in the source code using an annotation, specifically @program_id.

Let's begin with the hand contract. We're creating a data account, a necessity in Solang, although it remains unused in this program. Our interaction is solely with the lever program.

For this copy and paste pullLever function below constructor in hand.sol

// "Pull the lever" by calling the switchPower instruction on the lever program via a Cross Program Invocation.
    function pullLever(address dataAccount, string name) public {
        // The account required by the switchPower instruction.
        // This is the data account created by the lever program (not this program), which stores the state of the switch.
        AccountMeta[1] metas = [
            AccountMeta({pubkey: dataAccount, is_writable: true, is_signer: false})
        ];

        // The data required by the switchPower instruction.
        string instructionData = name;

        // Invoke the switchPower instruction on the lever program.
        leverProgram.switchPower{accounts: metas}(instructionData);
    }
Enter fullscreen mode Exit fullscreen mode

The pullLever function handles receiving arguments from the caller as an instruction. It then sets the instruction data and triggers the lever by invoking the switchPower instruction on the lever program through a Cross-Program Invocation (CPI Invoke).

During an external call (also known as CPI), the AccountMeta defines the accounts that need to be handed over to the called program.

instruction for the CPI to be processed by the callee in the lever program.

This is accountmeta information

address pubkey

The address (or public key) of the account

bool is_writable

Can the callee write to this account

bool is_signer

AccountMeta[1] metas = [
AccountMeta({pubkey: dataAccount, is_writable: true, is_signer: false})
];
Enter fullscreen mode Exit fullscreen mode

NOTE-:

For those familiar with Anchor Rust development, you're likely already acquainted with CPI (Cross-Program Invocation), but when working with Solang, things are a bit different. Unlike Anchor Rust, Solang doesn't have specific methods for CPI. Instead, if you use the syntax <address>.call(), a CPI is automatically constructed for you. The compiler takes care of generating the invoke or invoke_signed for you.

Here's an example:

leverInterface constant leverProgram = leverInterface(address'J5J3mD4ABcRtB7YcgrJBgugiQAP3DfcZi5bSAdiMAZWh');

This code allows you to perform a CPI to the lever program residing at address J5J3mD4ABcRtB7YcgrJBgugiQAP3DfcZi5bSAdiMAZWh.

Now, when you have:

leverProgram.switchPower{accounts: metas}(instructionData);

A CPI is constructed automatically, and the compiler generates an invoke() for this operation. This simplifies the process of working with CPI when using Solang.

This is how we achieve composability in Solana and Solang – by utilizing Cross-Program Invocation (CPI).

In our next articles of this series, we will explore how to do cpi with invoke_signed(that consumes PDA)

Writing test

Start by going into the test file and removing all the existing code. Then, replace it with this block of code.

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Lever } from "../target/types/lever";
import { Hand } from "../target/types/hand";

describe("cross-program-invocation", () => {
  // Configure the client to use the local cluster.
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  // Generate a new keypair for the data accounts for each program
  const dataAccountLever = anchor.web3.Keypair.generate();
  const dataAccountHand = anchor.web3.Keypair.generate();
  const wallet = provider.wallet;

  // The lever program and hand program
  const leverProgram = anchor.workspace.Lever as Program<Lever>;
  const handProgram = anchor.workspace.Hand as Program<Hand>;

  it("Initialize the lever!", async () => {
    // Initialize data account for the lever program
    const tx = await leverProgram.methods
      .new()
      .accounts({ dataAccount: dataAccountLever.publicKey })
      .signers([dataAccountLever])
      .rpc();
    console.log("Your transaction signature", tx);

    // Fetch the state of the data account
    const val = await leverProgram.methods
      .get()
      .accounts({ dataAccount: dataAccountLever.publicKey })
      .view();

    console.log("State:", val);
  });

  it("Pull the lever!", async () => {
    // Initialize data account for the hand program
    // This is required by Solang, but the account is not used
    const tx = await handProgram.methods
      .new()
      .accounts({ dataAccount: dataAccountHand.publicKey })
      .signers([dataAccountHand])
      .rpc();
    console.log("Your transaction signature", tx);

    // Call the pullLever instruction on the hand program, which invokes the lever program via CPI
    const tx2 = await handProgram.methods
      .pullLever(dataAccountLever.publicKey, "Chris")
      .accounts({ dataAccount: dataAccountHand.publicKey })
      .remainingAccounts([
        {
          pubkey: dataAccountLever.publicKey, // The lever program's data account, which stores the state
          isWritable: true,
          isSigner: false,
        },
        {
          pubkey: leverProgram.programId, // The lever program's program ID
          isWritable: false,
          isSigner: false,
        },
      ])
      .rpc({ skipPreflight: true });
    console.log("Your transaction signature", tx2);

    // Fetch the state of the data account
    const val = await leverProgram.methods
      .get()
      .accounts({ dataAccount: dataAccountLever.publicKey })
      .view();

    console.log("State:", val);
  });

  it("Pull it again!", async () => {
    // Call the pullLever instruction on the hand program, which invokes the lever program via CPI
    const tx = await handProgram.methods
      .pullLever(dataAccountLever.publicKey, "Ashley")
      .accounts({ dataAccount: dataAccountHand.publicKey })
      .remainingAccounts([
        {
          pubkey: dataAccountLever.publicKey, // The lever program's data account, which stores the state
          isWritable: true,
          isSigner: false,
        },
        {
          pubkey: leverProgram.programId, // The lever program's program ID
          isWritable: false,
          isSigner: false,
        },
      ])
      .rpc({ skipPreflight: true });

    console.log("Your transaction signature", tx);

    // Fetch the state of the data account
    const val = await leverProgram.methods
      .get()
      .accounts({ dataAccount: dataAccountLever.publicKey })
      .view();

    console.log("State:", val);
  });
});
Enter fullscreen mode Exit fullscreen mode

Let's break this down:

Begin by importing the necessary dependencies and types for both of our programs. This is crucial for managing tests that involve flipping the switch state, where Program A calls the "switchPower" function through cross-program invocation (CPI).

Then, we describe the test for the Cross-Program Invocation (CPI).

describe("cross-program-invocation", () => {

}
Enter fullscreen mode Exit fullscreen mode

In this part, we'll talk about and test how the "cpi" program works. We'll write different test cases, make assertions, and set expectations to make sure the program behaves correctly.

For this, you need to first set the Requierments for the test

Let’s write this

// Configure the client to use the local cluster.
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  // Generate a new keypair for the data accounts for each program
  const dataAccountLever = anchor.web3.Keypair.generate();
  const dataAccountHand = anchor.web3.Keypair.generate();
  const wallet = provider.wallet;

  // The lever program and hand program
  const leverProgram = anchor.workspace.Lever as Program<Lever>;
  const handProgram = anchor.workspace.Hand as Program<Hand>;
Enter fullscreen mode Exit fullscreen mode

Here we configure and start the setup for testing our cross-program-invocation Solana program.

  • Configuration and Provider Setup: First, we connect to the local cluster and set up the provider using AnchorProvider.env(). This provider is then assigned with anchor.setProvider(provider) The Provider is an abstraction of a connection to the Solana network, typically consisting of a Connection, Wallet, and a preflight commitment.

  • Generating Key Pairs: To store the program's state, we create a new data account using a key pair . Here we Generate a new keypair for the data accounts for each program. We set up wallet variables too.

  • Initializing the Program: We use anchor.workspace.Lever and anchor.workspace.Hand to make an instance of the program as Program. This prepares both program from the workspace, The program is an abstraction that combines the Provider, idl, and the programID (which is generated when the program is built) and allows us to call RPC methods against our program.

Now, let's initialize the data account for the lever program.

it("Initialize the lever!", async () => {
    // Initialize data account for the lever program
    const tx = await leverProgram.methods
      .new()
      .accounts({ dataAccount: dataAccountLever.publicKey })
      .signers([dataAccountLever])
      .rpc();
    console.log("Your transaction signature", tx);

    // Fetch the state of the data account
    const val = await leverProgram.methods
      .get()
      .accounts({ dataAccount: dataAccountLever.publicKey })
      .view();

    console.log("State:", val);
  });
Enter fullscreen mode Exit fullscreen mode

Here, we start by initializing the test and creating a transaction to establish a data account for the lever program. This is done using the following steps:

const tx = await leverProgram.methods
      .new()
      .accounts({ dataAccount: dataAccountLever.publicKey })
      .signers([dataAccountLever])
      .rpc();
    console.log("Your transaction signature", tx);
Enter fullscreen mode Exit fullscreen mode

Afterwards, we retrieve the current state value using this approach.

// Fetch the state of the data account
    const val = await leverProgram.methods
      .get()
      .accounts({ dataAccount: dataAccountLever.publicKey })
      .view();

    console.log("State:", val);
Enter fullscreen mode Exit fullscreen mode

Here, we get the state value by calling the "get()" method from the lever program.

Now, let's construct a test for pulling the lever. This test creates a transaction to alter the power state by initiating a cross-program invocation (CPI) to the lever program from the hand program.

First, we set up the data account for the hand program, which is needed by Solang even though it remains unused.

Then, the transaction uses the lever's data account and the name of the person who pulled the switch. We include the rest of the accounts and the program ID as accounts and instruction data for the CPI call.

it("Pull the lever!", async () => {
    // Initialize data account for the hand program
    // This is required by Solang, but the account is not used
    const tx = await handProgram.methods
      .new()
      .accounts({ dataAccount: dataAccountHand.publicKey })
      .signers([dataAccountHand])
      .rpc();
    console.log("Your transaction signature", tx);

    // Call the pullLever instruction on the hand program, which invokes the lever program via CPI
    const tx2 = await handProgram.methods
      .pullLever(dataAccountLever.publicKey, "Chris")
      .accounts({ dataAccount: dataAccountHand.publicKey })
      .remainingAccounts([
        {
          pubkey: dataAccountLever.publicKey, // The lever program's data account, which stores the state
          isWritable: true,
          isSigner: false,
        },
        {
          pubkey: leverProgram.programId, // The lever program's program ID
          isWritable: false,
          isSigner: false,
        },
      ])
      .rpc({ skipPreflight: true });
    console.log("Your transaction signature", tx2);

    // Fetch the state of the data account
    const val = await leverProgram.methods
      .get()
      .accounts({ dataAccount: dataAccountLever.publicKey })
      .view();

    console.log("State:", val);
  });
Enter fullscreen mode Exit fullscreen mode

Next, we retrieve the current state value using the lever program's "get" method. Then, we perform another pull, this time with different arguments, and fetch the updated state value.

And that’s it our tests are done.

Now we move to building, deploying, and testing our program.

Setting configuration (for building, deploying and testing program)

To begin, let's construct the program.

Building

Building the Program: Before building the program, navigate to the anchor.toml file and switch the cluster to "devnet".

Open the terminal in the root directory of your project and enter the following command.

anchor build
Enter fullscreen mode Exit fullscreen mode

This command will generate a target folder in the project root and generate idl and types of our Solana program to interact with the client side using rpc methods.

Now open a new terminal and check our configuration By writing this command

solana config get
Enter fullscreen mode Exit fullscreen mode

After running this you will see something like this

Config File: ~/.config/solana/cli/config.yml
RPC URL: https://api.devnet.solana.com
WebSocket URL: wss://api.devnet.solana.com/ (computed)
Keypair Path: ~/.config/solana/id.json
Commitment: confirmed
Enter fullscreen mode Exit fullscreen mode

"Devnet" is the RPC URL, and my wallet's default location is at "keypairpath." Make sure you generate a wallet keypair for deploying the program.

you can set devnet for cluster

by writing this command

solana config set --url https://api.devnet.solana.com
Enter fullscreen mode Exit fullscreen mode

by writing this command you can generate a new keypair

solana-keygen new
Enter fullscreen mode Exit fullscreen mode

Now you have a wallet

You can check the address(pubkey) and balance of this account using these commands

solana address
solana balance
Enter fullscreen mode Exit fullscreen mode

If you have some devnet sol then it is okay for deployment if it is not then Get some devnet airdrop by writing this command

solana airdrop 4
Enter fullscreen mode Exit fullscreen mode

Deploying

Deploying the program by opening terminal in a new tab and starting network cluster by writing this command

solana-test-validator
Enter fullscreen mode Exit fullscreen mode

This will start a devnet cluster in the system next deploy program using

anchor deploy
Enter fullscreen mode Exit fullscreen mode

you will get something like this

Deploying cluster: https://api.devnet.solana.com
Upgrade authority: ~/.config/solana/id.json
Deploying program "hand"...
Program path: ~/composability/cpi/target/deploy/hand.so...
Program Id: 7zJ7E8uZccmCH89eykH8yZsB6G685nbz7sSq8N9sZTtq

Deploying program "lever"...
Program path: ~/composability/cpi/target/deploy/lever.so...
Program Id: J5J3mD4ABcRtB7YcgrJBgugiQAP3DfcZi5bSAdiMAZWh

Deploy success
Enter fullscreen mode Exit fullscreen mode

Now our both program is on chain (devnet)

we get program id of our deployed program on the chain

Now grab this program id and change it in our program (solidity file) as well as anchor.toml file After pasting the new program to both files

in anchor.toml write like this

[features]
seeds = false
skip-lint = false

[programs.devnet]
hand = "7zJ7E8uZccmCH89eykH8yZsB6G685nbz7sSq8N9sZTtq"
lever="J5J3mD4ABcRtB7YcgrJBgugiQAP3DfcZi5bSAdiMAZWh"

[registry]
url = "https://api.apr.dev"

[provider]
cluster = "Devnet"
wallet = "~/.config/solana/id.json"

[scripts]
test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
Enter fullscreen mode Exit fullscreen mode

Build the program again with the command

anchor build
Enter fullscreen mode Exit fullscreen mode

Testing Program
Before testing check dependencies and run:

yarn install
npm install
Enter fullscreen mode Exit fullscreen mode

Now we run the test using this command

anchor test
Enter fullscreen mode Exit fullscreen mode

after this, you will see something like this

cross-program-invocation
Your transaction signature 5LixPfBtppvK6CGUgxKaY4obZfksEGoLXaKJMcvKgQVqcAir91oc1NHc8SCJ9MTbt4U9jvBHgQJPEQxwgkbqSXQv
State: true
    ✔ Initialize the lever! (1307ms)
Your transaction signature 5HkGWLAriP5XRJ9MTCpnMP8ZzUx2JMXsDk2B8TNYzuXPm4Wng1BwfaDMsUwiXqHjSVA683DA9YWuWVUKNvN4hyDV
Your transaction signature 4v1Zxyc5czpgkJ1SYYcwVi1C7K9icehPYkzDLUTyu9QfenaYqRnnkzVfX5Hyw6LXvA5kcdZEaiDsVx6o1hyNJx6x
State: false
    ✔ Pull the lever! (1528ms)
Your transaction signature 4qWHg6hrdWcXmGoS5zD4RGHdPczcMNYzKZnDTef8yyN7RNJj5Spj1ULG2ii6wBgf4pSczZNd3bApxedDQCKhSFhW
State: true
    ✔ Pull it again! (694ms)


  3 passing (4s)
Enter fullscreen mode Exit fullscreen mode

And that's it! We've successfully made a CPI call to switch the power status from "on" to "off" and back to "on."

This demonstrates how Cross-Program Invocation (CPI) functions in Solang to combine Solana programs.

Now, leveraging this concept of CPI, let's move on to integrating it into our "goldminttoken" program. This involves transferring some tokens to another account.

Conclusion

In this article, we've taken a detailed look at the concept of composability in Web3 programs, with a special focus on Solana using Solang (a solidity-compatible compiler).

In the theoretical part, we've covered cross-program invocation and how to create it using the invoke method by setting up the necessary instructions for the invocation. We've also delved into the inner workings of cross-program invocations in Solang, including the autogenerated invoke function by the Solang compiler.

In the practical section, we've walked through a hands-on example where we built a flip functionality to toggle power on and off, showcasing how cross-program invocation can be applied.

In our upcoming articles, we will further explore the concept by delving into invoke_signed, which requires PDA authority for cross-program invocations.

Further resources

Solana cpi
Calling between programs
Solang
Solang solana libraries
Invoke

Top comments (0)