Skip to content

Improve cross-contract calling page #452

@Daanvdplas

Description

@Daanvdplas

Worked on something while getting myself up to speed with it:

Cross-Contract Calling in ink!

Cross-contract calling enables smart contracts to interact with other deployed contracts, forming the foundation for composable and modular blockchain applications. This capability allows developers to build complex systems by combining simpler, focused contracts, enabling patterns like token standards, decentralized exchanges, and multi-contract architectures.

ink! provides several approaches for cross-contract communication, each designed for different use cases and levels of complexity. Understanding when to use each method is crucial for building maintainable and efficient smart contract systems.

Contract References

Contract references represent the most developer-friendly approach to cross-contract calling in ink!. This method provides compile-time type safety and automatic code generation, making it the recommended starting point for most cross-contract interactions.

When you compile an ink! contract, the framework automatically generates a reference type with a "Ref" suffix. For example, a contract named TokenContract generates a TokenContractRef type that can be imported and used by other contracts. This reference type mirrors the original contract's interface, providing the same method signatures with automatic encoding and decoding of parameters and return values.

The primary advantage of contract references lies in their familiar syntax and compiler assistance. Method calls on contract references look identical to local method calls, complete with IDE autocompletion and compile-time error checking. The compiler validates that called methods exist and that parameter types match the expected interface, catching errors early in the development process.

Contract references work best when you know the target contract's interface at compile time and need straightforward contract-to-contract communication. They excel in scenarios involving standard protocols like token transfers, where the interface is well-defined and stable. However, this approach has limitations when you need to send native tokens with calls or require dynamic method selection based on runtime conditions.

// Import the generated reference
use other_contract::OtherContractRef;

#[ink(message)]
pub fn call_other_contract(&self, contract_address: AccountId) -> Result<i32, Error> {
    let other_contract: OtherContractRef = contract_address.into();
    let result = other_contract.some_method(42, true)?;
    Ok(result)
}

Call Builder

The Call Builder provides maximum flexibility for cross-contract interactions when contract references prove insufficient. This low-level API gives developers complete control over call parameters, including gas limits, transferred values, and method selection, making it suitable for advanced scenarios that require fine-grained control.

Call Builder shines in situations requiring token transfers alongside contract calls. Unlike contract references, which cannot transfer native tokens, Call Builder allows you to specify the exact amount of tokens to send with each call. This capability proves essential for contracts that accept payments or require deposits as part of their operation.

Dynamic method selection represents another key strength of Call Builder. Rather than compile-time method binding, Call Builder allows runtime selection of contract methods based on application logic. This flexibility enables powerful patterns like proxy contracts that route calls to different implementations based on runtime conditions.

The trade-off for this flexibility comes in the form of reduced type safety and increased complexity. Call Builder requires manual construction of method selectors and parameter encoding, areas where the compiler cannot provide assistance. Developers must carefully ensure that selectors match target contract methods and that parameters are encoded correctly.

use ink::env::call::{build_call, ExecutionInput, Selector};

#[ink(message)]
pub fn flexible_call(&mut self, target: AccountId, selector: [u8; 4], data: Vec<u8>) -> Result<Vec<u8>, Error> {
    let result = build_call::<DefaultEnvironment>()
        .call(target)
        .gas_limit(5000)
        .transferred_value(100)
        .exec_input(
            ExecutionInput::new(Selector::new(selector))
                .push_arg(data)
        )
        .returns::<ReturnType<Vec<u8>>>()
        .try_invoke()?;
    Ok(result)
}

Trait-Based Calls

Trait-based calling introduces an abstraction layer that enables polymorphic contract interactions through shared interfaces. This approach proves particularly valuable when working with contract standards or building systems that need to interact with multiple implementations of the same interface.

ink! trait definitions, declared with the #[ink::trait_definition] attribute, create contracts that can be called through their trait interface rather than their concrete implementation. This abstraction enables powerful design patterns where the calling contract depends on an interface rather than a specific implementation, promoting loose coupling and enhancing system flexibility.

Token standards exemplify the power of trait-based calls. A contract needing to transfer tokens can depend on a generic token trait rather than a specific token implementation. This design allows the same contract to work with any token that implements the standard interface, whether it's a simple token, a complex governance token, or a wrapped asset.

The approach also facilitates upgradeable and modular architectures. By programming against interfaces, systems can swap implementations without changing calling code. This capability proves essential for protocols that need to evolve over time or support multiple implementations of the same functionality.

#[ink::trait_definition]
pub trait TokenStandard {
    #[ink(message)]
    fn transfer(&mut self, to: AccountId, amount: Balance) -> Result<(), TokenError>;
    
