Skip to content

Developer Guide: Sync Calls

Brian Hazzard edited this page Aug 4, 2025 · 5 revisions

Sync Call Developer Guide

Sync calls provide a synchronous way to execute functions in another contract, in contrast to inline action and require_recipient() host function, which are asynchronous in nature. That makes it simpler to reason about contract execution.

To make a sync call, the caller suspends its execution and transfers the control to the callee. After the callee finishes its execution, the results are returned to the caller, and the caller then resumes its execution.

Sync calls can be made using a CDT tag and convenient wrapper, or using the new low level sync call host functions. This guide explains both ways.

Prerequisites

Software Installation

Sync call is implemented in Spring 2.0.0-dev1 and CDT 5.0.0-dev1.

Clone the code

git clone https://github.com/AntelopeIO/spring.git --branch v2.0.0-dev1.1 --recursive spring

git clone https://github.com/AntelopeIO/cdt.git --branch v5.0.0-dev1.1 --recursive cdt

Build

Build the software using your usual way.

Please note, starting Rel 2.0, gcc-10 is not supported anymore on ubuntu 20. Install gcc 11 or higher on ubuntu 20, or use ubuntu 22. Or use the pinned build.

Activate sync_call Protocol Feature

Start nodeos and activate sync_call protocol feature. It requires all previous protocol features including savanna protocol feature.

./cleos push action eosio activate '["25ee6c71d3eccafff66aa68fb8d6bc0d34dfa23ac82866158b446920b4847582"]' -p eosio@active

Hello World Example

This section demonstrates an example where the callee contract implements a function hello_world() to print out Hello World! and the caller contract implement an action to call hello_world() in the callee, synchronously.

Callee

Include the new CDT system header file eosio/call.hpp. Add eosio::call attribute to hello_world(), and use eosio::call_wrapper template type to declare hello_world_wrapper function type to be used by callers, as in callee.hpp:

#include <eosio/call.hpp>
#include <eosio/eosio.hpp>

class [[eosio::contract]] callee : public eosio::contract{
public:
   using contract::contract;

   [[eosio::call]]
   void hello_world();
   using hello_world_wrapper = eosio::call_wrapper<"hello_world"_i, &callee::hello_world>;
};

where "hello_world"_i is the sync call function name ("hello_world"_i is a shortcut operator for hash_id("hello_world"), an internal function ID) and &callee::hello_world is the function reference.

Implement the function in usual way:

#include "callee.hpp"
#include <eosio/print.hpp>

void callee::hello_world(){
   eosio::print("Hello, World!");
}

Caller

On the caller, include "callee.hpp". Provide the callee contract to hello_world_wrapper. Call the function:

#include "callee.hpp"
#include <eosio/eosio.hpp>

using namespace eosio;

class [[eosio::contract]] caller : public eosio::contract{
public:
   using contract::contract;

   [[eosio::action]]
   void helloworld() {
      callee::hello_world_wrapper hello_world{"calleeacct"_n};  // declare a sync call
      hello_world();  // make the call
   }
};

Run

Deploy the callee contract to calleeacct and caller contract to calleracct.

Push the helloworld action and you will see

./cleos push action calleracct helloworld '[""]' -p calleracct@active
executed transaction: 898241710e58eef75de21d7ff7683c542f77ba5fea58c534c0d701dc45d18af1  96 bytes  619 us
#                         calleracct <= calleracct::helloworld              ""
#            calleeacct::hello_world <= calleracct

CONSOLE OUTPUT BEGIN =====================
[calleracct->(calleeacct,hello_world)]: CALL BEGIN ======
Hello, World!
[calleracct->(calleeacct,hello_world)]: CALL END   ======
CONSOLE OUTPUT END   =====================
warning: transaction executed locally, but may not be confirmed by the network yet         ]

Arguments and Return Value Example

You can pass arguments and return a value in sync calls.

Below is an example of a sync call adding two numbers.

Callee

callee.hpp

#include <eosio/call.hpp>
#include <eosio/eosio.hpp>

class [[eosio::contract]] callee : public eosio::contract{
public:
   using contract::contract;

