Skip to main content

Interacting with Contracts

Three types of interactions

Function call

A function call is the simplest and least expensive kind of contract interaction. A function call does exactly what you would expect a contract call to do in any other software development context: the contract transfers control and data to another part of the same contract. Because a function call always transfers control to the same contract, they do not change the values returned by get_current_contract and get_invoking_contract. A function call is the only way to access the private methods of a contract, although it can also be used to access the public methods of a contract.

To perform a function call, simply make a Rust function call.

Contract invocation

A contract invocation is a more powerful and more expensive kind of contract interaction. A contract invocation is similar to starting a new process because the code that runs will be in a separate address space, meaning that they do not share any data other than what was passed in the invocation. While a contract invocation typically transfers control to a different contract, it is possible to transfer control to the currently running contract. Regardless of whether the contract that receives control is a different contract or the currently running contract, the value returned by get_invoking_contract will be the previous value of get_current_contract. A contract invocation can only access the public methods of a contract.

If a contract contains a public function f, then invoking f can be done by making a Rust function call to f::invoke.

Some contracts, such as the token contract, only export the contract invocation functions. In doing so, they are able to assign those functions friendly names. For example, initialize

#[contractimpl(export_if = "export")]
impl TokenTrait for Token {
fn initialize(e: Env, admin: Identifier, decimal: u32, name: Binary, symbol: Binary) {

is exported as

pub use crate::contract::initialize::invoke as initialize;

This function is then easily called by the liquidity pool contract.

Stellar Operation

A Stellar operation is the ultimate entry point of every contract interaction. An operation transfers control and external data to a contract, allowing execution to begin.

This kind of interaction is currently supported in experimental versions of stellar-core only.

Interacting with contracts in tests

Debugging contracts explains that it is much more convenient to debug using native code than Wasm. Given that you are testing native code, it is tempting to interact with your contract directly using function calls. If you attempt this approach, you will find that it doesn't always work. Function call interactions do not set the environment into the correct state for contract execution, so functions involving contract data and determining the current or invoking contract will not work.

When writing tests, it is important to always interact with contracts through contract invocation. In a production setting, contract invocation will execute Wasm bytecode loaded from the ledger. So how does this work if you are testing native code? You must register your contract with the environment, so it knows what functions are available and how to call them. While this sounds complex, the contractimpl procedural macro automatically generates almost all the code to do this. All you have to do is write a small stub to actually call the generated code, such as

pub fn register_test_contract(e: &Env, contract_id: &[u8; 32]) {
let contract_id = FixedBinary::from_array(e, *contract_id);
e.register_contract(&contract_id, crate::contract::Token {});
}

Some contracts, such as the token contract, also provide a friendlier interface to facilitate testing. There are many ways these interfaces might make testing easier, but one common one is to allow automatic message signing by passing a ed25519_dalek::Keypair.

Note that everything described in this section is only available if the testutils feature is enabled.

Example

This machinery can also be used to test multiple contracts together. For example, the single offer contract test case creates a token.

Calling contracts

Contracts are invoked through a pair of host functions call and try_call:

  • try_call(contract, function, args) calls function exported from contract, passing args and returning a Status on any error.
  • call(contract, function, args) just calls try_call with its arguments and traps on Status, essentially propagating the error.

In both cases contract is a Binary host object containing the contract ID, function is a Symbol holding the name of an exported function to call, and args is a Vector of values to pass as arguments.

These host functions can be invoked in two separate ways:

  • From outside the host, such as when a user submits a transaction that calls a contract.
  • From within the host, when one contract calls another.

Both cases follow the same logic:

  • The contract's Wasm bytecode is retrieved from a CONTRACT_DATA ledger entry in the host's storage system.
  • A Wasm VM is instantiated for the duration of the invocation.
  • The function is looked up and invoked, with arguments passed from caller to callee.

When a call occurs from outside the host, any arguments will typically be provided in serialized XDR form accompanying the transaction, and will be deserialized and converted to host objects automatically before invoking the contract.

When a call occurs from inside the host, the caller and callee contracts share the same host and the caller can pass references to host objects directly to the callee without any need to serialize or deserialize them.

Since host objects are immutable, there is limited risk to passing a shared reference from one contract to another: the callee cannot modify the object in a way that would surprise the caller, only create new objects.

Preflight

Footprint

As mentioned in the persisting data section, a contract can only load or store CONTRACT_DATA entries that are declared in a footprint associated with its invocation.

A footprint is a set of ledger keys, each marked as either read-only or read-write. Read-only keys are available to the transaction for reading, read-write available for reading, writing or both.

Any transaction submitted by a user has to be accompanied by a footprint. A single footprint encompasses all the data read and written by all contracts transitively invoked by the transaction: not just the initial contract that the transaction calls, but also all contracts it calls, and so on.

Since it can be difficult for a user to always know which ledger entries a given contract call will attempt to read or write -- especially those caused by contracts called by other contracts deep within a transaction -- the host provides an auxiliary "preflight" mechanism that executes a transaction against a temporary, possibly slightly-stale snapshot of the ledger. The preflight mechanism is not constrained to only read or write the contents of a footprint; rather it records a footprint describing the transaction's execution, discards the execution's effects, and then returns the recorded footprint it to its caller.

Such a preflight-provided footprint can then be used to accompany a "real" submission of the same transaction to the network for real execution. If the state of the ledger has changed too much between the time of the preflight and the real submission, the footprint may be too stale and no longer accurately identify the keys the transaction needs to read and write, at which point the preflight must be retried to refresh the footprint.

In any event -- whether successful or failing -- the real transaction will execute atomically, deterministically and with serializable consistency semantics. An inaccurate footprint simply causes deterministic transaction failure, not a stale-read anomaly. All effects of such a failed transaction are discarded, as they would be in the presence of any other error.

Authorization

See authorization overview and authorization in transactions section for general information on Soroban authorization.

Preflight mechanism can also be used to compute the SorobanAuthorizedInvocation trees that have to be authorized by the Addresses in order for all the require_auth checks to pass.

Soroban host provides a special 'recording' mode for auth. Whenever require_auth is called host records its context (address, contract id, function, arguments), attributes it to an SorobanAuthorizedInvocation tree, and marks it as successful. Then after the invocation has finished, preflight can return all the recorded trees, as well as the generated random nonce values.

Given this information from preflight, the client only needs to provide these trees and nonces to the respective Addresses for signing and then build the final transaction using the preflight output and the corresponding signatures.

The recording auth mode is optional for preflight. For example, when dealing with the custom account contracts, it may be necessary to preflight the custom account's __check_auth code (that is simply omitted in the recording auth mode), for example, to get its ledger footprint. The non-recording mode is referred to as 'enforcing'. Enforcing mode is basically equivalent to running the transaction on-chain (with possibly a slightly stale ledger state) and hence it requires all the signatures to be valid.

Note, that the recording auth mode never emulates authorization failures. The reason for that is that failing authorization is always an exceptional situation (the Addresses for which you don't anticipate successful authorization shouldn't be used in the first place). It is similar to how, for example, the preflight doesn't emulate failures caused by the incorrect footprint. Preflight with enforcing auth mode may still be used to verify the signatures before executing the transaction on-chain.