Skip to content

SC: Implement new sync call host functions #346

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jun 12, 2025
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .cicd/defaults.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"antelope-spring-dev":{
"target":"main",
"target":"sync_call",
"prerelease":false
}
}
}
65 changes: 65 additions & 0 deletions libraries/eosiolib/capi/eosio/call.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#pragma once
#include "types.h"

#ifdef __cplusplus
extern "C" {
#endif

/**
* @addtogroup call_c Call C API
* @ingroup c_api
* @brief Defines API for querying call object and making a sync call
*/

/**
* Make a sync call in the context of this call's parent action or parent call
*
* @param receiver - the name of the account that the sync call is made to
* @param flags - flags (bits) representing blockchain level requirements about
* the call. Currently LSB bit indicates read-only. All other bits
* are reserved to be 0
* @param data - the data of the sync call, which may include function name, arguments, and other information
* @return -1 if the receiver contract does not have sync call entry point or the entry point's signature is invalid, otherwise
* @return the number of bytes of the return value of the call. If the function is `void`, rturn `0`
*
*/
__attribute__((eosio_wasm_import))
int64_t call(capi_name receiver, uint64_t flags, const char* data, size_t data_size);

/**
* Copy up to `len` bytes of the return value of the most recent call to `mem`,
* in the context of this call's parent action or parent call
*
* @brief Copy the return value of the most recent call to the specified location
* @param mem - a pointer where up to `len` bytes of the return value will be copied
* @param len - length of the return value to be copied
* @return the number of bytes of the return value that can be retrieved (the number of total bytes of return value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So success is return-value <= len ?

Note read_action_data defines return as: @return the number of bytes copied to msg, or number of bytes that can be copied if len==0 passed. Seems this should behave the same way.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_call_return_value and get_call_data are designed to behave slightly different from read_action_data. The new way seems simpler to the users. It just simply returns the number of byte that can be copied.

@arhag ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition, the new host functions are designed such that the user knows call data length from the argument of the entry point, and the return value of the sync call.

Copy link
Member

@arhag arhag May 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes.

The way read_action_data works means that the caller has to intentionally decide to use a host call just for the purpose of figuring out the actual size (by using 0 for the length argument) so that it can then allocate the appropriate amount of space in WASM memory so it can call the same host function again except with the proper non-zero value for the length argument passed in this time. If it tried to be optimistic and use some pre-allocated space that it thinks is sufficient to host the to-be-returned data and it was insufficient, the returned call would copy back the partial data but it would not tell it what the actual size would be. So it would need to call it a second time to get the actual size and then a third time to get the full data.

The approach we are taking here (and I hope we take going forward for all similar host functions that need to read data of unknown size) is to provide more useful information always so that the optimistic approach can be more efficient. If it works, only one call to the host function was needed (same case with read_action_data). But if it doesn't work, then it will know what size to re-allocate to and will be able to complete the retrieval in the second host function call (whereas read_action_data would require 3 host function calls in the unhappy path).

Additionally, if a contract didn't want to guess the size to pre-allocate and always wanted to get it done in two host function calls (get size, allocate, retrieve data) but with the first call being as light weight as possible (no copying of data required) as it can currently be done with read_action_data, then it would still be possible to do so with this new pattern. Just make the first host function call with a length value of 0. That tells the host that it will not bother copying any data, because there is no space reserved to copy to (by the way, in this case we could safely use nullptr as the buffer address, right?). And so it knows all it needs to do is get the size of the data to retrieve and return it. If the implementation can be faster for that special case (e.g. if it generates the data on-demand by serializing it, then it could use a datastream serializer that doesn't actually serialize but just counts the bytes the serialization would use), then the implementation has the opportunity (though not requirement) to use that faster approach for the special case where the length value is 0.

It is unfortunate that all of our unknown sized data retrieval host functions don't work the same way (and in particular this new way). But I don't want to keep sticking with an inferior way for consistency reasons. For consistency reasons, we can adopt the new way going forward and accept that the legacy methods will remain inconsistent.

*/
__attribute__((eosio_wasm_import))
uint32_t get_call_return_value( void* mem, uint32_t len );

/**
* Copy up to `len` bytes of the current call data to `mem`
*
* @brief Copy current call data to the specified location
* @param mem - a pointer where up to `len` bytes of the current call data will be copied
* @param len - length of the current call data to be copied
* @return the number of bytes of the data that can be retrieved (the number of total bytes of the data)
*/
__attribute__((eosio_wasm_import))
uint32_t get_call_data( void* mem, uint32_t len );

/**
* Set the return value from `mem` with `len` bytes. This is to be retrieved by parent action
* or parent call using `get_call_return_value`
*
* @brief Copy the return value from the specified location
* @param mem - a pointer where `len` bytes of the return value will be copied from
*/
__attribute__((eosio_wasm_import))
void set_call_return_value( void* mem, uint32_t len );

#ifdef __cplusplus
}
#endif
/// @} call
110 changes: 110 additions & 0 deletions libraries/eosiolib/contracts/eosio/call.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* @file
*/
#pragma once
#include <cstdlib>
#include <type_traits>