   [[eosio::call]]
   uint32_t add(uint32_t x, uint32_t y);
   using add_func = eosio::call_wrapper<"add"_i, &callee::add>;
};

callee.cpp

#include "callee.hpp"
#include <eosio/print.hpp>

uint32_t callee::add(uint32_t x, uint32_t y) {
   eosio::print("inside add()");
   return x + y;
}

Caller

caller.cpp

#include "callee.hpp"

#include <eosio/eosio.hpp>
#include <eosio/print.hpp>

using namespace eosio;

class [[eosio::contract]] caller : public eosio::contract{
public:
   using contract::contract;

   [[eosio::action]]
   void addaction(uint32_t x, uint32_t y) {
      callee::add_func add{ "calleeacct"_n };
      eosio::print("before add()");
      eosio::check(add(x, y) == x + y, "x + y was not correct");
      eosio::print("after add()");
   }
};

Run

Deploy the contracts and push addaction

./cleos push action calleracct addaction '[15, 30]' -p calleracct@active
executed transaction: 4f2910fc30dd4ad27d491c1fafc1168570c3b6c62907ca25327763211d0fc768  104 bytes  771 us
#                         calleracct <= calleracct::addaction               {"x":15,"y":30}
#                    calleeacct::add <= calleracct                          {"x":15,"y":30}
=>                                return value: 45

CONSOLE OUTPUT BEGIN =====================
before add()
[calleracct->(calleeacct,add)]: CALL BEGIN ======
inside add()
[calleracct->(calleeacct,add)]: CALL END   ======
after add()
CONSOLE OUTPUT END   =====================
warning: transaction executed locally, but may not be confirmed by the network yet         ]

Sync Call to Sync Call Example

A sync call can call another sync call. The max nested level is governed by max_sync_call_depth whose default value is 16.

caller action:

[[eosio::action]]
void nestedcalls() {
   callee::nested_calls_func nested_div{ "callee"_n };
   eosio::check(nested_div(32, 4) == 8, "32 / 4 was not 8");
}

Level 0 callee:

[[eosio::call]]
uint32_t nested_calls(uint32_t x, uint32_t y) {
   callee1::div_func div{ "callee1"_n };
   return div(x, y);
}
using nested_calls_func = eosio::call_wrapper<"nested_calls"_i, &callee::nested_calls>;

Level 1 callee1:

[[eosio::call]]
uint32_t div(uint32_t x, uint32_t y);
using div_func = eosio::call_wrapper<"div"_i, &callee1::div>;

uint32_t callee1::div(uint32_t x, uint32_t y) {
   return x / y;
}

get_sender() in a sync call returns the direct contract making the sync call.

Use Sync Call Host Functions

Spring implements sync call host functions and CDT provides an abstraction via eosio::call() and eosio::get_call_return_value() to facilitate making sync calls.

int64_t call(eosio::name receiver, uint64_t flags, const char* data, size_t data_size);
uint32_t get_call_return_value( void* mem, uint32_t len );

In current release, only the last significant bit of flags is used: 0 means read-write, 1 means read-only. All other bits must be 0.

Unlike using the wrapper, you will need to pack payload header and arguments, make the call, check the status, read return value, and unpack it.

For a sync call function just echoing input,

[[eosio::call]]
uint32_t echo_input(uint32_t input) {
   return input;
}

you can make a sync call to echo_input as

[[eosio::action]]
void usehostfuncs() {
   // Step 1: construct call data header, including version and function name's ID
   call_data_header header{ .version = 0, .func_name = "echo_input"_i.id };
   
   // Step 2: pack the header and arguments
   uint32_t input = 5;
   const std::vector<char> data{ eosio::pack(std::make_tuple(header, input)) };
   
   // Step 3: make the call
   auto return_value_size = eosio::call("callee"_n, 0, data.data(), data.size());
   
   // Step 4: check execution status
   eosio::check(return_value_size >= 0, "sync call not supported");
   
   // Step 5: read the return value
   std::vector<char> return_value;
   return_value.resize(return_value_size);
   eosio::get_call_return_value(return_value.data(), return_value.size());
   
   // Step 6: unpack the return value
   uint32_t output = eosio::unpack<uint32_t>(return_value);
}