    #[ink(message)]
    fn balance_of(&self, account: AccountId) -> Balance;
}

#[ink(message)]
pub fn transfer_any_token(&mut self, token: AccountId, to: AccountId, amount: Balance) -> Result<(), Error> {
    let token_contract: TokenStandard = token.into();
    token_contract.transfer(to, amount)?;
    Ok(())
}

Direct Environment Calls

Direct environment calls provide the lowest-level interface to the contract execution environment, offering access to all chain capabilities at the cost of convenience and safety. This approach bypasses higher-level abstractions to interact directly with the underlying contract runtime.

These calls prove necessary when building framework-level code or accessing functionality not exposed through higher-level APIs. Examples include custom calling patterns, specialized error handling, or integration with chain-specific features that haven't been abstracted by ink!'s standard APIs.

Performance-critical applications may also benefit from direct environment calls, as they eliminate the overhead of higher-level abstractions. However, this performance gain comes at the cost of type safety and developer ergonomics, making direct calls suitable primarily for specialized use cases.

The complexity and reduced safety of direct environment calls make them inappropriate for most application-level code. Developers should exhaust higher-level alternatives before resorting to direct environment calls, and when using them, should carefully encapsulate the complexity behind safer abstractions.

use ink::env;

#[ink(message)]
pub fn direct_call(&mut self, target: AccountId, input: Vec<u8>) -> Result<Vec<u8>, Error> {
    let result = env::invoke_contract(&env::call::CallParams::eval(target)
        .gas_limit(5000)
        .value(0)
        .exec_input(input)
        .returns::<Vec<u8>>()
    )?;
    Ok(result)
}

Solidity ABI Compatibility

Solidity ABI compatibility enables ink! contracts to interact with existing Ethereum contracts and protocols, facilitating migration and cross-ecosystem integration. This capability proves essential when building bridges between ink!-based systems and the broader Ethereum ecosystem.

The approach works by using specialized contract reference types that handle the encoding differences between ink! and Solidity. These references automatically convert between ink!'s native types and Solidity's ABI-encoded format, enabling seamless interaction despite the different underlying representations.

Migration scenarios represent a primary use case for Solidity ABI compatibility. Projects moving from Ethereum to Substrate-based chains can maintain compatibility with existing contracts and protocols during the transition period. This compatibility reduces migration risk and enables gradual adoption of ink!'s features.

Cross-chain applications also benefit from Solidity ABI compatibility when they need to interact with contracts deployed on multiple networks. By supporting both ink! and Solidity interfaces, these applications can operate across different blockchain ecosystems without requiring separate implementations.

#[ink::contract_ref]
pub trait ERC20 {
    #[ink(message, selector = 0xa9059cbb)]
    fn transfer(&mut self, to: [u8; 20], amount: U256) -> bool;
    
    #[ink(message, selector = 0x70a08231)]
    fn balanceOf(&self, account: [u8; 20]) -> U256;
}

#[ink(message)]
pub fn interact_with_solidity_token(&mut self, token_address: AccountId, recipient: [u8; 20], amount: U256) -> Result<(), Error> {
    let token: ERC20 = token_address.into();
    let success = token.transfer(recipient, amount);
    if !success {
        return Err(Error::TransferFailed);
    }
    Ok(())
}

Choosing the Right Approach

The selection of an appropriate cross-contract calling method depends on several factors including type safety requirements, flexibility needs, and the specific use case at hand.

Contract references serve as the default choice for most cross-contract interactions. Their type safety, familiar syntax, and compiler assistance make them ideal for standard contract-to-contract communication where the interface is known at compile time. Most applications should start with contract references and only move to more complex approaches when specific requirements demand it.

Call Builder becomes necessary when applications need features unavailable in contract references, particularly token transfers or dynamic method selection. The additional complexity of Call Builder is justified when these capabilities are essential to the application's functionality.

Trait-based calls provide the best solution for polymorphic interactions and protocol implementations. When building systems that need to work with multiple implementations of the same interface, trait-based calls offer the necessary abstraction while maintaining type safety.

Direct environment calls should be reserved for specialized scenarios requiring access to low-level chain functionality or maximum performance. Most applications can accomplish their goals through higher-level APIs, making direct calls necessary only in exceptional circumstances.

Solidity ABI compatibility addresses specific interoperability requirements when working with existing Ethereum contracts or building cross-ecosystem applications. This approach should be used only when such compatibility is explicitly required.

The progression from contract references to more advanced approaches represents a natural evolution as applications grow in complexity. Starting with the simplest appropriate method and evolving to more advanced techniques as requirements emerge provides the best balance of maintainability and functionality.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions