AppLayerLabs / bdk-cpp

MIT License
7 stars 12 forks source link

SDK Test Suite Implementation #86

Closed itamarcps closed 7 months ago

itamarcps commented 7 months ago

SDK Test Suite Implementation

This pull request introduces a new class within our tests: SDKTestSuite. This class seamlessly manages blockchain components (DB, State, P2P, rdPoS, State, Options) and offers a straightforward approach for performing transactions, creating contracts, and calling contract functions/events. Below is an example that demonstrates deploying an ERC20 contract and executing the transfer function, showcasing a significant ease of use compared to our previous environment.

Address destinationOfTransfer = Address(Utils::randBytes(20));
SDKTestSuite sdkTestSuite("testSuiteDeployAndCall");
Address newContract = sdkTestSuite.deployContract<ERC20>(std::string("ERC20"), std::string("ERC20"), uint8_t(18), uint256_t("1000000000000000000"));
auto balance = sdkTestSuite.callViewFunction(newContract, &ERC20::balanceOf, sdkTestSuite.getChainOwnerAccount().address)

Implementation Details:

SDKTestSuite has a single constructor, declared as follows:

SDKTestSuite(const std::string& sdkPath,
const std::vector<TestAccount>& accounts = {},
const std::unique_ptr<Options>& options = nullptr) { /* implementation */ }

For most cases, using SDKTestSuite("folderPath") suffices. However, it is possible to supply a list of TestAccount and Options to initialize the blockchain, where accounts in the list will receive 1000000000000000000000 (wei) in native tokens. A newly constructed TestSuite will always have a clear DB/chain.

The TestAccount struct is defined as:

struct TestAccount {
  const PrivKey privKey;
  const Address address;
  TestAccount() = default;
  TestAccount(const PrivKey& privKey_) : privKey(privKey_), address(Secp256k1::toAddress(Secp256k1::toPub(privKey))) {}
  static TestAccount newRandomAccount() {
    return TestAccount(Utils::randBytes(32));
  } 
  explicit operator bool() const { return bool(this->privKey); }
};

SDKTestSuite includes a default TestAccount, named chainOwnerAccount_, which is equivalent to the chain owner for testnet defaults (0x00dead00665771855a34155f5e7405489df2c3c6). This account is used for all functions requiring a transaction signature, unless another TestAccount is specified in the function argument.

SDKTestSuite::advanceChain() is the primary function for progressing the chain. It generates a new valid block (adhering to rdPoS rules), validates it, and adds it to the blockchain. If an automatically generated block is invalid, it will throw an exception; this is unlikely but serves as a safety measure.

Template function declarations for contract interactions are as follows:

/**
 * Create a transaction to deploy a new contract and advance the chain with it.
 * Always use the chain owner account to deploy contracts.s
 * @tparam TContract Contract type to deploy.
 * @tparam Args... Arguments to pass to the contract constructor.
 * @return Address of the deployed contract.
 */
template <typename TContract, typename ...Args>
const Address deployContract(Args&&... args);

/**
 * Create a transaction to call a contract function and advance the chain with it.
 * Specialization for function with args
 * We are not able to set default values like in the other specialization because of the variadic template.
 * Therefore we need functions with no value/testAccount/timestamp parameters.
 * @tparam R Return type of the function.
 * @tparam TContract Contract type to call.
 * @tparam Args... Arguments to pass to the function.
 * @param contractAddress Address of the contract to call.
 * @param value Value to send with the transaction.
 * @param testAccount Account to send the transaction from.
 * @param timestamp Timestamp to use for the transaction in microseconds.
 * @param func Function to call.
 * @param args Arguments to pass to the function.
 */
template <typename R, typename TContract, typename ...Args>
const Hash callFunction(const Address& contractAddress,
                        const uint256_t& value,
                        const TestAccount& testAccount,
                        const uint64_t& timestamp,
                        R(TContract::*func)(const Args&...),
                        const Args&... args)

/**
 * Call a contract view function with args and return the result.
 * @tparam R Return type of the function.
 * @tparam TContract Contract type to call.
 * @tparam Args... Arguments to pass to the function.
 * @param contractAddress Address of the contract to call.
 * @param func Function to call.
 * @param args Arguments to pass to the function.
 * @return The result of the function call. (R)
 */
template <typename R, typename TContract, typename ...Args>
const R callViewFunction(const Address& contractAddress,
                         R(TContract::*func)(const Args&...) const,
                         const Args&... args)

The usage can be exemplified as follows:

sdkTestSuite.deployContract<ERC20>(std::string("ERC20"), std::string("ERC20"), uint8_t(18), uint256_t("1000000000000000000"));
sdkTestSuite.callFunction(newContract, &ERC20::transfer, destinationOfTransfer, uint256_t("10000000000000000"));
sdkTestSuite.callViewFunction(newContract, &ERC20::balanceOf, destinationOfTransfer);

Please refer to the implementation for a comprehensive understanding of all overloaded types and function implementations. For instance, callFunction has 9 different implementations to cover scenarios where the value, testAccount, and timestamp arguments are omitted, defaulting them to 0, the chain owner TestAccount, and std::chrono::high_resolution_clock::now(), respectively.

To retrieve specific events emitted by a transaction, developers should use the following function:

/**
* Retrieve specific events emitted by a confirmed transaction.
* @tparam TContract Contract type.
* @tparam Args... Arguments for the EventParam.
* @tparam Flags... Flags for the EventParam.
* @param txHash Transaction hash to search for events.
* @param func Function to look for.
* @param anonymous (optional) Specify if the event is anonymous.
  */
  template <typename TContract, typename... Args, bool... Flags>
  std::vector<Event> getEventsEmittedByTx(const Hash& txHash,
  void(TContract::*func)(const EventParam<Args, Flags>&...),
  const std::tuple<EventParam<Args, Flags>...>& args,
  bool anonymous = false)

This can be invoked as follows:

auto events = sdkTestSuite.getEventsEmittedByTx(changeNameAndValueTx, &SimpleContract::nameChanged);
auto filteredEvents = sdkTestSuite.getEventsEmittedByTx(changeNameAndValueTx, &SimpleContract::nameChanged, std::make_tuple(EventParam<std::string, true>("Hello World 2!")));

For a detailed understanding, please consult the implementation for all declarations.

Points for Discussion:

TODO:

/**
* Proposed template to return a tuple of event arguments.
  */
  template <typename TContract, typename... Args, bool... Flags>
  std::tuple<Args...> getEventsEmittedByTx(const Hash& txHash,
  void(TContract::*func)(const EventParam<Args, Flags>&...),
  bool anonymous = false)
  {
    /// How we can derive the tuple from the function pointer?
    /// Remember that we need to ignore the indexed arguments/not return them.
  }