From df845c65a554dbc6a517dc01c7e1e6e08deb61ec Mon Sep 17 00:00:00 2001 From: Petar Radovic Date: Wed, 29 Jul 2026 11:50:53 +0200 Subject: [PATCH] feat: add missing ffi impl wrappers (#57) --- flake.nix | 2 +- src/logos_blockchain_module.cpp | 182 ++++++++++++-- src/logos_blockchain_module.h | 41 ++- tests/event_stubs.cpp | 17 ++ tests/mocks/mock_logos_blockchain.cpp | 74 ++++++ tests/stubs/logos_blockchain.h | 27 +- tests/test_blockchain.cpp | 342 ++++++++++++++++++++++++++ 7 files changed, 667 insertions(+), 18 deletions(-) diff --git a/flake.nix b/flake.nix index 585484f..aa94818 100644 --- a/flake.nix +++ b/flake.nix @@ -3,7 +3,7 @@ inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder/0.2.4"; - logos-blockchain.url = "github:logos-blockchain/logos-blockchain?ref=ec648fa7239576dccb9aa8b684d4a780c9064797"; + logos-blockchain.url = "github:logos-blockchain/logos-blockchain?ref=133570d58a141629a0c751fdf4412a66898acac2"; }; outputs = inputs@{ logos-module-builder, ... }: diff --git a/src/logos_blockchain_module.cpp b/src/logos_blockchain_module.cpp index 2c208db..c716055 100644 --- a/src/logos_blockchain_module.cpp +++ b/src/logos_blockchain_module.cpp @@ -263,15 +263,45 @@ namespace { } // namespace void LogosBlockchainModule::on_new_block_callback(const char* block) { - if (s_instance) { - fprintf(stderr, "Received new block: %s\n", block); - json j; - j["block"] = std::string(block); - s_instance->newBlock(j.dump()); - // SAFETY: - // We are getting an owned pointer here which is freed after this callback is called, so there is no need to - // free the resource here as we are copying the data! + if (!s_instance || !block) { + return; } + fprintf(stderr, "Received new block: %s\n", block); + json j; + j["block"] = std::string(block); + s_instance->newBlock(j.dump()); + // SAFETY: + // We are getting an owned pointer here which is freed after this callback is called, so there is no need to + // free the resource here as we are copying the data! +} + +// The stream callbacks pass the FFI's JSON through unwrapped (it is already a +// complete JSON document with the node's HTTP stream schema). A NULL pointer +// means the stream ended; it is forwarded as the JSON literal `null` so +// termination stays in-band on the same event. + +void LogosBlockchainModule::on_processed_block_callback(const char* event) { + if (!s_instance) { + return; + } + if (!event) { + fprintf(stderr, "Processed block stream ended.\n"); + s_instance->processedBlock("null"); + return; + } + s_instance->processedBlock(std::string(event)); +} + +void LogosBlockchainModule::on_lib_block_callback(const char* event) { + if (!s_instance) { + return; + } + if (!event) { + fprintf(stderr, "LIB block stream ended.\n"); + s_instance->libBlock("null"); + return; + } + s_instance->libBlock(std::string(event)); } LogosBlockchainModule::LogosBlockchainModule() { @@ -414,7 +444,15 @@ StdLogosResult LogosBlockchainModule::start(const std::string& config_path, cons s_instance = this; OperationStatus subscribe_status = subscribe_to_new_blocks(node, on_new_block_callback); - return result::from_operation_status(subscribe_status); + if (!is_ok(&subscribe_status)) { + return result::err(operation_status::take_message(subscribe_status)); + } + OperationStatus processed_status = subscribe_to_processed_blocks(node, on_processed_block_callback); + if (!is_ok(&processed_status)) { + return result::err(operation_status::take_message(processed_status)); + } + OperationStatus lib_status = subscribe_to_lib_blocks(node, on_lib_block_callback); + return result::from_operation_status(lib_status); } StdLogosResult LogosBlockchainModule::stop() { @@ -906,6 +944,31 @@ StdLogosResult LogosBlockchainModule::channel_deposit_with_notes( return result::ok(bytes_to_hex(reinterpret_cast(&value), ADDRESS_BYTES)); } +StdLogosResult LogosBlockchainModule::get_channel_state(const std::string& channel_id_hex) const { + if (!node) { + return result::err("The node is not running."); + } + + const std::vector bytes = parse_address_hex(channel_id_hex); + if (bytes.empty() || static_cast(bytes.size()) != ADDRESS_BYTES) { + return result::err("Invalid channel_id (64 hex characters required)."); + } + + auto [value, error] = ::get_channel_state(node, bytes.data()); + if (!is_ok(&error)) { + return result::err(operation_status::take_message(error)); + } + + std::string out(value); + OperationStatus free_status = free_cstring(value); + if (!is_ok(&free_status)) { + fprintf( + stderr, "Failed to free channel state string: %s\n", operation_status::take_message(free_status).c_str() + ); + } + return result::ok(std::move(out)); +} + StdLogosResult LogosBlockchainModule::wallet_get_claimable_vouchers() const { if (!node) { return result::err("The node is not running."); @@ -936,6 +999,38 @@ StdLogosResult LogosBlockchainModule::wallet_get_claimable_vouchers() const { return result::ok(obj.dump()); } +StdLogosResult LogosBlockchainModule::wallet_fund_tx(const std::string& request_json) const { + if (!node) { + return result::err("The node is not running."); + } + + auto [value, error] = ::wallet_fund_tx(node, request_json.c_str()); + if (!is_ok(&error)) { + return result::err(operation_status::take_message(error)); + } + + std::string out(value); + OperationStatus free_status = free_cstring(value); + if (!is_ok(&free_status)) { + fprintf(stderr, "Failed to free funded tx string: %s\n", operation_status::take_message(free_status).c_str()); + } + return result::ok(std::move(out)); +} + +// Transactions + +StdLogosResult LogosBlockchainModule::submit_signed_transaction(const std::string& signed_tx_json) const { + if (!node) { + return result::err("The node is not running."); + } + + auto [value, error] = ::submit_signed_transaction(node, signed_tx_json.c_str()); + if (!is_ok(&error)) { + return result::err(operation_status::take_message(error)); + } + return result::ok(bytes_to_hex(reinterpret_cast(&value), TX_HASH_BYTES)); +} + // Blend StdLogosResult LogosBlockchainModule::blend_join_as_core_node( @@ -955,11 +1050,7 @@ StdLogosResult LogosBlockchainModule::blend_join_as_core_node( return result::err("Invalid locked_note_id_hex (64 hex characters required)."); } - auto [value, error] = ::blend_join_as_core_node( - node, - locator.c_str(), - locked_note_id_bytes.data() - ); + auto [value, error] = ::blend_join_as_core_node(node, locator.c_str(), locked_note_id_bytes.data()); if (!is_ok(&error)) { return result::err(operation_status::take_message(error)); } @@ -1049,10 +1140,21 @@ StdLogosResult LogosBlockchainModule::get_cryptarchia_info() const { json obj; obj["lib"] = bytes_to_hex(reinterpret_cast(value->lib), ADDRESS_BYTES); + obj["lib_slot"] = static_cast(value->lib_slot); obj["tip"] = bytes_to_hex(reinterpret_cast(value->tip), ADDRESS_BYTES); obj["slot"] = static_cast(value->slot); obj["height"] = static_cast(value->height); - obj["mode"] = (value->mode == State::Online) ? "Online" : "Bootstrapping"; + switch (value->mode) { + case State::Online: + obj["mode"] = "Online"; + break; + case State::NotStarted: + obj["mode"] = "NotStarted"; + break; + default: + obj["mode"] = "Bootstrapping"; + break; + } OperationStatus free_status = free_cryptarchia_info(value); if (!is_ok(&free_status)) { @@ -1060,3 +1162,53 @@ StdLogosResult LogosBlockchainModule::get_cryptarchia_info() const { } return result::ok(obj.dump()); } + +StdLogosResult LogosBlockchainModule::get_block_events(const std::string& header_id_hex) const { + if (!node) { + return result::err("The node is not running."); + } + + const std::vector bytes = parse_address_hex(header_id_hex); + if (bytes.empty() || static_cast(bytes.size()) != ADDRESS_BYTES) { + return result::err("Header ID must be 64 hex characters (32 bytes)."); + } + + auto [value, error] = ::get_block_events(node, reinterpret_cast(bytes.data())); + if (!is_ok(&error)) { + return result::err(operation_status::take_message(error)); + } + + std::string out(value); + OperationStatus free_status = free_cstring(value); + if (!is_ok(&free_status)) { + fprintf( + stderr, "Failed to free block events string: %s\n", operation_status::take_message(free_status).c_str() + ); + } + return result::ok(std::move(out)); +} + +// Time + +StdLogosResult LogosBlockchainModule::get_time_info() const { + if (!node) { + return result::err("The node is not running."); + } + + auto [value, error] = ::get_time_info(node); + if (!is_ok(&error)) { + return result::err(operation_status::take_message(error)); + } + + json obj; + obj["slot_duration_ms"] = static_cast(value->slot_duration_ms); + obj["genesis_time_unix_ms"] = value->genesis_time_unix_ms; + obj["current_slot"] = static_cast(value->current_slot); + obj["current_epoch"] = value->current_epoch; + + OperationStatus free_status = free_time_info(value); + if (!is_ok(&free_status)) { + fprintf(stderr, "Failed to free time info: %s\n", operation_status::take_message(free_status).c_str()); + } + return result::ok(obj.dump()); +} diff --git a/src/logos_blockchain_module.h b/src/logos_blockchain_module.h index b3e9d6d..e7e7388 100644 --- a/src/logos_blockchain_module.h +++ b/src/logos_blockchain_module.h @@ -99,6 +99,16 @@ public: ) const; [[nodiscard]] StdLogosResult leader_claim() const; [[nodiscard]] StdLogosResult wallet_get_claimable_vouchers() const; + // Funds an unsigned transaction: request_json is passed through to the + // node's wallet fund endpoint (same JSON schema as the HTTP `/wallet/fund` + // request body); returns the funded transaction as JSON. + [[nodiscard]] StdLogosResult wallet_fund_tx(const std::string& request_json) const; + + // Transactions + // Submits a signed transaction: signed_tx_json is passed through to the + // node (same JSON schema as the HTTP `/mantle/transact` request body); + // returns the transaction hash hex on success. + [[nodiscard]] StdLogosResult submit_signed_transaction(const std::string& signed_tx_json) const; // Channel // Amount-based deposit: the binding selects funding notes itself (splitting a @@ -128,6 +138,11 @@ public: const std::string& optional_tip_hex ) const; + // State of the channel with the given 32-byte channel ID, as JSON (same + // schema as the node's `/mantle/channel/{id}` HTTP endpoint). Fails with a + // not-found error when the channel does not exist yet. + [[nodiscard]] StdLogosResult get_channel_state(const std::string& channel_id_hex) const; + // Blend [[nodiscard]] StdLogosResult blend_join_as_core_node( const std::string& locator, @@ -141,6 +156,13 @@ public: // Cryptarchia [[nodiscard]] StdLogosResult get_cryptarchia_info() const; + // Events emitted by the block with the given 32-byte header ID, as JSON. + [[nodiscard]] StdLogosResult get_block_events(const std::string& header_id_hex) const; + + // Time + // Consensus time info as JSON: + // { slot_duration_ms, genesis_time_unix_ms, current_slot, current_epoch } + [[nodiscard]] StdLogosResult get_time_info() const; // clang-format off // Clang-format only handles public/private/protected, so it miss-indents this section. @@ -150,6 +172,19 @@ logos_events: // blockJson is the full block serialized as JSON. // ReSharper disable once CppFunctionIsNotImplemented void newBlock(const std::string& blockJson); + // Fired per processed block. eventJson carries the block plus the chain + // state after processing it (same schema as the node's + // `/cryptarchia/blocks/stream` HTTP endpoint; transaction ids at + // `transactions[].mantle_tx.hash`). When the stream ends, fired exactly + // once with the JSON literal `null` — restart the node subscription (via + // stop/start) to keep receiving events. + // ReSharper disable once CppFunctionIsNotImplemented + void processedBlock(const std::string& eventJson); + // Fired per newly finalized (LIB) block. blockInfoJson uses the same + // schema as the node's `/cryptarchia/lib/stream` HTTP endpoint. When the + // stream ends, fired exactly once with the JSON literal `null`. + // ReSharper disable once CppFunctionIsNotImplemented + void libBlock(const std::string& blockInfoJson); // clang-format on private: @@ -158,6 +193,10 @@ private: // Static instance for C callback (C API doesn't support user data) static LogosBlockchainModule* s_instance; - // C-compatible callback function + // C-compatible callback functions. The stream callbacks receive NULL + // exactly once when their stream ends; that is forwarded as the JSON + // literal `null` on the corresponding event. static void on_new_block_callback(const char* block); + static void on_processed_block_callback(const char* event); + static void on_lib_block_callback(const char* event); }; diff --git a/tests/event_stubs.cpp b/tests/event_stubs.cpp index f68110c..cb6a741 100644 --- a/tests/event_stubs.cpp +++ b/tests/event_stubs.cpp @@ -12,6 +12,23 @@ #include "logos_blockchain_module.h" +// Recorded payloads of the most recent stream events, so tests can assert what +// the trampolines forwarded (including the `null` end-of-stream sentinel). +std::string g_lastNewBlockEventJson; +std::string g_lastProcessedBlockEventJson; +std::string g_lastLibBlockEventJson; + void LogosBlockchainModule::newBlock(const std::string& blockJson) { + g_lastNewBlockEventJson = blockJson; emitEventImpl_("newBlock", nullptr); } + +void LogosBlockchainModule::processedBlock(const std::string& eventJson) { + g_lastProcessedBlockEventJson = eventJson; + emitEventImpl_("processedBlock", nullptr); +} + +void LogosBlockchainModule::libBlock(const std::string& blockInfoJson) { + g_lastLibBlockEventJson = blockInfoJson; + emitEventImpl_("libBlock", nullptr); +} diff --git a/tests/mocks/mock_logos_blockchain.cpp b/tests/mocks/mock_logos_blockchain.cpp index 965efee..dbc02db 100644 --- a/tests/mocks/mock_logos_blockchain.cpp +++ b/tests/mocks/mock_logos_blockchain.cpp @@ -16,6 +16,12 @@ std::string g_lastGeneratedStatePath; std::string g_lastGeneratedStoragePath; std::string g_lastGeneratedLogsPath; +// Captures the callbacks passed to the subscription calls so tests can drive +// the streams (including the NULL end-of-stream sentinel). +BlockCallback g_lastNewBlockCallback = nullptr; +BlockCallback g_lastProcessedBlockCallback = nullptr; +BlockCallback g_lastLibBlockCallback = nullptr; + static char s_fakeNode = 0; static CryptarchiaInfo s_fakeCryptarchiaInfo = {}; @@ -137,9 +143,22 @@ StringResult get_peer_id(const char* config_path) { OperationStatus subscribe_to_new_blocks(LogosBlockchainNode* node, BlockCallback callback) { LOGOS_CMOCK_RECORD("subscribe_to_new_blocks"); + g_lastNewBlockCallback = callback; return make_status(LOGOS_CMOCK_RETURN(int, "subscribe_to_new_blocks")); } +OperationStatus subscribe_to_processed_blocks(LogosBlockchainNode* node, BlockCallback callback) { + LOGOS_CMOCK_RECORD("subscribe_to_processed_blocks"); + g_lastProcessedBlockCallback = callback; + return make_status(LOGOS_CMOCK_RETURN(int, "subscribe_to_processed_blocks")); +} + +OperationStatus subscribe_to_lib_blocks(LogosBlockchainNode* node, BlockCallback callback) { + LOGOS_CMOCK_RECORD("subscribe_to_lib_blocks"); + g_lastLibBlockCallback = callback; + return make_status(LOGOS_CMOCK_RETURN(int, "subscribe_to_lib_blocks")); +} + BalanceResult get_balance(LogosBlockchainNode* node, const uint8_t* address, const void* reserved) { LOGOS_CMOCK_RECORD("get_balance"); BalanceResult result; @@ -218,6 +237,15 @@ OperationStatus free_claimable_vouchers(ClaimableVouchers vouchers) { return make_status(0); } +StringResult get_channel_state(LogosBlockchainNode* node, const uint8_t* channel_id) { + LOGOS_CMOCK_RECORD("get_channel_state"); + StringResult result; + const char* json = LOGOS_CMOCK_RETURN_STRING("get_channel_state"); + result.value = json ? strdup(json) : nullptr; + result.error = make_status(LOGOS_CMOCK_RETURN(int, "get_channel_state_error")); + return result; +} + // Wallet-notes mock storage (up to 4 notes) static WalletNote s_mockNotes[4]; @@ -251,6 +279,23 @@ OperationStatus free_wallet_notes(WalletNotes notes) { return make_status(0); } +StringResult wallet_fund_tx(LogosBlockchainNode* node, const char* request_json) { + LOGOS_CMOCK_RECORD("wallet_fund_tx"); + StringResult result; + const char* json = LOGOS_CMOCK_RETURN_STRING("wallet_fund_tx"); + result.value = json ? strdup(json) : nullptr; + result.error = make_status(LOGOS_CMOCK_RETURN(int, "wallet_fund_tx_error")); + return result; +} + +SubmitTransactionResult submit_signed_transaction(LogosBlockchainNode* node, const char* signed_tx_json) { + LOGOS_CMOCK_RECORD("submit_signed_transaction"); + SubmitTransactionResult result; + memset(result.value, 0xFA, sizeof(Hash)); + result.error = make_status(LOGOS_CMOCK_RETURN(int, "submit_signed_transaction_error")); + return result; +} + FfiChannelDepositResult channel_deposit(LogosBlockchainNode* node, const ChannelDepositArguments* arguments) { LOGOS_CMOCK_RECORD("channel_deposit"); FfiChannelDepositResult result; @@ -313,6 +358,7 @@ CryptarchiaInfoResult get_cryptarchia_info(LogosBlockchainNode* node) { LOGOS_CMOCK_RECORD("get_cryptarchia_info"); CryptarchiaInfoResult result; memset(&s_fakeCryptarchiaInfo, 0, sizeof(s_fakeCryptarchiaInfo)); + s_fakeCryptarchiaInfo.lib_slot = static_cast(LOGOS_CMOCK_RETURN(int, "cryptarchia_lib_slot")); s_fakeCryptarchiaInfo.slot = static_cast(LOGOS_CMOCK_RETURN(int, "cryptarchia_slot")); s_fakeCryptarchiaInfo.height = static_cast(LOGOS_CMOCK_RETURN(int, "cryptarchia_height")); s_fakeCryptarchiaInfo.mode = static_cast(LOGOS_CMOCK_RETURN(int, "cryptarchia_mode")); @@ -328,6 +374,34 @@ OperationStatus free_cryptarchia_info(CryptarchiaInfo* info) { return make_status(0); } +StringResult get_block_events(LogosBlockchainNode* node, const HeaderId* header_id) { + LOGOS_CMOCK_RECORD("get_block_events"); + StringResult result; + const char* json = LOGOS_CMOCK_RETURN_STRING("get_block_events"); + result.value = json ? strdup(json) : nullptr; + result.error = make_status(LOGOS_CMOCK_RETURN(int, "get_block_events_error")); + return result; +} + +static TimeInfo s_fakeTimeInfo = {}; + +TimeInfoResult get_time_info(LogosBlockchainNode* node) { + LOGOS_CMOCK_RECORD("get_time_info"); + TimeInfoResult result; + s_fakeTimeInfo.slot_duration_ms = static_cast(LOGOS_CMOCK_RETURN(int, "time_slot_duration_ms")); + s_fakeTimeInfo.genesis_time_unix_ms = static_cast(LOGOS_CMOCK_RETURN(int, "time_genesis_time_unix_ms")); + s_fakeTimeInfo.current_slot = static_cast(LOGOS_CMOCK_RETURN(int, "time_current_slot")); + s_fakeTimeInfo.current_epoch = static_cast(LOGOS_CMOCK_RETURN(int, "time_current_epoch")); + result.value = &s_fakeTimeInfo; + result.error = make_status(LOGOS_CMOCK_RETURN(int, "get_time_info_error")); + return result; +} + +OperationStatus free_time_info(TimeInfo* info) { + LOGOS_CMOCK_RECORD("free_time_info"); + return make_status(0); +} + OperationStatus free_cstring(char* s) { LOGOS_CMOCK_RECORD("free_cstring"); free(s); diff --git a/tests/stubs/logos_blockchain.h b/tests/stubs/logos_blockchain.h index 1bafce2..d3c35c5 100644 --- a/tests/stubs/logos_blockchain.h +++ b/tests/stubs/logos_blockchain.h @@ -45,7 +45,7 @@ typedef struct { } OperationStatus; // Consensus state enum -typedef enum { Bootstrapping, Online } State; +typedef enum { Bootstrapping, Online, NotStarted } State; // Key type for generate_key / add_key typedef enum { Ed25519, Zk } KeyType; @@ -134,12 +134,21 @@ typedef struct { // Cryptarchia consensus info typedef struct { uint8_t lib[32]; + uint64_t lib_slot; uint8_t tip[32]; uint64_t slot; uint64_t height; State mode; } CryptarchiaInfo; +// Time service info +typedef struct { + uint64_t slot_duration_ms; + int64_t genesis_time_unix_ms; + uint64_t current_slot; + uint32_t current_epoch; +} TimeInfo; + // Result types (C++ structured bindings decompose these) typedef struct { LogosBlockchainNode* value; OperationStatus error; } NodeResult; typedef struct { uint64_t value; OperationStatus error; } BalanceResult; @@ -152,6 +161,8 @@ typedef struct { ClaimableVouchers value; OperationStatus error; } FfiClaimableV typedef struct { Hash value; OperationStatus error; } BlendHashResult; typedef struct { char* value; OperationStatus error; } StringResult; typedef struct { CryptarchiaInfo* value; OperationStatus error; } CryptarchiaInfoResult; +typedef struct { TimeInfo* value; OperationStatus error; } TimeInfoResult; +typedef struct { Hash value; OperationStatus error; } SubmitTransactionResult; // Block event callback typedef void (*BlockCallback)(const char* block_json); @@ -164,6 +175,10 @@ OperationStatus generate_user_config(GenerateConfigArgs args); NodeResult start_lb_node(const char* config_path, const char* deployment); OperationStatus shutdown_node(LogosBlockchainNode* node); OperationStatus subscribe_to_new_blocks(LogosBlockchainNode* node, BlockCallback callback); +// Streams: each event is a JSON C string; the callback is invoked exactly once +// with NULL when the stream ends. +OperationStatus subscribe_to_processed_blocks(LogosBlockchainNode* node, BlockCallback callback); +OperationStatus subscribe_to_lib_blocks(LogosBlockchainNode* node, BlockCallback callback); // Config management OperationStatus update_user_config(const char* user_config_path, const char* keystore_path); @@ -209,6 +224,10 @@ FfiWalletNotesResult get_wallet_notes( const uint8_t* wallet_address, const HeaderId* optional_tip); OperationStatus free_wallet_notes(WalletNotes notes); +StringResult wallet_fund_tx(LogosBlockchainNode* node, const char* request_json); + +// Transactions +SubmitTransactionResult submit_signed_transaction(LogosBlockchainNode* node, const char* signed_tx_json); // Channel FfiChannelDepositResult channel_deposit(LogosBlockchainNode* node, const ChannelDepositArguments* arguments); @@ -217,6 +236,7 @@ FfiChannelDepositResult channel_deposit_with_notes( const ChannelDepositWithNotesArguments* arguments); FfiClaimableVouchersResult get_claimable_vouchers(LogosBlockchainNode* node, const HeaderId* optional_tip); OperationStatus free_claimable_vouchers(ClaimableVouchers vouchers); +StringResult get_channel_state(LogosBlockchainNode* node, const uint8_t* channel_id); // Blend BlendHashResult blend_join_as_core_node( @@ -232,6 +252,11 @@ StringResult get_transaction(LogosBlockchainNode* node, const TxHash* tx_hash); // Cryptarchia CryptarchiaInfoResult get_cryptarchia_info(LogosBlockchainNode* node); OperationStatus free_cryptarchia_info(CryptarchiaInfo* info); +StringResult get_block_events(LogosBlockchainNode* node, const HeaderId* header_id); + +// Time +TimeInfoResult get_time_info(LogosBlockchainNode* node); +OperationStatus free_time_info(TimeInfo* info); // Memory management OperationStatus free_cstring(char* s); diff --git a/tests/test_blockchain.cpp b/tests/test_blockchain.cpp index 96b0c39..2a69f74 100644 --- a/tests/test_blockchain.cpp +++ b/tests/test_blockchain.cpp @@ -321,6 +321,42 @@ LOGOS_TEST(get_cryptarchia_info_without_node_returns_error) { LOGOS_ASSERT_FALSE(module.get_cryptarchia_info().success); } +LOGOS_TEST(get_block_events_without_node_returns_error) { + auto t = LogosTestContext("blockchain_module"); + LogosBlockchainModule module; + LOGOS_ASSERT_FALSE(module.get_block_events(VALID_HEX).success); +} + +LOGOS_TEST(get_time_info_without_node_returns_error) { + auto t = LogosTestContext("blockchain_module"); + LogosBlockchainModule module; + LOGOS_ASSERT_FALSE(module.get_time_info().success); +} + +LOGOS_TEST(get_channel_state_without_node_returns_error) { + auto t = LogosTestContext("blockchain_module"); + LogosBlockchainModule module; + StdLogosResult result = module.get_channel_state(VALID_HEX); + LOGOS_ASSERT_FALSE(result.success); + LOGOS_ASSERT_TRUE(contains(result.error, "not running")); +} + +LOGOS_TEST(wallet_fund_tx_without_node_returns_error) { + auto t = LogosTestContext("blockchain_module"); + LogosBlockchainModule module; + StdLogosResult result = module.wallet_fund_tx("{}"); + LOGOS_ASSERT_FALSE(result.success); + LOGOS_ASSERT_TRUE(contains(result.error, "not running")); +} + +LOGOS_TEST(submit_signed_transaction_without_node_returns_error) { + auto t = LogosTestContext("blockchain_module"); + LogosBlockchainModule module; + StdLogosResult result = module.submit_signed_transaction("{}"); + LOGOS_ASSERT_FALSE(result.success); + LOGOS_ASSERT_TRUE(contains(result.error, "not running")); +} + // ============================================================================ // Node lifecycle (start / stop) // ============================================================================ @@ -334,9 +370,36 @@ LOGOS_TEST(start_succeeds_with_mocked_dependencies) { LOGOS_ASSERT_TRUE(module != nullptr); LOGOS_ASSERT(t.cFunctionCalled("start_lb_node")); LOGOS_ASSERT(t.cFunctionCalled("subscribe_to_new_blocks")); + LOGOS_ASSERT(t.cFunctionCalled("subscribe_to_processed_blocks")); + LOGOS_ASSERT(t.cFunctionCalled("subscribe_to_lib_blocks")); delete module; } +LOGOS_TEST(start_fails_when_processed_blocks_subscription_fails) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + LogosBlockchainModule module; + + t.mockCFunction("start_lb_node").returns(1); + t.mockCFunction("subscribe_to_new_blocks").returns(0); + t.mockCFunction("subscribe_to_processed_blocks").returns(1); + + LOGOS_ASSERT_FALSE(module.start(tmpDir.filePath("config.json"), "").success); +} + +LOGOS_TEST(start_fails_when_lib_blocks_subscription_fails) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + LogosBlockchainModule module; + + t.mockCFunction("start_lb_node").returns(1); + t.mockCFunction("subscribe_to_new_blocks").returns(0); + t.mockCFunction("subscribe_to_processed_blocks").returns(0); + t.mockCFunction("subscribe_to_lib_blocks").returns(1); + + LOGOS_ASSERT_FALSE(module.start(tmpDir.filePath("config.json"), "").success); +} + LOGOS_TEST(start_returns_1_when_already_running) { auto t = LogosTestContext("blockchain_module"); TempDir tmpDir; @@ -525,6 +588,30 @@ LOGOS_TEST(get_transaction_rejects_invalid_hex) { delete module; } +LOGOS_TEST(get_block_events_rejects_invalid_hex) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + StdLogosResult result = module->get_block_events("tooshort"); + LOGOS_ASSERT_FALSE(result.success); + LOGOS_ASSERT_TRUE(contains(result.error, "64 hex")); + delete module; +} + +LOGOS_TEST(get_channel_state_rejects_invalid_hex) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + StdLogosResult result = module->get_channel_state("bad"); + LOGOS_ASSERT_FALSE(result.success); + LOGOS_ASSERT_TRUE(contains(result.error, "channel_id")); + delete module; +} + // ============================================================================ // 0x prefix handling // ============================================================================ @@ -1013,6 +1100,104 @@ LOGOS_TEST(wallet_get_claimable_vouchers_returns_error_on_ffi_failure) { delete module; } +LOGOS_TEST(wallet_fund_tx_returns_json_on_success) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("wallet_fund_tx").returns(R"({"mantle_tx":{"ops":[]}})"); + t.mockCFunction("wallet_fund_tx_error").returns(0); + + StdLogosResult result = module->wallet_fund_tx(R"({"tx":{},"funding_keys":[]})"); + LOGOS_ASSERT_TRUE(result.success); + LOGOS_ASSERT_TRUE(contains(result.value.get(), "mantle_tx")); + LOGOS_ASSERT(t.cFunctionCalled("wallet_fund_tx")); + LOGOS_ASSERT(t.cFunctionCalled("free_cstring")); + delete module; +} + +LOGOS_TEST(wallet_fund_tx_returns_error_on_ffi_failure) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("wallet_fund_tx_error").returns(1); + + StdLogosResult result = module->wallet_fund_tx("{}"); + LOGOS_ASSERT_FALSE(result.success); + LOGOS_ASSERT_TRUE(contains(result.error, "mock error")); + delete module; +} + +// Transactions + +LOGOS_TEST(submit_signed_transaction_returns_tx_hash) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("submit_signed_transaction_error").returns(0); + + StdLogosResult result = module->submit_signed_transaction("{}"); + LOGOS_ASSERT_TRUE(result.success); + // Mock fills hash with 0xFA -> hex "fafa...fa" (64 chars) + std::string hash = result.value.get(); + LOGOS_ASSERT_EQ(static_cast(hash.length()), 64); + LOGOS_ASSERT_TRUE(hash.substr(0, 2) == "fa"); + LOGOS_ASSERT(t.cFunctionCalled("submit_signed_transaction")); + delete module; +} + +LOGOS_TEST(submit_signed_transaction_returns_error_on_ffi_failure) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("submit_signed_transaction_error").returns(1); + + StdLogosResult result = module->submit_signed_transaction("{}"); + LOGOS_ASSERT_FALSE(result.success); + LOGOS_ASSERT_TRUE(contains(result.error, "mock error")); + delete module; +} + +// Channel state + +LOGOS_TEST(get_channel_state_returns_json_on_success) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("get_channel_state").returns(R"({"tip":"abc","inscriptions":[]})"); + t.mockCFunction("get_channel_state_error").returns(0); + + StdLogosResult result = module->get_channel_state(VALID_HEX); + LOGOS_ASSERT_TRUE(result.success); + LOGOS_ASSERT_TRUE(contains(result.value.get(), "inscriptions")); + LOGOS_ASSERT(t.cFunctionCalled("get_channel_state")); + LOGOS_ASSERT(t.cFunctionCalled("free_cstring")); + delete module; +} + +LOGOS_TEST(get_channel_state_returns_error_on_ffi_failure) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("get_channel_state_error").returns(1); + + StdLogosResult result = module->get_channel_state(VALID_HEX); + LOGOS_ASSERT_FALSE(result.success); + LOGOS_ASSERT_TRUE(contains(result.error, "mock error")); + delete module; +} + // Blend LOGOS_TEST(blend_join_as_core_node_returns_declaration_id) { @@ -1146,6 +1331,7 @@ LOGOS_TEST(get_cryptarchia_info_returns_json_on_success) { LOGOS_ASSERT_TRUE(module != nullptr); t.mockCFunction("get_cryptarchia_info_error").returns(0); + t.mockCFunction("cryptarchia_lib_slot").returns(90); t.mockCFunction("cryptarchia_slot").returns(100); t.mockCFunction("cryptarchia_height").returns(50); t.mockCFunction("cryptarchia_mode").returns(1); // Online @@ -1160,11 +1346,26 @@ LOGOS_TEST(get_cryptarchia_info_returns_json_on_success) { LOGOS_ASSERT_TRUE(contains(json, "Online")); LOGOS_ASSERT_TRUE(contains(json, "lib")); LOGOS_ASSERT_TRUE(contains(json, "tip")); + LOGOS_ASSERT_TRUE(contains(json, "\"lib_slot\":90")); LOGOS_ASSERT(t.cFunctionCalled("get_cryptarchia_info")); LOGOS_ASSERT(t.cFunctionCalled("free_cryptarchia_info")); delete module; } +LOGOS_TEST(get_cryptarchia_info_not_started_mode) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("get_cryptarchia_info_error").returns(0); + t.mockCFunction("cryptarchia_mode").returns(2); // NotStarted + + StdLogosResult result = module->get_cryptarchia_info(); + LOGOS_ASSERT_TRUE(contains(result.value.get(), "NotStarted")); + delete module; +} + LOGOS_TEST(get_cryptarchia_info_bootstrapping_mode) { auto t = LogosTestContext("blockchain_module"); TempDir tmpDir; @@ -1191,6 +1392,147 @@ LOGOS_TEST(get_cryptarchia_info_returns_error_on_ffi_failure) { delete module; } +LOGOS_TEST(get_block_events_returns_json_on_success) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("get_block_events").returns(R"([{"type":"inscription"}])"); + t.mockCFunction("get_block_events_error").returns(0); + + StdLogosResult result = module->get_block_events(VALID_HEX); + LOGOS_ASSERT_TRUE(result.success); + LOGOS_ASSERT_TRUE(contains(result.value.get(), "inscription")); + LOGOS_ASSERT(t.cFunctionCalled("get_block_events")); + LOGOS_ASSERT(t.cFunctionCalled("free_cstring")); + delete module; +} + +LOGOS_TEST(get_block_events_returns_error_on_ffi_failure) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("get_block_events_error").returns(1); + + LOGOS_ASSERT_FALSE(module->get_block_events(VALID_HEX).success); + delete module; +} + +// Time + +LOGOS_TEST(get_time_info_returns_json_on_success) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("get_time_info_error").returns(0); + t.mockCFunction("time_slot_duration_ms").returns(2000); + t.mockCFunction("time_genesis_time_unix_ms").returns(1700000); + t.mockCFunction("time_current_slot").returns(1234); + t.mockCFunction("time_current_epoch").returns(7); + + StdLogosResult result = module->get_time_info(); + LOGOS_ASSERT_TRUE(result.success); + std::string json = result.value.get(); + LOGOS_ASSERT_TRUE(contains(json, "\"slot_duration_ms\":2000")); + LOGOS_ASSERT_TRUE(contains(json, "\"genesis_time_unix_ms\":1700000")); + LOGOS_ASSERT_TRUE(contains(json, "\"current_slot\":1234")); + LOGOS_ASSERT_TRUE(contains(json, "\"current_epoch\":7")); + LOGOS_ASSERT(t.cFunctionCalled("get_time_info")); + LOGOS_ASSERT(t.cFunctionCalled("free_time_info")); + delete module; +} + +LOGOS_TEST(get_time_info_returns_error_on_ffi_failure) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("get_time_info_error").returns(1); + + LOGOS_ASSERT_FALSE(module->get_time_info().success); + delete module; +} + +// ============================================================================ +// Stream events (trampolines drive logos_events; end of stream = JSON null) +// ============================================================================ + +// Captured by the mock subscribe calls (mock_logos_blockchain.cpp). +extern BlockCallback g_lastNewBlockCallback; +extern BlockCallback g_lastProcessedBlockCallback; +extern BlockCallback g_lastLibBlockCallback; +// Recorded by the event stubs (event_stubs.cpp). +extern std::string g_lastNewBlockEventJson; +extern std::string g_lastProcessedBlockEventJson; +extern std::string g_lastLibBlockEventJson; + +LOGOS_TEST(processed_block_stream_forwards_json_and_null_sentinel) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + LOGOS_ASSERT_TRUE(g_lastProcessedBlockCallback != nullptr); + + g_lastProcessedBlockEventJson.clear(); + g_lastProcessedBlockCallback(R"({"header_id":"abc","transactions":[]})"); + LOGOS_ASSERT_EQ(g_lastProcessedBlockEventJson, std::string(R"({"header_id":"abc","transactions":[]})")); + + g_lastProcessedBlockCallback(nullptr); + LOGOS_ASSERT_EQ(g_lastProcessedBlockEventJson, std::string("null")); + delete module; +} + +LOGOS_TEST(lib_block_stream_forwards_json_and_null_sentinel) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + LOGOS_ASSERT_TRUE(g_lastLibBlockCallback != nullptr); + + g_lastLibBlockEventJson.clear(); + g_lastLibBlockCallback(R"({"header_id":"def","slot":9})"); + LOGOS_ASSERT_EQ(g_lastLibBlockEventJson, std::string(R"({"header_id":"def","slot":9})")); + + g_lastLibBlockCallback(nullptr); + LOGOS_ASSERT_EQ(g_lastLibBlockEventJson, std::string("null")); + delete module; +} + +// The legacy new-block stream never sends NULL, but the trampoline must not +// crash if it ever does (regression test for the added guard). +LOGOS_TEST(new_block_callback_ignores_null_pointer) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + LOGOS_ASSERT_TRUE(g_lastNewBlockCallback != nullptr); + + g_lastNewBlockEventJson.clear(); + g_lastNewBlockCallback(nullptr); + LOGOS_ASSERT_EQ(g_lastNewBlockEventJson, std::string()); + delete module; +} + +// Stream events are not delivered after the module stops (s_instance cleared). +LOGOS_TEST(stream_events_not_delivered_after_stop) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + LOGOS_ASSERT_TRUE(module->stop().success); + + g_lastProcessedBlockEventJson.clear(); + g_lastProcessedBlockCallback(R"({"header_id":"abc"})"); + LOGOS_ASSERT_EQ(g_lastProcessedBlockEventJson, std::string()); + delete module; +} + // ============================================================================ // Config management (operate on file paths, no running node required) // ============================================================================