access_mode in Call Wrapper

call_wrapper is defined as

template <eosio::hash_id::raw Func_Name, auto Func_Ref, access_mode Access_Mode=access_mode::read_write, support_mode Support_Mode = support_mode::abort_op>
struct call_wrapper {
   ...
}

You can use access_mode and support_mode to define a wrapper to provide finer control over sync calls for users.

enum class access_mode { read_write = 0, read_only = 1 };

access_mode indicates whether a sync call is read_write or read_only. Default is read_write. If it is read_only, the protocol will ensure state is not changed during the execution.

For example, for a table

   struct [[eosio::table]] person {
      eosio::name key;
      std::string first_name;
      std::string street;

      uint64_t primary_key() const { return key.value; }
   };

   using address_index = eosio::multi_index<"people"_n, person>;

You can define a read only sync call as

   [[eosio::call]]
   person_info get_person(eosio::name user);
   using get_person_func_read_only = eosio::call_wrapper<"get_person"_i, &callee::get_person, eosio::access_mode::read_only>;

If you set the read_only flag for a function which modifies the table

   [[eosio::call]]
   void insert_person(eosio::name user, std::string first_name, std::string street);
   using insert_person_read_only = eosio::call_wrapper<"insert_person"_i, &sync_callee::insert_person, eosio::access_mode::read_only>;

and call the function in an action

[[eosio::action]]
void insertrdonly() {
   sync_callee::insert_person_read_only{"callee"_n}("alice"_n, "alice", "123 Main St.");
}

when you push the action, you will get

error 2025-07-15T22:30:41.029 cleos     main.cpp:927                  print_result         ] soft_except->to_detail_string(): 3050007 unaccessible_api: Attempt to use unaccessible API
this API is not allowed in read only action/call
    {}
    nodeos  preconditions.hpp:123 operator()
sync call exception callee <= caller console output:
    {"receiver":"callee","sender":"caller","console":""}
    nodeos  host_context.cpp:133 execute_sync_call
caller <= caller::insertrdonly console output:
    {"console":"","account":"caller","action":"insertrdonly","receiver":"caller"}
    nodeos  apply_context.cpp:131 exec_one

support_mode in Call Wrapper

enum class support_mode { abort_op = 0, no_op = 1 };

support_mode indicates what to do if the receiver does not support sync calls, or the receiver is not deployed, or the called function does not exist.

Default is to abort the call. If you don't specify it or specify it using abort_op, and the sync call is not supported, you will get eosio_assert_message_exception with a message "receiver does not support sync call but support_mode is set to abort".

[[eosio::call]]
void abort_on_not_supported();
using abort_on_not_supported_func_1 = eosio::call_wrapper<"abort_on_not_supported"_i, &abort_on_not_supported>;  // one way
using abort_on_not_supported_func_2 = eosio::call_wrapper<"abort_on_not_supported"_i, &abort_on_not_supported, eosio::access_mode::read_write, eosio::support_mode::abort_op>;  // another way

If it is set to no_op, the wrapper is made to return a std::optional indicate the execution status. If the original function is a void, the wrapper returns std::optional<eosio::void_call>; if the original function returns a value with type orig_type, the wrapper returns std::optional<orig_type>

After execution, check the return value. If it is std::nullopt, that means the function did not execute. If it has value, that means the function executed successfully and you can use the value.

[[eosio::call]]
void no_op_on_not_supported();
using no_op_on_not_supported_func = eosio::call_wrapper<"no_op_on_not_supported"_i, &no_op_on_not_supported, eosio::access_mode::read_write, eosio::support_mode::no_op>;

no_op_on_not_supported returns std::optional<eosio::void_call>.

[[eosio::call]]
int no_op_on_not_supported();
using no_op_on_not_supported_func = eosio::call_wrapper<"no_op_on_not_supported"_i, &no_op_on_not_supported, eosio::access_mode::read_write, eosio::support_mode::no_op>;

no_op_on_not_supported returns std::optional<int>.

Details

Restrictions

