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)
callsfunction
exported fromcontract
, passingargs
and returning aStatus
on any error.call(contract, function, args)
just callstry_call
with its arguments and traps onStatus
, 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 Address
es 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 Address
es 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 Address
es 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.