#include "../../core/eosio/serialize.hpp"
#include "../../core/eosio/datastream.hpp"
#include "../../core/eosio/name.hpp"

namespace eosio {

namespace internal_use_do_not_use {
extern "C" {
__attribute__((eosio_wasm_import))
int64_t call(uint64_t receiver, uint64_t flags, const char* data, size_t data_size);

__attribute__((eosio_wasm_import))
uint32_t get_call_return_value( void* mem, uint32_t len );

__attribute__((eosio_wasm_import))
uint32_t get_call_data( void* mem, uint32_t len );

__attribute__((eosio_wasm_import))
void set_call_return_value( void* mem, uint32_t len );
}
};

/**
* @defgroup call call
* @ingroup contracts
* @brief Defines type-safe C++ wrappers for querying call and sending call
* @note There are some methods from the @ref call that can be used directly from C++
*/

inline uint32_t get_call_return_value( void* mem, uint32_t len ) {
return internal_use_do_not_use::get_call_return_value(mem, len);
}

inline uint32_t get_call_data( void* mem, uint32_t len ) {
return internal_use_do_not_use::get_call_data(mem, len);
}

inline void set_call_return_value( void* mem, uint32_t len ) {
internal_use_do_not_use::set_call_return_value(mem, len);
}

/**
* This is the packed representation of a call
*
* @ingroup call
*/
struct call {
/**
* Name of the account the call is intended for
*/
const name receiver{};

/**
* indicating if the call is read only or not
*/
const bool read_only = false;

/**
* if the receiver contract does not have sync_call entry point or its signature
* is invalid, when no_op_if_receiver_not_support_sync_call is set to true,
* the sync call is no op, otherwise the call is aborted and an exception is raised.
*/
const bool no_op_if_receiver_not_support_sync_call = false;

/**
* Payload data
*/
const std::vector<char> data{};

/**
* Construct a new call object with receiver, name, and payload data
*
* @tparam T - Type of call data, must be serializable by `pack(...)`
* @param receiver - The name of the account this call is intended for
* @param flags - The flags
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

 *  @param flags - flags (bits) representing blockchain level requirements about
 *                 the call. Currently LSB bit indicates read-only. All other bits
 *                 are reserved to be 0

Although, since this is suppose to be an easy to use wrapper. I think this should just be a bool read_only. And call can create the correct flags.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This call class is low level. I chose to keep it closer to the host function parameters, while make the to-be-implemented wrapper to take read_only argument.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think about it more and agree with you. I have changed to take in read_only and another flag no_op_if_receiver_not_support_sync_call as parameters. Thanks.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update the comment please.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed the bools to enums in #351. I have updated the comment there.

* @param payload - The call data that will be serialized via pack into data
*/
template<typename T>
call( struct name receiver, T&& payload, bool read_only = false, bool no_op = false )
: receiver(receiver)
, read_only(read_only)
, no_op_if_receiver_not_support_sync_call(no_op)
, data(pack(std::forward<T>(payload))) {}

/// @cond INTERNAL
EOSLIB_SERIALIZE( call, (receiver)(read_only)(no_op_if_receiver_not_support_sync_call)(data) )
/// @endcond

/**
* Make a call using the functor operator
*/
int64_t operator()() const {
uint64_t flags = read_only ? 0x01 : 0x00; // last bit indicating read only
auto retval = internal_use_do_not_use::call(receiver.value, flags, data.data(), data.size());

if (retval == -1) { // sync call is not supported by the receiver contract
check(no_op_if_receiver_not_support_sync_call, "receiver does not support sync call but no_op_if_receiver_not_support_sync_call flag is not set");
}
return retval;
}
};

} // namespace eosio
Loading