The following host functions are not allowed in sync calls (but they can still be used in the calling actions). Using them in a sync call function will results in unaccessible_api exception (code 3050007).

  1. Authorization is not supported in sync calls. require_auth, require_auth2, and has_auth cannot be used in a sync call.
  2. Inline actions send_inline, send_context_free_inline and require_recipient host function cannot be used.
  3. Action related host functions are not allowed:read_action_data, action_data_size set_action_return_value, and get_action.
  4. Deferred transaction related host functions are not allowed: send_deferred and cancel_deferred.

On Chain Configuration Parameters

chain_config_v2 is used to support sync calls. Two new parameters are introduced:

  1. max_sync_call_depth: size limit for sync call depth, default is 16
  2. max_sync_call_data_size: size limit for sync call input and return value respectively, default is 512kb

Use public Inheritance to Derive from eosio::contract

Starting from CDT 5.0, a contact must derive publicly from eosio::contract.

For this privately inherited contract

class [[eosio::contract]] sync_callee : eosio::contract

you will get a compile error

<stdin>:38:5: error: 'set_exec_type' is a private member of 'eosio::contract'
obj.set_exec_type(eosio::contract::exec_type_t::call);
    ^
./sync_callee.hpp:4:41: note: constrained by implicitly private inheritance here
class [[eosio::contract]] sync_callee : eosio::contract{

Adding public before eosio::contract will resolve the compiler error

class [[eosio::contract]] sync_callee : public eosio::contract

eosio::call Attribute

Both eosio::action and eosio:call are allowed to tag on the same function.

This is useful in a read-only accessor. That allows the same function to be called from a contract as a sync call to get the return value or to be called as a top-level action of a read-only transaction to get the return value in the trace as an action return value.

For example, in the same add sync function above, you can add eosio::action attribute to the method and eosio::access_mode::read_only to the call wrapper:

[[eosio::call, eosio::action]]
uint32_t add(uint32_t x, uint32_t y);
using add_func = eosio::call_wrapper<"add"_i, &callee::add, eosio::access_mode::read_only>;

Now you can push an action directly to add, or use another action like addaction above to call it as a sync call.

Sync Call Function Names

Sync call function names can be any valid C++ identifiers with max length of 128 characters. If a call is tagged as action too, the name must follow action name's restriction.

Please note, in current release, long function names are hashed to an uint64_t value internally. If a hashing conflict happens (unlikely) for names within the same contract, CDT will report an error (xxx, yyy, zzz are place holders):

call name (xxx)'s ID yyy conflicts with a previous call name zzz. Please choose another name'

indicating which names are conflicting; the user needs to rename one of them.

State and Reentrancy

dev-1 allows reentrancy without any restrictions. In future releases, it is planned to provide a mechanism to opt in reentracncy, to avoid unexpected reentrancy problems for contracts which are not designed to support reentrancy.

Cares must be taken in coding when using a sync call that (including its children) can modify the same tables as the calling contract. Handles to tables and iterators may become invalidated after the sync call returns. They need to be reinitiated.

Failures During Sync Calls

Any failure (exception) along a chain of sync calls will fail the entire transaction. The exception will pop up along the call path.

Find the Calling Contract

To find the calling contract (parent action/sync call) of a sync call, use get_sender() host function.

Sync Call Detection

To determine whether a contract is being executed within a sync call context, use is_sync_call() (provided via eosio::contract class).

Custom Sync Call Entry Function Support

To provide developers with greater flexibility, developers are allowed to define their own sync call entry functions. In doing so, you need to pack arguments, unpack return values, and dispatch called functions on your own. CDT will not generate sync call code when detecting sync_call() entry function is present.

The following illustrates example steps.

  1. In eosio name space, define an action and a function to be called.
#include <eosio/eosio.hpp>
#include <eosio/call.hpp>

namespace eosio {

class [[eosio::contract]] custom_call_entry : public contract {
public:
   using contract::contract;

   [[eosio::action]]
   void custentryact1() {
      // Pass function ID in `data`
      const std::vector<char> data{ eosio::pack(1) }; // function ID 1
       
      // Make the sync call to "receiver"_n account. The custom entry function will dispatch
      // the call based on function ID.
      eosio::call("receiver"_n, 0, data.data(), data.size());

      // Retrieve return value and unpack it
      std::vector<char> return_value(sizeof(uint32_t));
      eosio::get_call_return_value(return_value.data(), return_value.size());

      // Verify it
      eosio::check(eosio::unpack<uint32_t>(return_value) == 10, "return value is not 10");
   }

