diff --git a/flake.lock b/flake.lock index c15be9e..f40e959 100644 --- a/flake.lock +++ b/flake.lock @@ -24,16 +24,16 @@ "rust-rapidsnark": "rust-rapidsnark" }, "locked": { - "lastModified": 1781613030, - "narHash": "sha256-kJZ8N/FeJBZRyRtP5JlXVf9zczOPrsBJW17mAsJdKeo=", + "lastModified": 1781698521, + "narHash": "sha256-cnk7CmRskPL5CjH3Q0vZEoLbYVkwUaQrq86xdJiLje8=", "owner": "logos-blockchain", "repo": "logos-blockchain", - "rev": "3dc81b4ec49ba977220f3ff432885258265aa986", + "rev": "0222706b14010fbdfbe9a94d3617ebf46a77fdd1", "type": "github" }, "original": { "owner": "logos-blockchain", - "ref": "3dc81b4ec49ba977220f3ff432885258265aa986", + "ref": "0222706b14010fbdfbe9a94d3617ebf46a77fdd1", "repo": "logos-blockchain", "type": "github" } diff --git a/flake.nix b/flake.nix index 1048e9d..debd81f 100644 --- a/flake.nix +++ b/flake.nix @@ -3,8 +3,8 @@ inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder?ref=bc868bf47f9d797637ac78af814b0db718dde4b8"; - # v0.1.3-rc.10-compatible + rust-rapidsnark nix fixes + cli commands + leader_claim C binding + config generation fixes - logos-blockchain.url = "github:logos-blockchain/logos-blockchain?ref=3dc81b4ec49ba977220f3ff432885258265aa986"; + # v0.1.3-rc.10-compatible + rust-rapidsnark nix fixes + cli commands + leader_claim + channel deposit / wallet notes C bindings + logos-blockchain.url = "github:logos-blockchain/logos-blockchain?ref=0222706b14010fbdfbe9a94d3617ebf46a77fdd1"; }; outputs = inputs@{ logos-module-builder, ... }: diff --git a/src/logos_blockchain_module.cpp b/src/logos_blockchain_module.cpp index 4dd39d0..aa591e6 100644 --- a/src/logos_blockchain_module.cpp +++ b/src/logos_blockchain_module.cpp @@ -48,6 +48,27 @@ namespace { } } + // Parse arbitrary-length hex (optional 0x prefix) into bytes. Unlike + // parse_address_hex this does not enforce a fixed length; used for the + // variable-length channel deposit metadata. Returns false on odd length or + // non-hex input. + bool parse_hex_bytes(const std::string& hex_in, std::vector& out) { + std::string hex = hex_in; + boost::algorithm::trim(hex); + if (hex.size() >= 2 && hex[0] == '0' && (hex[1] == 'x' || hex[1] == 'X')) + hex = hex.substr(2); + if (hex.size() % 2 != 0) + return false; + try { + std::string decoded; + boost::algorithm::unhex(hex.begin(), hex.end(), std::back_inserter(decoded)); + out.assign(decoded.begin(), decoded.end()); + return true; + } catch (const boost::algorithm::non_hex_input&) { + return false; + } + } + std::string bytes_to_hex(const uint8_t* data, size_t len) { std::string out; out.reserve(len * 2); @@ -604,6 +625,54 @@ std::vector LogosBlockchainModule::wallet_get_known_addresses() { return out; } +std::string LogosBlockchainModule::wallet_get_notes( + const std::string& wallet_address_hex, + const std::string& optional_tip_hex +) { + if (!node) { + return "Error: The node is not running."; + } + + const std::vector address_bytes = parse_address_hex(wallet_address_hex); + if (address_bytes.empty() || static_cast(address_bytes.size()) != ADDRESS_BYTES) { + return "Error: Invalid wallet address (64 hex characters required)."; + } + + std::vector tip_bytes; + const HeaderId* optional_tip = nullptr; + if (!optional_tip_hex.empty()) { + tip_bytes = parse_address_hex(optional_tip_hex); + if (tip_bytes.empty() || static_cast(tip_bytes.size()) != ADDRESS_BYTES) { + return "Error: Invalid optional tip (64 hex characters or empty)."; + } + optional_tip = reinterpret_cast(tip_bytes.data()); + } + + auto [value, error] = get_wallet_notes(node, address_bytes.data(), optional_tip); + if (!is_ok(&error)) { + return "Error: Failed to get wallet notes: " + std::to_string(error); + } + + json obj; + obj["tip"] = bytes_to_hex(reinterpret_cast(value.tip), TX_HASH_BYTES); + json notes = json::array(); + for (size_t i = 0; i < value.len; ++i) { + const WalletNote& note = value.notes[i]; + json n; + n["id"] = bytes_to_hex(reinterpret_cast(note.id), TX_HASH_BYTES); + // Value is u64; serialized as a string to avoid JSON number precision loss. + n["value"] = std::to_string(note.value); + notes.push_back(std::move(n)); + } + obj["notes"] = std::move(notes); + + const OperationStatus free_status = free_wallet_notes(value); + if (!is_ok(&free_status)) { + fprintf(stderr, "Failed to free wallet notes. Error: %d\n", free_status); + } + return obj.dump(); +} + std::string LogosBlockchainModule::leader_claim() { if (!node) { return "Error: The node is not running."; @@ -617,6 +686,166 @@ std::string LogosBlockchainModule::leader_claim() { return bytes_to_hex(reinterpret_cast(&value), TX_HASH_BYTES); } +// Channel + +std::string LogosBlockchainModule::channel_deposit( + const std::string& channel_id_hex, + const std::string& funding_public_key_hex, + const std::string& amount, + const std::string& metadata_hex, + const std::string& optional_tip_hex +) { + if (!node) { + return "Error: The node is not running."; + } + + std::string amount_trimmed = amount; + boost::algorithm::trim(amount_trimmed); + uint64_t amount_val = 0; + auto [ptr, ec] = std::from_chars(amount_trimmed.data(), amount_trimmed.data() + amount_trimmed.size(), amount_val); + if (ec != std::errc{} || ptr != amount_trimmed.data() + amount_trimmed.size() || amount_trimmed.empty()) { + return "Error: Invalid amount (positive integer required)."; + } + if (amount_val == 0) { + return "Error: Invalid amount (must be greater than zero)."; + } + + const std::vector channel_bytes = parse_address_hex(channel_id_hex); + if (channel_bytes.empty() || static_cast(channel_bytes.size()) != ADDRESS_BYTES) { + return "Error: Invalid channel_id (64 hex characters required)."; + } + + const std::vector funding_bytes = parse_address_hex(funding_public_key_hex); + if (funding_bytes.empty() || static_cast(funding_bytes.size()) != ADDRESS_BYTES) { + return "Error: Invalid funding_public_key (64 hex characters required)."; + } + + std::vector metadata_bytes; + if (!metadata_hex.empty() && !parse_hex_bytes(metadata_hex, metadata_bytes)) { + return "Error: Invalid metadata (even-length hex string required)."; + } + + std::vector tip_bytes; + const HeaderId* optional_tip = nullptr; + if (!optional_tip_hex.empty()) { + tip_bytes = parse_address_hex(optional_tip_hex); + if (tip_bytes.empty() || static_cast(tip_bytes.size()) != ADDRESS_BYTES) { + return "Error: Invalid optional tip (64 hex characters or empty)."; + } + optional_tip = reinterpret_cast(tip_bytes.data()); + } + + ChannelDepositArguments args{}; + args.optional_tip = optional_tip; + args.channel_id = channel_bytes.data(); + args.funding_public_key = funding_bytes.data(); + args.amount = amount_val; + args.metadata = metadata_bytes.empty() ? nullptr : metadata_bytes.data(); + args.metadata_len = metadata_bytes.size(); + + auto [value, error] = ::channel_deposit(node, &args); + if (!is_ok(&error)) { + return "Error: Failed to deposit into channel: " + std::to_string(error); + } + return bytes_to_hex(reinterpret_cast(&value), ADDRESS_BYTES); +} + +std::string LogosBlockchainModule::channel_deposit_with_notes( + const std::string& channel_id_hex, + const std::vector& input_note_id_hexes, + const std::string& metadata_hex, + const std::string& change_public_key_hex, + const std::vector& funding_public_key_hexes, + const std::string& max_tx_fee, + const std::string& optional_tip_hex +) { + if (!node) { + return "Error: The node is not running."; + } + + const std::vector channel_bytes = parse_address_hex(channel_id_hex); + if (channel_bytes.empty() || static_cast(channel_bytes.size()) != ADDRESS_BYTES) { + return "Error: Invalid channel_id (64 hex characters required)."; + } + + if (input_note_id_hexes.empty()) { + return "Error: At least one input note required."; + } + // Note IDs are 32-byte values stored contiguously so the buffer can be passed + // as a `NoteId` (uint8_t[32]) array. + std::vector note_ids_flat; + note_ids_flat.reserve(input_note_id_hexes.size() * ADDRESS_BYTES); + for (const std::string& hex : input_note_id_hexes) { + const std::vector b = parse_address_hex(hex); + if (b.empty() || static_cast(b.size()) != ADDRESS_BYTES) { + return "Error: Invalid input note id (64 hex characters required)."; + } + note_ids_flat.insert(note_ids_flat.end(), b.begin(), b.end()); + } + + const std::vector change_bytes = parse_address_hex(change_public_key_hex); + if (change_bytes.empty() || static_cast(change_bytes.size()) != ADDRESS_BYTES) { + return "Error: Invalid change_public_key (64 hex characters required)."; + } + + if (funding_public_key_hexes.empty()) { + return "Error: At least one funding public key required."; + } + std::vector> funding_bytes; + for (const std::string& hex : funding_public_key_hexes) { + std::vector b = parse_address_hex(hex); + if (b.empty() || static_cast(b.size()) != ADDRESS_BYTES) { + return "Error: Invalid funding public key (64 hex characters required)."; + } + funding_bytes.push_back(std::move(b)); + } + std::vector funding_ptrs; + funding_ptrs.reserve(funding_bytes.size()); + for (const auto& b : funding_bytes) + funding_ptrs.push_back(b.data()); + + std::string fee_trimmed = max_tx_fee; + boost::algorithm::trim(fee_trimmed); + uint64_t max_tx_fee_val = 0; + auto [ptr, ec] = std::from_chars(fee_trimmed.data(), fee_trimmed.data() + fee_trimmed.size(), max_tx_fee_val); + if (ec != std::errc{} || ptr != fee_trimmed.data() + fee_trimmed.size() || fee_trimmed.empty()) { + return "Error: Invalid max_tx_fee (non-negative integer required)."; + } + + std::vector metadata_bytes; + if (!metadata_hex.empty() && !parse_hex_bytes(metadata_hex, metadata_bytes)) { + return "Error: Invalid metadata (even-length hex string required)."; + } + + std::vector tip_bytes; + const HeaderId* optional_tip = nullptr; + if (!optional_tip_hex.empty()) { + tip_bytes = parse_address_hex(optional_tip_hex); + if (tip_bytes.empty() || static_cast(tip_bytes.size()) != ADDRESS_BYTES) { + return "Error: Invalid optional tip (64 hex characters or empty)."; + } + optional_tip = reinterpret_cast(tip_bytes.data()); + } + + ChannelDepositWithNotesArguments args{}; + args.optional_tip = optional_tip; + args.channel_id = channel_bytes.data(); + args.input_note_ids = reinterpret_cast(note_ids_flat.data()); + args.input_note_ids_len = input_note_id_hexes.size(); + args.metadata = metadata_bytes.empty() ? nullptr : metadata_bytes.data(); + args.metadata_len = metadata_bytes.size(); + args.change_public_key = change_bytes.data(); + args.funding_public_keys = funding_ptrs.data(); + args.funding_public_keys_len = funding_ptrs.size(); + args.max_tx_fee = max_tx_fee_val; + + auto [value, error] = ::channel_deposit_with_notes(node, &args); + if (!is_ok(&error)) { + return "Error: Failed to deposit into channel: " + std::to_string(error); + } + return bytes_to_hex(reinterpret_cast(&value), ADDRESS_BYTES); +} + std::string LogosBlockchainModule::wallet_get_claimable_vouchers() { if (!node) { return "Error: The node is not running."; diff --git a/src/logos_blockchain_module.h b/src/logos_blockchain_module.h index ab9c34f..3097ddc 100644 --- a/src/logos_blockchain_module.h +++ b/src/logos_blockchain_module.h @@ -75,9 +75,45 @@ public: const std::string& optional_tip_hex ); std::vector wallet_get_known_addresses(); + // Spendable notes (UTXOs) of a wallet address, as a JSON string: + // { "tip": "", "notes": [ { "id": "", "value": "" }, ... ] } + // optional_tip_hex may be empty to query at the current tip. Note IDs round-trip + // into channel_deposit_with_notes. + std::string wallet_get_notes( + const std::string& wallet_address_hex, + const std::string& optional_tip_hex + ); std::string leader_claim(); std::string wallet_get_claimable_vouchers(); + // Channel + // Amount-based deposit: the binding selects funding notes itself (splitting a + // note via a transfer when no exact-value note exists) so the channel receives + // exactly `amount`. funding_public_key owns the funding notes, the deposit note + // and any change. metadata_hex may be empty; optional_tip_hex may be empty to + // build against the current tip. Returns the transaction hash hex on success. + std::string channel_deposit( + const std::string& channel_id_hex, + const std::string& funding_public_key_hex, + const std::string& amount, + const std::string& metadata_hex, + const std::string& optional_tip_hex + ); + // Note-based deposit: the caller supplies the exact notes to consume (their + // whole value enters the channel), so amount = sum of the notes' values. Use + // wallet_get_notes to obtain note IDs. The gas fee is funded from + // funding_public_keys (change to change_public_key), capped at max_tx_fee. + // metadata_hex / optional_tip_hex may be empty. Returns the tx hash hex. + std::string channel_deposit_with_notes( + const std::string& channel_id_hex, + const std::vector& input_note_id_hexes, + const std::string& metadata_hex, + const std::string& change_public_key_hex, + const std::vector& funding_public_key_hexes, + const std::string& max_tx_fee, + const std::string& optional_tip_hex + ); + // Blend std::string blend_join_as_core_node( const std::string& provider_id_hex, diff --git a/tests/mocks/mock_logos_blockchain.cpp b/tests/mocks/mock_logos_blockchain.cpp index 2fb5115..83b6c4b 100644 --- a/tests/mocks/mock_logos_blockchain.cpp +++ b/tests/mocks/mock_logos_blockchain.cpp @@ -198,6 +198,58 @@ OperationStatus free_claimable_vouchers(ClaimableVouchers vouchers) { return 0; } +// Wallet-notes mock storage (up to 4 notes) +static WalletNote s_mockNotes[4]; + +FfiWalletNotesResult get_wallet_notes( + LogosBlockchainNode* node, + const uint8_t* wallet_address, + const HeaderId* optional_tip) +{ + LOGOS_CMOCK_RECORD("get_wallet_notes"); + FfiWalletNotesResult result; + memset(&result.value, 0, sizeof(WalletNotes)); + int err = LOGOS_CMOCK_RETURN(int, "get_wallet_notes_error"); + result.error = err; + if (err == 0) { + int count = LOGOS_CMOCK_RETURN(int, "get_wallet_notes_count"); + if (count > 4) count = 4; + if (count < 0) count = 0; + for (int i = 0; i < count; ++i) { + memset(s_mockNotes[i].id, 0x10 + i, sizeof(NoteId)); + s_mockNotes[i].value = static_cast(100 * (i + 1)); + } + memset(result.value.tip, 0xFF, sizeof(HeaderId)); + result.value.notes = count > 0 ? s_mockNotes : nullptr; + result.value.len = static_cast(count); + } + return result; +} + +OperationStatus free_wallet_notes(WalletNotes notes) { + LOGOS_CMOCK_RECORD("free_wallet_notes"); + return 0; +} + +FfiChannelDepositResult channel_deposit(LogosBlockchainNode* node, const ChannelDepositArguments* arguments) { + LOGOS_CMOCK_RECORD("channel_deposit"); + FfiChannelDepositResult result; + memset(result.value, 0xBC, sizeof(Hash)); + result.error = LOGOS_CMOCK_RETURN(int, "channel_deposit_error"); + return result; +} + +FfiChannelDepositResult channel_deposit_with_notes( + LogosBlockchainNode* node, + const ChannelDepositWithNotesArguments* arguments) +{ + LOGOS_CMOCK_RECORD("channel_deposit_with_notes"); + FfiChannelDepositResult result; + memset(result.value, 0xDE, sizeof(Hash)); + result.error = LOGOS_CMOCK_RETURN(int, "channel_deposit_with_notes_error"); + return result; +} + BlendHashResult blend_join_as_core_node( LogosBlockchainNode* node, const uint8_t* provider_id, diff --git a/tests/stubs/logos_blockchain.h b/tests/stubs/logos_blockchain.h index a7f1fe1..d8e5e6f 100644 --- a/tests/stubs/logos_blockchain.h +++ b/tests/stubs/logos_blockchain.h @@ -16,6 +16,7 @@ extern "C" { typedef uint8_t Hash[32]; typedef uint8_t HeaderId[32]; typedef uint8_t TxHash[32]; +typedef Hash NoteId; // Opaque node handle typedef struct LogosBlockchainNode LogosBlockchainNode; @@ -54,6 +55,30 @@ typedef struct { uint64_t amount; } TransferFundsArguments; +// Arguments for channel_deposit (amount-based) +typedef struct { + const HeaderId* optional_tip; + const uint8_t* channel_id; + const uint8_t* funding_public_key; + uint64_t amount; + const uint8_t* metadata; + size_t metadata_len; +} ChannelDepositArguments; + +// Arguments for channel_deposit_with_notes (note-based) +typedef struct { + const HeaderId* optional_tip; + const uint8_t* channel_id; + const NoteId* input_note_ids; + size_t input_note_ids_len; + const uint8_t* metadata; + size_t metadata_len; + const uint8_t* change_public_key; + const uint8_t* const* funding_public_keys; + size_t funding_public_keys_len; + uint64_t max_tx_fee; +} ChannelDepositWithNotesArguments; + // Known addresses result container typedef struct { uint8_t** addresses; @@ -71,6 +96,19 @@ typedef struct { size_t len; } ClaimableVouchers; +// A single spendable wallet note (UTXO): its note ID and value. +typedef struct { + NoteId id; + uint64_t value; +} WalletNote; + +// The set of spendable notes for a wallet address at a given tip. +typedef struct { + HeaderId tip; + WalletNote* notes; + size_t len; +} WalletNotes; + // Cryptarchia consensus info typedef struct { uint8_t lib[32]; @@ -85,7 +123,9 @@ typedef struct { LogosBlockchainNode* value; OperationStatus error; } NodeResult typedef struct { uint64_t value; OperationStatus error; } BalanceResult; typedef struct { Hash value; OperationStatus error; } TransferHashResult; typedef struct { TxHash value; OperationStatus error; } FfiLeaderClaimResult; +typedef struct { Hash value; OperationStatus error; } FfiChannelDepositResult; typedef struct { KnownAddresses value; OperationStatus error; } KnownAddressesResult; +typedef struct { WalletNotes value; OperationStatus error; } FfiWalletNotesResult; typedef struct { ClaimableVouchers value; OperationStatus error; } FfiClaimableVouchersResult; typedef struct { Hash value; OperationStatus error; } BlendHashResult; typedef struct { char* value; OperationStatus error; } StringResult; @@ -142,6 +182,17 @@ TransferHashResult transfer_funds(LogosBlockchainNode* node, const TransferFunds FfiLeaderClaimResult leader_claim(LogosBlockchainNode* node); KnownAddressesResult get_known_addresses(LogosBlockchainNode* node); OperationStatus free_known_addresses(KnownAddresses addrs); +FfiWalletNotesResult get_wallet_notes( + LogosBlockchainNode* node, + const uint8_t* wallet_address, + const HeaderId* optional_tip); +OperationStatus free_wallet_notes(WalletNotes notes); + +// Channel +FfiChannelDepositResult channel_deposit(LogosBlockchainNode* node, const ChannelDepositArguments* arguments); +FfiChannelDepositResult channel_deposit_with_notes( + LogosBlockchainNode* node, + const ChannelDepositWithNotesArguments* arguments); FfiClaimableVouchersResult get_claimable_vouchers(LogosBlockchainNode* node, const HeaderId* optional_tip); OperationStatus free_claimable_vouchers(ClaimableVouchers vouchers); diff --git a/tests/test_blockchain.cpp b/tests/test_blockchain.cpp index e26de5c..9444438 100644 --- a/tests/test_blockchain.cpp +++ b/tests/test_blockchain.cpp @@ -156,6 +156,31 @@ LOGOS_TEST(leader_claim_without_node_returns_error) { LOGOS_ASSERT_TRUE(contains(result, "not running")); } +LOGOS_TEST(channel_deposit_without_node_returns_error) { + auto t = LogosTestContext("blockchain_module"); + LogosBlockchainModule module; + std::string result = module.channel_deposit(VALID_HEX, VALID_HEX, "100", "", ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "not running")); +} + +LOGOS_TEST(channel_deposit_with_notes_without_node_returns_error) { + auto t = LogosTestContext("blockchain_module"); + LogosBlockchainModule module; + std::string result = module.channel_deposit_with_notes( + VALID_HEX, {VALID_HEX}, "", VALID_HEX, {VALID_HEX}, "0", ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "not running")); +} + +LOGOS_TEST(wallet_get_notes_without_node_returns_error) { + auto t = LogosTestContext("blockchain_module"); + LogosBlockchainModule module; + std::string result = module.wallet_get_notes(VALID_HEX, ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "not running")); +} + LOGOS_TEST(wallet_get_known_addresses_without_node_returns_empty) { auto t = LogosTestContext("blockchain_module"); LogosBlockchainModule module; @@ -535,6 +560,267 @@ LOGOS_TEST(leader_claim_returns_error_on_ffi_failure) { delete module; } +LOGOS_TEST(channel_deposit_returns_tx_hash) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("channel_deposit_error").returns(0); + + std::string result = module->channel_deposit(VALID_HEX, VALID_HEX, "500", "", ""); + LOGOS_ASSERT_FALSE(starts_with(result, "Error:")); + LOGOS_ASSERT_EQ(static_cast(result.length()), 64); + LOGOS_ASSERT_TRUE(starts_with(result, "bc")); + LOGOS_ASSERT(t.cFunctionCalled("channel_deposit")); + delete module; +} + +LOGOS_TEST(channel_deposit_with_metadata_and_tip) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("channel_deposit_error").returns(0); + + std::string result = module->channel_deposit(VALID_HEX, VALID_HEX, "100", "deadbeef", VALID_HEX); + LOGOS_ASSERT_FALSE(starts_with(result, "Error:")); + LOGOS_ASSERT_EQ(static_cast(result.length()), 64); + delete module; +} + +LOGOS_TEST(channel_deposit_returns_error_on_ffi_failure) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("channel_deposit_error").returns(1); + + std::string result = module->channel_deposit(VALID_HEX, VALID_HEX, "100", "", ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "Failed to deposit into channel")); + delete module; +} + +LOGOS_TEST(channel_deposit_rejects_invalid_amount) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + std::string result = module->channel_deposit(VALID_HEX, VALID_HEX, "not_a_number", "", ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "amount")); + delete module; +} + +LOGOS_TEST(channel_deposit_rejects_zero_amount) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + std::string result = module->channel_deposit(VALID_HEX, VALID_HEX, "0", "", ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "amount")); + delete module; +} + +LOGOS_TEST(channel_deposit_rejects_invalid_channel_id) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + std::string result = module->channel_deposit("bad", VALID_HEX, "100", "", ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "channel_id")); + delete module; +} + +LOGOS_TEST(channel_deposit_rejects_invalid_funding_key) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + std::string result = module->channel_deposit(VALID_HEX, "short", "100", "", ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "funding_public_key")); + delete module; +} + +LOGOS_TEST(channel_deposit_rejects_invalid_metadata) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + std::string result = module->channel_deposit(VALID_HEX, VALID_HEX, "100", "xyz", ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "metadata")); + delete module; +} + +LOGOS_TEST(channel_deposit_rejects_invalid_optional_tip) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + std::string result = module->channel_deposit(VALID_HEX, VALID_HEX, "100", "", "bad_tip"); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "tip")); + delete module; +} + +LOGOS_TEST(wallet_get_notes_returns_json_on_success) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("get_wallet_notes_error").returns(0); + t.mockCFunction("get_wallet_notes_count").returns(2); + + std::string result = module->wallet_get_notes(VALID_HEX, ""); + LOGOS_ASSERT_FALSE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "\"tip\"")); + LOGOS_ASSERT_TRUE(contains(result, "\"notes\"")); + LOGOS_ASSERT_TRUE(contains(result, "\"value\":\"100\"")); + LOGOS_ASSERT_TRUE(contains(result, "\"value\":\"200\"")); + LOGOS_ASSERT(t.cFunctionCalled("get_wallet_notes")); + LOGOS_ASSERT(t.cFunctionCalled("free_wallet_notes")); + delete module; +} + +LOGOS_TEST(wallet_get_notes_returns_empty_notes_array) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("get_wallet_notes_error").returns(0); + t.mockCFunction("get_wallet_notes_count").returns(0); + + std::string result = module->wallet_get_notes(VALID_HEX, ""); + LOGOS_ASSERT_FALSE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "\"notes\":[]")); + delete module; +} + +LOGOS_TEST(wallet_get_notes_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_wallet_notes_error").returns(1); + + std::string result = module->wallet_get_notes(VALID_HEX, ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "Failed to get wallet notes")); + delete module; +} + +LOGOS_TEST(wallet_get_notes_rejects_invalid_address) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + std::string result = module->wallet_get_notes("bad", ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "wallet address")); + delete module; +} + +LOGOS_TEST(channel_deposit_with_notes_returns_tx_hash) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("channel_deposit_with_notes_error").returns(0); + + std::string result = module->channel_deposit_with_notes( + VALID_HEX, {VALID_HEX}, "deadbeef", VALID_HEX, {VALID_HEX}, "1000", ""); + LOGOS_ASSERT_FALSE(starts_with(result, "Error:")); + LOGOS_ASSERT_EQ(static_cast(result.length()), 64); + LOGOS_ASSERT_TRUE(starts_with(result, "de")); + LOGOS_ASSERT(t.cFunctionCalled("channel_deposit_with_notes")); + delete module; +} + +LOGOS_TEST(channel_deposit_with_notes_returns_error_on_ffi_failure) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + t.mockCFunction("channel_deposit_with_notes_error").returns(1); + + std::string result = module->channel_deposit_with_notes( + VALID_HEX, {VALID_HEX}, "", VALID_HEX, {VALID_HEX}, "0", ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "Failed to deposit into channel")); + delete module; +} + +LOGOS_TEST(channel_deposit_with_notes_rejects_empty_notes) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + std::string result = module->channel_deposit_with_notes( + VALID_HEX, {}, "", VALID_HEX, {VALID_HEX}, "0", ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "input note")); + delete module; +} + +LOGOS_TEST(channel_deposit_with_notes_rejects_invalid_note_id) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + std::string result = module->channel_deposit_with_notes( + VALID_HEX, {"bad"}, "", VALID_HEX, {VALID_HEX}, "0", ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "input note id")); + delete module; +} + +LOGOS_TEST(channel_deposit_with_notes_rejects_empty_funding_keys) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + std::string result = module->channel_deposit_with_notes( + VALID_HEX, {VALID_HEX}, "", VALID_HEX, {}, "0", ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "funding public key")); + delete module; +} + +LOGOS_TEST(channel_deposit_with_notes_rejects_invalid_max_tx_fee) { + auto t = LogosTestContext("blockchain_module"); + TempDir tmpDir; + auto* module = createStartedModule(t, tmpDir); + LOGOS_ASSERT_TRUE(module != nullptr); + + std::string result = module->channel_deposit_with_notes( + VALID_HEX, {VALID_HEX}, "", VALID_HEX, {VALID_HEX}, "not_a_number", ""); + LOGOS_ASSERT_TRUE(starts_with(result, "Error:")); + LOGOS_ASSERT_TRUE(contains(result, "max_tx_fee")); + delete module; +} + LOGOS_TEST(wallet_transfer_funds_single_sender_via_vector) { auto t = LogosTestContext("blockchain_module"); TempDir tmpDir;