   // `eosio::call` tag is optional
   uint32_t example_call() {
      return 10;
   }
};
} /// namespace eosio
  1. Using extern "C", define sync_call() entry function
extern "C" {
   [[eosio::wasm_entry]]
   int64_t sync_call(uint64_t sender, uint64_t receiver, uint32_t data_size) {
      // Construct contract object
      eosio::datastream<const char*> ds(nullptr, 0); // not used in this example
      eosio::custom_call_entry contract_obj(eosio::name{receiver}, eosio::name{receiver}, ds);

      // Unpack call data
      std::vector<char> data(sizeof(uint32_t));
      eosio::get_call_data(data.data(), data.size());
      auto func_id = eosio::unpack<uint32_t>(data);

      // Dispatch sync calls
      uint32_t rv = 0;
      switch (func_id) {
         case 1:  rv = contract_obj.example_call(); break;
         default: eosio::check(false, "wrong function ID");
      }

      // Set return value
      eosio::set_call_return_value(&rv, sizeof(uint32_t));

      return 0; // return 0 to indicate success
   }
}

ABI

A single calls section is added to ABI to represent sync calls.

The ABI for above add contract looks like

{
    "____comment": "This file was generated with eosio-abigen. DO NOT EDIT ",
    "version": "eosio::abi/1.3",
    "types": [],
    "structs": [
        {
            "name": "add",
            "base": "",
            "fields": [
                {
                    "name": "x",
                    "type": "uint32"
                },
                {
                    "name": "y",
                    "type": "uint32"
                }
            ]
        }
    ],
    "actions": [],
    "tables": [],
    "ricardian_clauses": [],
    "variants": [],
    "action_results": [],
    "calls": [
        {
            "name": "add",
            "type": "add",
            "id": 193486030,
            "result_type": "uint32"
        }
    ]
}

Appendix 1: Example Sync Call Contracts on Vaulta Testnet

We have deployed all the sync call test contracts used in Spring CICD to Vaulta Testnet https://testnet-1.vaulta.com/.

The source code of those contracts are https://github.com/AntelopeIO/spring/tree/release/2.0/unittests/test-contracts/sync_caller, https://github.com/AntelopeIO/spring/tree/release/2.0/unittests/test-contracts/sync_callee, and https://github.com/AntelopeIO/spring/tree/release/2.0/unittests/test-contracts/sync_callee1.

The contracts are deployed to accounts caller, callee, and callee1 respectively.

https://github.com/AntelopeIO/spring/blob/release/2.0/unittests/sync_call_cpp_tests.cpp implements test cases using those contracts. You can experiment any of those tests on the testnet.

You need to complete the following steps before running tests.

  1. Create a key pair using "Create Keys" button on the testnet.
  2. Import the private key into your wallet.
  3. Create an account using "Create Account" button.

For a test in sync_call_cpp_tests.cpp you want to run, look for push_action. By converting the arguments of push_action into cleos arguments, you run the test.

For example, the first test case in sync_call_cpp_tests.cpp pushes action basictest deployed on "caller"_n account. basictest makes a sync call to sync_callee::basictest(uint32_t input) which echos input.

BOOST_AUTO_TEST_CASE(basic_test) { try {
   call_tester_cpp t;

   int   input        = 15;
   auto  trx_trace    = t.push_action("caller"_n, "basictest"_n, "caller"_n, mvo()("input", std::             to_string(input)));
   ...

You can push the same transaction with cleos (replace <your_account> with your actual account):

./cleos -u https://api.testnet-1.vaulta.com push action caller basictest '[15]' -p <your_account>@active
executed transaction: 35e1d0ddb82775d183a21f42fff434c61a9064977ffacb16ba8677f63e383545  96 bytes  187 us
#                             caller <= caller::basictest                   {"input":15}
#                  callee::basictest <= caller                              {"input":15}
=>                                return value: 15

warning: transaction executed locally, but may not be confirmed by the network yet         ]
Clone this wiki locally