Building Ixian Client Apps

This guide shows you how to build custom Ixian client applications using the Ixian-Core library. Client apps are lightweight nodes that don't maintain the full blockchain but can send transactions, manage contacts, and communicate via the Ixian P2P network.

What is an Ixian Client?

Ixian clients are applications that:

  • Connect to the Ixian P2P network as light clients
  • Send and receive transactions
  • Manage contacts and encrypted messaging
  • Don't store the full blockchain (query DLT nodes instead)
  • Can be gateways, bots, IoT devices, or custom applications

Client vs Full Node

FeatureClient (Light Node)Full Node (DLT)
Blockchain StorageNo (queries remote nodes)Yes (full copy)
Connection TypeConnects to S2 (streaming) nodesDirect P2P connections
Transaction ValidationBasic (signatures only)Full (consensus rules)
Block GenerationNoYes
Resource UsageLow (MB of RAM)High (GBs of RAM, TBs of disk)
Use CasesWallets, gateways, botsNetwork backbone, mining

Architecture Overview

Your Application
    ↓ (implements)
IxianNode (abstract class)
    ↓ (uses)
Ixian-Core Components
    ├── Wallet Management
    ├── Transaction Processing
    ├── StreamClientManager (connects to S2 nodes)
    ├── NetworkClientManager (queries DLT via S2)
    ├── Presence System (Starling sector routing)
    ├── Cryptography
    └── Storage

Prerequisites

  • .NET 10.0 SDK or later
  • Visual Studio 2026 or VS Code with C# extension
  • Basic understanding of C# and async programming
  • Familiarity with Ixian concepts (addresses, transactions, presence)

Getting Started

Step 1: Create a New Project

# Create a new console application
dotnet new console -n MyIxianClient
cd MyIxianClient

# Clone Ixian-Core alongside your project
cd ..
git clone https://github.com/ixian-platform/Ixian-Core.git
cd MyIxianClient

Step 2: Add Ixian-Core Reference

Edit your .csproj file:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <!-- Import Ixian-Core shared project -->
  <Import Project="..\Ixian-Core\IXICore.projitems" Label="Shared" />

  <ItemGroup>
    <!-- Ixian-Core dependencies -->
    <PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
    <PackageReference Include="Mono.Nat" Version="3.0.4" />
    <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
    <PackageReference Include="RocksDB" Version="10.4.2.64152" />
  </ItemGroup>
</Project>

Step 3: Implement IxianNode

Create Node.cs:

using IXICore;
using IXICore.Activity;
using IXICore.Inventory;
using IXICore.Meta;
using IXICore.Network;
using IXICore.Network.Messages;
using IXICore.RegNames;
using IXICore.Storage;
using IXICore.Streaming;
using IXICore.Utils;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using static IXICore.Transaction;

namespace IxianClient
{
    public class Node : IxianNode
    {
        private bool running = false;
        private Thread? mainLoopThread = null;

        private TransactionInclusion? tiv = null;

        private CoreStreamProcessor? streamProcessor = null;

        private long lastSectorUpdate = 0;

        private NetworkClientManagerStatic? networkClientManagerStatic = null;

        public static IActivityStorage? activityStorage = null;

        private IStorage? blockStorage = null;

        public Node()
        {
            Init();
        }

        private void Init()
        {
            // Initialize IxianHandler with your app version
            IxianHandler.init("MyClient v1.0.0", this, NetworkType.main);

            // Initialize wallet
            if (!InitWallet())
            {
                Console.WriteLine("Failed to initialize wallet. Shutting down.");
                IxianHandler.requestShutdown();
                return;
            }

            // Disable console output
            Logging.consoleOutput = false;

            // Initialize block header storage
            blockStorage = new RocksDBStorage("headers", 0, CoreConfig.maxBlockHeadersPerDatabase, 3, RocksDBOptimizations.Mobiles, 0);

            // Initialize activity/transaction storage
            activityStorage = new ActivityStorage("activity", 0, 0, RocksDBOptimizations.Mobiles, 0);

            // Initialize peer storage
            PeerStorage.init("");

            // Setup network client manager (queries blockchain via S2)
            networkClientManagerStatic = new NetworkClientManagerStatic(10);
            NetworkClientManager.init(networkClientManagerStatic);
            StreamClientManager.init(6, true);

            // Prepare the stream processor
            streamProcessor = new CoreStreamProcessor(new ICPendingMessageProcessor("", false), StreamCapabilities.Incoming);

            // Init TIV
            tiv = new TransactionInclusion(blockStorage, new ICTransactionInclusionCallbacks(), TIVBlockVerificationMode.Minimal);

            // Initialize presence list with keepalive
            PresenceList.init("", 0, 'C', CoreConfig.clientKeepAliveInterval);

            IxianHandler.localStorage = new LocalStorage("", new ICLocalStorageCallbacks());

            InventoryCache.init(new InventoryCacheClient(tiv));

            RelaySectors.init(CoreConfig.relaySectorLevels, null);

            Console.WriteLine($"Node initialized. Wallet: {IxianHandler.getWalletStorage().getPrimaryAddress()}");
        }

        private bool InitWallet()
        {
            string walletPath = "wallet.ixi";
            WalletStorage walletStorage = new WalletStorage(walletPath);

            Logging.flush();

            if (!walletStorage.walletExists())
            {
                ConsoleHelpers.displayBackupText();

                // Request a password
                string password = "";
                while (password.Length < 10)
                {
                    Logging.flush();
                    password = ConsoleHelpers.requestNewPassword("Enter a password for your new wallet: ");
                    if (IxianHandler.forceShutdown)
                    {
                        return false;
                    }
                }
                walletStorage.generateWallet(password);
            }
            else
            {
                ConsoleHelpers.displayBackupText();

                bool success = false;
                while (!success)
                {
                    string password = "";
                    if (password.Length < 10)
                    {
                        Logging.flush();
                        Console.Write("Enter wallet password: ");
                        password = ConsoleHelpers.getPasswordInput();
                    }
                    if (IxianHandler.forceShutdown)
                    {
                        return false;
                    }
                    if (walletStorage.readWallet(password))
                    {
                        success = true;
                    }
                }
            }


            if (walletStorage.getPrimaryPublicKey() == null)
            {
                return false;
            }

            // Wait for any pending log messages to be written
            Logging.flush();

            Console.WriteLine();
            Console.WriteLine("Your IXIAN addresses are: ");
            Console.ForegroundColor = ConsoleColor.Green;
            foreach (var entry in walletStorage.getMyAddressesBase58())
            {
                Console.WriteLine(entry);
            }
            Console.ResetColor();
            Console.WriteLine();

            Logging.info("Public Node Address: {0}", walletStorage.getPrimaryAddress().ToString());


            if (walletStorage.viewingWallet)
            {
                Logging.error("Viewing-only wallet {0} cannot be used as the primary wallet.", walletStorage.getPrimaryAddress().ToString());
                return false;
            }

            IxianHandler.addWallet(walletStorage);

            // Prepare the balances list
            List<Address> address_list = IxianHandler.getWalletStorage().getMyAddresses();
            foreach (Address addr in address_list)
            {
                IxianHandler.balances.Add(addr, new Balance(addr, 0));
            }

            return true;
        }

        public void Start()
        {
            if (running) return;

            running = true;

            if (!blockStorage!.prepareStorage(false))
            {
                Logging.error("Error while preparing block storage! Aborting.");
                return;
            }

            activityStorage!.prepareStorage(false);

            var pending_txs = activityStorage.getActivitiesByStatus(ActivityStatus.Pending, true);
            pending_txs.AddRange(activityStorage.getActivitiesByStatus(ActivityStatus.Reverted, true));
            // Load pending transactions
            foreach (var pending_tx in pending_txs)
            {
                if (pending_tx.type == ActivityType.TransactionReceived)
                {
                    PendingTransactions.addIncomingTransaction(pending_tx.transaction);
                }
                else if (pending_tx.type == ActivityType.TransactionSent
                        || pending_tx.type == ActivityType.IxiName)
                {
                    PendingTransactions.addOutgoingTransaction(pending_tx.transaction, pending_tx.transaction.toList.TakeLast(2).Select(x => x.Key).ToList());
                }
            }

            ulong block_height = 0;
            byte[]? block_checksum = null;
            if (IxianHandler.networkType == NetworkType.main)
            {
                // Use baked block as starting point for mainnet, to avoid having to sync from genesis
                block_height = CoreConfig.bakedBlockHeight;
                block_checksum = CoreConfig.bakedBlockChecksum;
            }

            // Start TIV
            tiv?.start(block_height, block_checksum, true);

            // Start presence keepalive (announces our presence to network)
            PresenceList.startKeepAlive();

            // Start the network queue
            NetworkQueue.start();

            streamProcessor.start();

            // Connect to your sector of S2 nodes
            NetworkClientManager.start(1);

            // Connect to S2 streaming nodes (for presence and messaging)
            StreamClientManager.start();

            // Start main loop for periodic tasks
            mainLoopThread = new Thread(MainLoop);
            mainLoopThread.Start();

            Console.WriteLine("Node started and connecting to network...");
        }

        public void Stop()
        {
            running = false;

            // First stop localStorage, to flush any pending chat messages to storage
            // The Node is currently in shutting down state, so no incoming messages will be processed by the message processors
            IxianHandler.localStorage?.stop();

            // Stop the stream processor, it includes pending messages
            streamProcessor.stop();

            // Stop everything else storage related
            activityStorage?.stopStorage();

            PeerStorage.savePeersFile(true);

            // Stop the block storage
            blockStorage?.stopStorage();

            tiv?.stop();
            
            PresenceList.stopKeepAlive();
            NetworkQueue.stop();
            NetworkClientManager.stop();
            StreamClientManager.stop();

            mainLoopThread?.Join();

            Console.WriteLine("Node stopped.");
        }

        private void MainLoop()
        {
            while (running)
            {
                try
                {
                    // Fetch sector nodes
                    RequestSectorUpdate();

                    // Request balance update periodically
                    RequestBalanceUpdate();

                    // Check for balance changes
                    CheckBalanceChanges();

                    // Cleanup old presence entries
                    PresenceList.performCleanup();

                    // Save peer data
                    PeerStorage.savePeersFile();
                }
                catch (Exception e)
                {
                    Console.WriteLine($"Error in main loop: {e.Message}");
                }

                Thread.Sleep(5000);
            }
        }

        private void RequestSectorUpdate()
        {
            if (lastSectorUpdate + 300 < Clock.getTimestamp())
            {
                lastSectorUpdate = Clock.getTimestamp();
                CoreProtocolMessage.fetchSectorNodes(IxianHandler.primaryWalletAddress, CoreConfig.maxRelaySectorNodesToRequest);
            }
        }

        private void RequestBalanceUpdate()
        {
            foreach (var balance in IxianHandler.balances.Values)
            {
                // Request initial wallet balance
                if (balance.blockHeight == 0 || balance.lastUpdate + 300 < Clock.getTimestamp())
                {
                    CoreProtocolMessage.broadcastProtocolMessage(['M', 'H', 'R'], ProtocolMessageCode.getBalance2, balance.address.addressNoChecksum.GetIxiBytes(), null);
                }
            }
        }

        // ===== IxianNode Abstract Method Implementations =====

        public override Block? getBlockHeader(ulong blockNum)
        {
            return blockStorage!.getBlock(blockNum);
        }

        public override byte[]? getBlockHash(ulong blockNum)
        {
            var tsd = blockStorage!.getBlockTotalSignerDifficulty(blockNum);
            return tsd.blockHash;
        }

        public override Block? getLastBlock()
        {
            return tiv.getLastBlockHeader();
        }

        public override ulong getLastBlockHeight()
        {
            Block? block = tiv.getLastBlockHeader();
            if (block == null)
            {
                return 0;
            }
            return block.blockNum;
        }

        public override int getLastBlockVersion()
        {
            Block? block = tiv.getLastBlockHeader();
            if (block == null
                || block.version < Block.maxVersion)
            {
                // TODO Omega force to v10 after upgrade
                return Block.maxVersion - 1;
            }
            return block.version;
        }

        public override bool addIncomingTransaction(Transaction tx)
        {
            if (tx.timeStamp == 0)
            {
                tx.timeStamp = Clock.getTimestamp();
            }
            if (IxianHandler.addTransactionToActivityStorage(activityStorage, tx))
            {
                return PendingTransactions.addIncomingTransaction(tx);
            }
            return false;
        }

        public override bool addTransaction(Transaction tx, List<Address> relayNodeAddresses, List<ExtendedAddress>? extendedAddresses, byte[]? requestId, bool force_broadcast)
        {
            if (tx.timeStamp == 0)
            {
                tx.timeStamp = Clock.getTimestamp();
            }
            if (IxianHandler.addTransactionToActivityStorage(activityStorage, tx))
            {
                if (PendingTransactions.addOutgoingTransaction(tx, relayNodeAddresses))
                {
                    foreach (var address in relayNodeAddresses)
                    {
                        NetworkClientManager.sendToClient(address, ProtocolMessageCode.transactionData2, tx.getBytes(true, true));
                    }
                    if (extendedAddresses != null)
                    {
                        CoreStreamProcessor.transactionSend(tx, extendedAddresses, requestId);
                    }
                    return true;
                }
            }
            return false;
        }

        public override bool isAcceptingConnections()
        {
            return false; // Clients don't accept incoming connections
        }

        public override void parseProtocolMessage(ProtocolMessageCode code, byte[] data, RemoteEndpoint endpoint)
        {

            if (endpoint == null)
            {
                Logging.error("Endpoint was null. parseProtocolMessage");
                return;
            }
            try
            {
                switch (code)
                {
                    case ProtocolMessageCode.hello:
                        {
                            using (MemoryStream m = new MemoryStream(data))
                            {
                                using (BinaryReader reader = new BinaryReader(m))
                                {
                                    CoreProtocolMessage.processHelloMessageV6(endpoint, reader);

                                    Friend? friend = FriendList.getFriend(endpoint.presence.wallet);
                                    if (friend != null)
                                    {
                                        friend.updatedStreamingNodes = Clock.getNetworkTimestamp();
                                        friend.relayNode = new Peer(endpoint.getFullAddress(true), endpoint.presence.wallet, Clock.getTimestamp(), Clock.getTimestamp(), Clock.getTimestamp(), 0);
                                        friend.online = true;
                                    }
                                }
                            }
                        }
                        break;


                    case ProtocolMessageCode.helloData:
                        using (MemoryStream m = new MemoryStream(data))
                        {
                            using (BinaryReader reader = new BinaryReader(m))
                            {
                                if (!CoreProtocolMessage.processHelloMessageV6(endpoint, reader))
                                {
                                    return;
                                }

                                char node_type = endpoint.presenceAddress.type;

                                ulong last_block_num = reader.ReadIxiVarUInt();
                                int bcLen = (int)reader.ReadIxiVarUInt();
                                byte[] block_checksum = reader.ReadBytes(bcLen);

                                endpoint.blockHeight = last_block_num;

                                int block_version = (int)reader.ReadIxiVarUInt();

                                if (node_type != 'C' && node_type != 'R')
                                {
                                    ulong highest_block_height = IxianHandler.getHighestKnownNetworkBlockHeight();
                                    if (last_block_num + 10 < highest_block_height)
                                    {
                                        CoreProtocolMessage.sendBye(endpoint, ProtocolByeCode.tooFarBehind, string.Format("Your node is too far behind, your block height is {0}, highest network block height is {1}.", last_block_num, highest_block_height), highest_block_height.ToString(), true);
                                        return;
                                    }
                                }

                                // Process the hello data
                                endpoint.helloReceived = true;
                                NetworkClientManager.recalculateLocalTimeDifference();

                                if (node_type == 'R')
                                {
                                    if (!StreamClientManager.isConnectedTo(StreamClientManager.primaryS2Address)
                                        && StreamClientManager.isConnectedTo(endpoint))
                                    {
                                        // Update local presence information
                                        StreamClientManager.primaryS2Address = endpoint.getFullAddress(true);
                                        IxianHandler.publicPort = endpoint.incomingPort;
                                        IxianHandler.publicIP = endpoint.address;
                                        StreamClientManager.setPinnedNodes(new() { StreamClientManager.primaryS2Address });
                                        PresenceList.forceSendKeepAlive = true;
                                        Logging.info("Forcing KA from networkprotocol");
                                    }
                                    else
                                    {
                                        // Announce local presence
                                        var myPresence = PresenceList.curNodePresence;
                                        if (myPresence != null)
                                        {
                                            foreach (var pa in myPresence.addresses)
                                            {
                                                var iika = new InventoryItemKeepAlive2(pa.lastSeenTime, myPresence.wallet, pa.device);
                                                endpoint.addInventoryItem(iika);
                                            }
                                        }
                                    }

                                    // Fetch friends presences if outgoing stream capabilities are enabled
                                    if ((CoreStreamProcessor.streamCapabilities & StreamCapabilities.Outgoing) != 0)
                                    {
                                        CoreStreamProcessor.fetchAllFriendsPresencesInSector(endpoint.presence.wallet);
                                    }
                                }

                                if (node_type == 'M'
                                    || node_type == 'H'
                                    || node_type == 'R')
                                {
                                    CoreProtocolMessage.subscribeToEvents(endpoint);
                                }

                                Friend? friend = FriendList.getFriend(endpoint.presence.wallet);
                                if (friend != null)
                                {
                                    friend.updatedStreamingNodes = Clock.getNetworkTimestamp();
                                    friend.relayNode = new Peer(endpoint.getFullAddress(true), endpoint.presence.wallet, Clock.getTimestamp(), Clock.getTimestamp(), Clock.getTimestamp(), 0);
                                    friend.online = true;
                                    if (node_type == 'C')
                                    {
                                        if (friend.bot)
                                        {
                                            CoreStreamProcessor.sendGetBotInfo(friend);
                                        }
                                    }
                                }
                            }
                        }
                        break;

                    case ProtocolMessageCode.s2data:
                        {
                            streamProcessor.receiveData(data, endpoint);
                        }
                        break;

                    case ProtocolMessageCode.getPresence2:
                        {
                            using (MemoryStream m = new MemoryStream(data))
                            {
                                using (BinaryReader reader = new BinaryReader(m))
                                {
                                    int walletLen = (int)reader.ReadIxiVarUInt();
                                    Address wallet = new Address(reader.ReadBytes(walletLen));

                                    Presence? p = PresenceList.getPresenceByAddress(wallet);
                                    if (p != null)
                                    {
                                        lock (p)
                                        {
                                            byte[][] presence_chunks = p.getByteChunks();
                                            foreach (byte[] presence_chunk in presence_chunks)
                                            {
                                                endpoint.sendData(ProtocolMessageCode.updatePresence, presence_chunk);
                                            }
                                        }
                                    }
                                    else
                                    {
                                        Logging.warn("Node has requested presence information about {0} that is not in our PL.", wallet.ToString());
                                    }
                                }
                            }
                        }
                        break;

                    case ProtocolMessageCode.balance2:
                        {
                            if (endpoint.presenceAddress.type != 'M'
                                && endpoint.presenceAddress.type != 'H'
                                && endpoint.presenceAddress.type != 'R')
                            {
                                Logging.warn("Received balance2 from non-master node {0}. Ignoring.", endpoint.getFullAddress());
                                return;
                            }
                            using (MemoryStream m = new MemoryStream(data))
                            {
                                using (BinaryReader reader = new BinaryReader(m))
                                {
                                    int address_length = (int)reader.ReadIxiVarUInt();
                                    Address address = new Address(reader.ReadBytes(address_length));

                                    int balance_bytes_len = (int)reader.ReadIxiVarUInt();
                                    byte[] balance_bytes = reader.ReadBytes(balance_bytes_len);

                                    // Retrieve the latest balance
                                    IxiNumber ixi_balance = new IxiNumber(balance_bytes);

                                    // Retrieve the blockheight for the balance
                                    ulong block_height = reader.ReadIxiVarUInt();
                                    byte[] block_checksum = reader.ReadBytes((int)reader.ReadIxiVarUInt());

                                    foreach (Balance balance in IxianHandler.balances.Values)
                                    {
                                        if (address.addressNoChecksum.SequenceEqual(balance.address.addressNoChecksum))
                                        {
                                            if (block_height > balance.blockHeight && (balance.balance != ixi_balance || balance.blockHeight == 0))
                                            {
                                                balance.address = address;
                                                balance.balance = ixi_balance;
                                                balance.blockHeight = block_height;
                                                balance.blockChecksum = block_checksum;
                                                balance.verified = false;
                                            }

                                            balance.lastUpdate = Clock.getTimestamp();
                                        }
                                    }
                                }
                            }
                        }
                        break;

                    case ProtocolMessageCode.updatePresence:
                        HandleUpdatePresence(data, endpoint);
                        break;

                    case ProtocolMessageCode.keepAlivePresence:
                        HandleKeepAlivePresence(data, endpoint);
                        break;

                    case ProtocolMessageCode.blockHeaders4:
                        HandleBlockHeaders4(data, endpoint);
                        break;

                    case ProtocolMessageCode.pitData2:
                        {
                            if (endpoint.presenceAddress.type != 'M'
                                && endpoint.presenceAddress.type != 'H'
                                && endpoint.presenceAddress.type != 'R')
                            {
                                Logging.warn("Received pit data from non-master node {0}. Ignoring.", endpoint.getFullAddress());
                                return;
                            }
                            tiv?.receivedPIT2(data, endpoint);
                        }
                        break;

                    case ProtocolMessageCode.transactionData2:
                        HandleTransactionData(data, endpoint);
                        break;

                    case ProtocolMessageCode.bye:
                        CoreProtocolMessage.processBye(data, endpoint);
                        break;

                    case ProtocolMessageCode.sectorNodes:
                        HandleSectorNodes(data, endpoint);
                        break;

                    case ProtocolMessageCode.keepAlivesChunk:
                        HandleKeepAlivesChunk(data, endpoint);
                        break;

                    case ProtocolMessageCode.getKeepAlives:
                        CoreProtocolMessage.processGetKeepAlives(data, endpoint);
                        break;

                    case ProtocolMessageCode.inventory2:
                        break;

                    case ProtocolMessageCode.rejected:
                        HandleRejected(data, endpoint);
                        break;

                    case ProtocolMessageCode.transactionsChunk3:
                        HandleTransactionsChunk3(data, endpoint);
                        break;

                    default:
                        Logging.warn("Unknown protocol message: {0}, from {1} ({2})", code, endpoint.getFullAddress(), endpoint.serverWalletAddress);
                        break;
                }
            }
            catch (Exception e)
            {
                Logging.error("Error parsing network message. Details: {0}", e.ToString());
            }
        }

        public override void shutdown()
        {
            Stop();
        }

        public override IxiNumber getMinSignerPowDifficulty(ulong blockNum, int curBlockVersion, long curBlockTimestamp)
        {
            return tiv.getMinSignerPowDifficulty(blockNum, curBlockVersion, curBlockTimestamp);
        }

        public override RegisteredNameRecord getRegName(byte[] name, bool useAbsoluteId)
        {
            throw new NotImplementedException();
        }

        private (Transaction? transaction, List<Address>? relayNodeAddresses, List<ExtendedAddress>? extendedAddresses) PrepareTransactionFrom(Address fromAddress, ExtendedAddress toAddress, IxiNumber amount, bool check_balance = true)
        {
            IxiNumber fee = ConsensusConfig.forceTransactionPrice;
            Dictionary<Address, ToEntry> toList = new(new AddressComparer());
            Address pubKey = new(IxianHandler.getWalletStorage().getPrimaryPublicKey());

            if (!IxianHandler.getWalletStorage().isMyAddress(fromAddress))
            {
                Console.WriteLine("From address is not my address.");
                return (null, null, null);
            }

            Dictionary<byte[], IxiNumber> fromList = new(new ByteArrayComparer())
            {
                { IxianHandler.getWalletStorage().getAddress(fromAddress).nonce, amount }
            };

            List<ExtendedAddress> extendedAddresses = new List<ExtendedAddress>();

            toList.AddOrReplace(toAddress.PaymentAddress, new ToEntry(Transaction.getExpectedVersion(IxianHandler.getLastBlockVersion()), amount, toAddress.Tag));

            if (toAddress.Flag != AddressPaymentFlag.Primary)
            {
                extendedAddresses.Add(toAddress);
            }

            List<Address> tmpRelayNodeAddresses = NetworkClientManager.getRandomConnectedClientAddresses(2);
            List<Address> relayNodeAddresses = new List<Address>();
            IxiNumber relayFee = 0;
            foreach (Address relayNodeAddress in tmpRelayNodeAddresses)
            {
                if (toList.ContainsKey(relayNodeAddress))
                {
                    continue;
                }
                var tmpFee = fee > ConsensusConfig.transactionDustLimit ? fee : ConsensusConfig.transactionDustLimit;
                ToEntry toEntry = new ToEntry(getExpectedVersion(IxianHandler.getLastBlockVersion()),
                                              tmpFee,
                                              null,
                                              null);
                relayNodeAddresses.Add(relayNodeAddress);
                toList.Add(relayNodeAddress, toEntry);
                relayFee += tmpFee;
            }

            // Prepare transaction to calculate fee
            Transaction transaction = new((int)Transaction.Type.Normal, fee, toList, fromList, pubKey, IxianHandler.getHighestKnownNetworkBlockHeight());

            relayFee = 0;
            foreach (Address relayNodeAddress in relayNodeAddresses)
            {
                var tmpFee = transaction.fee > ConsensusConfig.transactionDustLimit ? transaction.fee : ConsensusConfig.transactionDustLimit;
                toList[relayNodeAddress].amount = tmpFee;
                relayFee += tmpFee;
            }

            byte[] first_address = fromList.Keys.First();
            fromList[first_address] = fromList[first_address] + relayFee + transaction.fee;
            if (check_balance)
            {
                IxiNumber wal_bal = IxianHandler.getWalletBalance(new Address(transaction.pubKey.addressNoChecksum, first_address));
                if (fromList[first_address] > wal_bal)
                {
                    IxiNumber maxAmount = wal_bal - transaction.fee;

                    if (maxAmount < 0)
                        maxAmount = 0;

                    Console.WriteLine($"Insufficient funds to cover amount and transaction fee.\nMaximum amount you can send is {maxAmount} IXI.\n");
                    return (null, null, null);
                }
            }
            // Prepare transaction with updated "from" amount to cover fee
            transaction = new((int)Transaction.Type.Normal, fee, toList, fromList, pubKey, IxianHandler.getHighestKnownNetworkBlockHeight());
            return (transaction, relayNodeAddresses, extendedAddresses);
        }

        public Transaction? SendTransactionFrom(Address fromAddress, ExtendedAddress toAddress, IxiNumber amount, byte[]? requestId)
        {
            var prepTx = PrepareTransactionFrom(fromAddress, toAddress, amount);
            var transaction = prepTx.transaction;
            var relayNodeAddresses = prepTx.relayNodeAddresses;
            
            if (transaction == null || relayNodeAddresses == null)
            {
                return null;
            }

            // Send the transaction
            if (IxianHandler.addTransaction(transaction, relayNodeAddresses, prepTx.extendedAddresses, requestId, true))
            {
                Console.WriteLine($"Sending transaction, txid: {transaction.getTxIdString()}");
                return transaction;
            }
            else
            {
                Console.WriteLine($"Could not send transaction, txid: {transaction.getTxIdString()}");
            }
            return null;
        }

        public bool SendPayment(string toAddress, string amount)
        {
            try
            {
                var recipient = new ExtendedAddress(toAddress);
                var txAmount = new IxiNumber(amount);
                var myAddress = IxianHandler.getWalletStorage().getPrimaryAddress();

                var tx = SendTransactionFrom(myAddress, recipient, txAmount, null);
                return tx != null;
            }
            catch (Exception e)
            {
                Console.WriteLine($"Error sending payment: {e.Message}");
                return false;
            }
        }

        private IxiNumber lastKnownBalance = new IxiNumber(0);

        private void CheckBalanceChanges()
        {
            var myAddress = IxianHandler.getWalletStorage().getPrimaryAddress();
            var currentBalance = IxianHandler.getWalletBalance(myAddress);

            if (currentBalance != lastKnownBalance)
            {
                Console.WriteLine($"\n*** Balance changed: {lastKnownBalance} -> {currentBalance} IXI ***\n");
                lastKnownBalance = currentBalance;
            }
        }

        // Query your own balance
        public IxiNumber GetMyBalance()
        {
            var myAddress = IxianHandler.getWalletStorage().getPrimaryAddress();
            return IxianHandler.getWalletBalance(myAddress);
        }

        // Query any address balance (returns cached value, use RequestBalanceUpdate to refresh)
        public IxiNumber GetBalance(string address)
        {
            var addr = new Address(address);
            return IxianHandler.getWalletBalance(addr);
        }

        // Request fresh balance from network for specific address
        public void RequestBalanceUpdate(Address address)
        {
            byte[] getBalanceBytes;
            using (var ms = new MemoryStream())
            {
                using (var writer = new BinaryWriter(ms))
                {
                    writer.WriteIxiVarInt(address.addressNoChecksum.Length);
                    writer.Write(address.addressNoChecksum);
                }
                getBalanceBytes = ms.ToArray();
            }

            CoreProtocolMessage.broadcastProtocolMessage(
                new char[] { 'M', 'H', 'R' },
                ProtocolMessageCode.getBalance2,
                getBalanceBytes,
                null
            );
        }

        // Check if an address is online (from local cache)
        public bool IsAddressOnline(string address)
        {
            var addr = new Address(address);
            var presence = PresenceList.getPresenceByAddress(addr);
            return presence != null;
        }

        // Get presence details for an address (from local cache)
        public Presence? GetPresence(string address)
        {
            var addr = new Address(address);
            return PresenceList.getPresenceByAddress(addr);
        }

        // Request sector nodes from the network that handle a specific address's sector
        // The sector is determined by the first 10 bytes of the address's sector prefix
        public void RequestSector(string address)
        {
            var addr = new Address(address);
            Console.WriteLine($"Requesting sectors for {address}...");
            
            // Fetch relay nodes (S2 nodes) that are responsible for this address's sector
            CoreProtocolMessage.fetchSectorNodes(addr, CoreConfig.maxRelaySectorNodesToRequest);
            
            // The network will respond with sectorNodes message containing relay node presences
            // This is handled by HandleSectorNodes() which updates RelaySectors and PeerStorage
        }

        // Request presence information for a specific address from the network
        // Uses the sector-based routing system to query the appropriate relay nodes
        public void RequestPresence(string address)
        {
            var addr = new Address(address);
            Console.WriteLine($"Requesting presence for {address}...");

            // Create a temporary friend object to use the sector-based presence fetching mechanism
            var friend = new Friend(FriendType.Temporary, FriendState.Approved, addr, null, "", null, null, 0, true);
            
            // Get relay nodes responsible for this address's sector from local cache
            List<Peer> peers = new();
            var relays = RelaySectors.Instance.getSectorNodes(addr.sectorPrefix, CoreConfig.maxRelaySectorNodesToRequest);
            foreach (var relay in relays)
            {
                var p = PresenceList.getPresenceByAddress(relay);
                if (p == null)
                {
                    continue;
                }
                var pa = p.addresses.First();
                peers.Add(new(pa.address, relay, pa.lastSeenTime, 0, 0, 0));
            }
            
            // Assign sector nodes to the friend and request presence via streaming protocol
            friend.sectorNodes = peers;
            friend.updatedSectorNodes = Clock.getTimestamp();
            CoreStreamProcessor.fetchFriendsPresence(friend);

            // The network will respond with updatePresence messages
        }

        // Display presence information for an address
        public void DisplayPresenceInfo(string address)
        {
            var addr = new Address(address);
            var presence = PresenceList.getPresenceByAddress(addr);

            if (presence == null)
            {
                Console.WriteLine($"  Status: Offline or not found in local cache");
                Console.WriteLine($"  Tip: The presence might not be cached yet. Wait a few seconds after RequestPresence().");
                return;
            }

            Console.WriteLine($"  Status: Online");
            Console.WriteLine($"  Wallet: {presence.wallet?.ToString() ?? "N/A"}");
            Console.WriteLine($"  Endpoints: {presence.addresses.Count}");

            foreach (var endpoint in presence.addresses)
            {
                char nodeType = endpoint.type;
                string typeDesc = nodeType switch
                {
                    'C' => "Client",
                    'M' => "Master (DLT)",
                    'H' => "Host (DLT)",
                    'R' => "Relay (S2)",
                    'W' => "Worker",
                    _ => "Unknown"
                };
                Console.WriteLine($"    - {endpoint.address} (type: {nodeType} - {typeDesc})");
            }
        }

        // Get transaction status
        public string GetTransactionStatus(byte[] txid)
        {
            // Check if transaction is confirmed
            ActivityObject? activity = activityStorage.getActivityById(txid);
            if (activity == null)
            {
                return "Unknown (not found)";
            }

            switch (activity.status)
            {
                case ActivityStatus.Final:
                    return $"Confirmed in block {activity.appliedBlockHeight}";
                case ActivityStatus.Pending:
                    return "Pending (waiting for confirmation)";
                case ActivityStatus.Reverted:
                    return "Reverted (transaction was included in a block that got reverted)";
                case ActivityStatus.Rejected:
                    return "Rejected (transaction was rejected by the network)";
                case ActivityStatus.Expired:
                    return "Expired (transaction was not included in a block within the expected time frame)";
                case ActivityStatus.Unknown:
                    return "Unknown (transaction status cannot be verified)";
            }

            return "Unknown status";
        }

        // Get transaction status by string txid
        public string GetTransactionStatus(string txidString)
        {
            try
            {
                byte[] txid = Transaction.txIdLegacyToV8(txidString);
                return GetTransactionStatus(txid);
            }
            catch
            {
                return "Invalid transaction ID";
            }
        }

        private void HandleTransactionsChunk3(byte[] data, RemoteEndpoint endpoint)
        {
            if (endpoint.presenceAddress.type != 'M'
                        && endpoint.presenceAddress.type != 'H'
                        && endpoint.presenceAddress.type != 'R')
            {
                Console.WriteLine($"Received transactions chunk from non-master node {endpoint.getFullAddress()}. Ignoring.");
                return;
            }
            using (MemoryStream m = new MemoryStream(data))
            {
                using (BinaryReader reader = new BinaryReader(m))
                {
                    long msg_id = reader.ReadIxiVarInt();

                    int tx_count = (int)reader.ReadIxiVarUInt();

                    int max_tx_per_chunk = CoreConfig.maximumTransactionsPerChunk;
                    if (tx_count > max_tx_per_chunk)
                    {
                        tx_count = max_tx_per_chunk;
                    }

                    var sw = new System.Diagnostics.Stopwatch();
                    sw.Start();
                    int processedTxCount = 0;
                    int totalTxCount = 0;
                    for (int i = 0; i < tx_count; i++)
                    {
                        if (m.Position == m.Length)
                        {
                            break;
                        }

                        int tx_len = (int)reader.ReadIxiVarUInt();
                        byte[] tx_bytes = reader.ReadBytes(tx_len);

                        Transaction tx = new Transaction(tx_bytes, false, true);

                        totalTxCount++;

                        if (IxianHandler.addIncomingTransaction(tx))
                        {
                            processedTxCount++;
                        }
                    }
                    sw.Stop();
                    TimeSpan elapsed = sw.Elapsed;
                    Logging.info("Processed {0}/{1} txs for #{2} in {3}ms", processedTxCount, totalTxCount, msg_id, elapsed.TotalMilliseconds);
                }
            }
        }

        private void HandleBlockHeaders4(byte[] data, RemoteEndpoint endpoint)
        {
            if (endpoint.presenceAddress.type != 'M'
                && endpoint.presenceAddress.type != 'H'
                && endpoint.presenceAddress.type != 'R')
            {
                Logging.warn("Received block headers from non-master node {0}. Ignoring.", endpoint.getFullAddress());
                return;
            }
            using (MemoryStream m = new MemoryStream(data))
            {
                using (BinaryReader reader = new BinaryReader(m))
                {
                    ulong from = reader.ReadIxiVarUInt();
                    if (from > IxianHandler.getLastBlockHeight() + 1)
                    {
                        Logging.warn("Received block headers starting from {0}, but our last block height is {1}. Ignoring.", from, IxianHandler.getLastBlockHeight());
                        return;
                    }
                    ulong totalCount = reader.ReadIxiVarUInt();

                    int filterLen = (int)reader.ReadIxiVarUInt();
                    byte[] filterBytes = reader.ReadBytes(filterLen);

                    byte[] headersBytes = new byte[reader.BaseStream.Length - reader.BaseStream.Position];
                    Array.Copy(data, reader.BaseStream.Position, headersBytes, 0, headersBytes.Length);

                    tiv.receivedBlockHeaders3(headersBytes, endpoint);
                }
            }
        }

        private void HandleTransactionData(byte[] data, RemoteEndpoint endpoint)
        {
            if (endpoint.presenceAddress.type != 'M'
                && endpoint.presenceAddress.type != 'H'
                && endpoint.presenceAddress.type != 'R')
            {
                Logging.warn("Received transaction data from non-master node {0}. Ignoring.", endpoint.getFullAddress());
                return;
            }

            Transaction tx = new Transaction(data, true, true);

            // Check if my transaction
            bool myTransaction = IxianHandler.isMyAddress(tx.pubKey);
            if (!myTransaction)
            {
                foreach (var toEntry in tx.toList.Keys)
                {
                    if (IxianHandler.isMyAddress(toEntry))
                    {
                        myTransaction = true;
                        break;
                    }
                }
            }

            Logging.trace("Received new transaction {0}", tx.getTxIdString());

            if (myTransaction)
            {
                // If transaction already processed
                ActivityObject? activity = activityStorage.getActivityById(tx.id, null);
                if (activity != null)
                {
                    if (activity.status != ActivityStatus.Final)
                    {
                        if (endpoint.presenceAddress.type == 'M'
                            || endpoint.presenceAddress.type == 'H'
                            || endpoint.presenceAddress.type == 'R')
                        {
                            PendingTransactions.increaseReceivedCount(tx.id, endpoint.presence.wallet);
                        }
                    }
                }
                else
                {
                    IxianHandler.addIncomingTransaction(tx);
                }
            }
        }

        private void HandleKeepAlivesChunk(byte[] data, RemoteEndpoint endpoint)
        {
            using (MemoryStream m = new MemoryStream(data))
            {
                using (BinaryReader reader = new BinaryReader(m))
                {
                    int ka_count = (int)reader.ReadIxiVarUInt();

                    int max_ka_per_chunk = CoreConfig.maximumKeepAlivesPerChunk;
                    if (ka_count > max_ka_per_chunk)
                    {
                        ka_count = max_ka_per_chunk;
                    }

                    for (int i = 0; i < ka_count; i++)
                    {
                        if (m.Position == m.Length)
                        {
                            break;
                        }

                        int ka_len = (int)reader.ReadIxiVarUInt();
                        byte[] ka_bytes = reader.ReadBytes(ka_len);

                        HandleKeepAlivePresence(ka_bytes, endpoint);
                    }
                }
            }
        }

        private void HandleUpdatePresence(byte[] data, RemoteEndpoint endpoint)
        {
            // Parse the data and update entries in the presence list
            Presence? p = PresenceList.updateFromBytes(data, IxianHandler.getMinSignerPowDifficulty(IxianHandler.getLastBlockHeight(), IxianHandler.getLastBlockVersion(), 0));
            if (p == null)
            {
                return;
            }

            Logging.trace("Received presence update for " + p.wallet);
            Friend? f = FriendList.getFriend(p.wallet);
            if (f != null)
            {
                if (f.publicKey == null)
                {
                    f.setPublicKey(p.pubkey);
                }
                var pa = p.addresses[0];
                if (f.lastSeenTime < pa.lastSeenTime)
                {
                    // TODO use actual wallet address once Presence hostname contains such address
                    f.relayNode = new Peer(pa.address, null, pa.lastSeenTime, 0, 0, 0);
                    f.updatedStreamingNodes = pa.lastSeenTime;
                    f.lastSeenTime = pa.lastSeenTime;
                }
            }
        }

        private void HandleKeepAlivePresence(byte[] data, RemoteEndpoint endpoint)
        {
            Address address;
            long last_seen = 0;
            byte[] device_id;
            char node_type;
            bool updated = PresenceList.receiveKeepAlive(data, out address, out last_seen, out device_id, out node_type, endpoint);

            InventoryCache.Instance.setProcessedFlag(InventoryItemTypes.keepAlive2, InventoryItemKeepAlive2.getHash(last_seen, address, device_id));

            if (!updated)
            {
                return;
            }

            Logging.trace("Received keepalive update for " + address);
            Presence? p = PresenceList.getPresenceByAddress(address);
            if (p == null)
                return;

            Friend? f = FriendList.getFriend(p.wallet);
            if (f != null)
            {
                var pa = p.addresses[0];
                if (f.lastSeenTime < pa.lastSeenTime)
                {
                    // TODO use actual wallet address once Presence hostname contains such address
                    f.relayNode = new Peer(pa.address, null, pa.lastSeenTime, 0, 0, 0);
                    f.updatedStreamingNodes = pa.lastSeenTime;
                    f.lastSeenTime = pa.lastSeenTime;
                }
            }
        }

        private void HandleSectorNodes(byte[] data, RemoteEndpoint endpoint)
        {
            if (endpoint.presenceAddress.type != 'M'
                && endpoint.presenceAddress.type != 'H'
                && endpoint.presenceAddress.type != 'R')
            {
                Logging.warn("Received sector nodes from non-master node {0}. Ignoring.", endpoint.getFullAddress());
                return;
            }

            int offset = 0;

            var prefixAndOffset = data.ReadIxiBytes(offset);
            offset += prefixAndOffset.bytesRead;
            byte[] prefix = prefixAndOffset.bytes;

            var nodeCountAndOffset = data.GetIxiVarUInt(offset);
            offset += nodeCountAndOffset.bytesRead;
            int nodeCount = (int)nodeCountAndOffset.num;

            for (int i = 0; i < nodeCount; i++)
            {
                var kaBytesAndOffset = data.ReadIxiBytes(offset);
                offset += kaBytesAndOffset.bytesRead;

                Presence? p = PresenceList.updateFromBytes(kaBytesAndOffset.bytes, IxianHandler.getMinSignerPowDifficulty(IxianHandler.getLastBlockHeight(), IxianHandler.getLastBlockVersion(), 0));
                if (p != null)
                {
                    RelaySectors.Instance.addRelayNode(p.wallet);
                }
            }

            List<Peer> peers = new();
            var relays = RelaySectors.Instance.getSectorNodes(prefix, CoreConfig.maxRelaySectorNodesToRequest);
            foreach (var relay in relays)
            {
                var p = PresenceList.getPresenceByAddress(relay);
                if (p == null)
                {
                    continue;
                }
                var pa = p.addresses.First();
                peers.Add(new(pa.address, relay, pa.lastSeenTime, 0, 0, 0));

                PeerStorage.addPeerToPeerList(pa.address, p.wallet, pa.lastSeenTime, 0, 0, 0);
            }

            if (IxianHandler.primaryWalletAddress.sectorPrefix.SequenceEqual(prefix))
            {
                networkClientManagerStatic.setClientsToConnectTo(peers);
            }

            var friends = FriendList.getFriendsBySectorPrefix(prefix);
            foreach (var friend in friends)
            {
                friend.updatedSectorNodes = Clock.getTimestamp();
                friend.sectorNodes = peers;
            }

            friends = IXISocketConnections.GetPendingSectorRequestsBySectorPrefix(prefix);
            foreach (var friend in friends)
            {
                friend.updatedSectorNodes = Clock.getTimestamp();
                friend.sectorNodes = peers;
                IXISocketConnections.RemovePendingSectorRequest(friend);
            }
        }

        private void HandleRejected(byte[] data, RemoteEndpoint endpoint)
        {
            try
            {
                Rejected rej = new Rejected(data);
                switch (rej.code)
                {
                    case RejectedCode.TransactionInvalid:
                    case RejectedCode.TransactionInsufficientFee:
                    case RejectedCode.TransactionDust:
                        if (endpoint.presenceAddress.type != 'M'
                            && endpoint.presenceAddress.type != 'H'
                            && endpoint.presenceAddress.type != 'R')
                        {
                            Logging.error("Received 'rejected' message {0} {1} from non-master {2}", rej.code, Transaction.getTxIdString(rej.data), endpoint.getFullAddress());
                            return;
                        }
                        Logging.error("Transaction {0} was rejected with code: {1}", Transaction.getTxIdString(rej.data), rej.code);
                        PendingTransactions.increaseRejectedCount(rej.data, endpoint.serverWalletAddress);
                        break;

                    case RejectedCode.TransactionDuplicate:
                        if (endpoint.presenceAddress.type != 'M'
                            && endpoint.presenceAddress.type != 'H'
                            && endpoint.presenceAddress.type != 'R')
                        {
                            Logging.error("Received 'rejected' message {0} {1} from non-master {2}", rej.code, Transaction.getTxIdString(rej.data), endpoint.getFullAddress());
                            return;
                        }
                        Logging.warn("Transaction {0} already sent.", Transaction.getTxIdString(rej.data), rej.code);
                        // All good
                        PendingTransactions.increaseReceivedCount(rej.data, endpoint.serverWalletAddress);
                        break;

                    default:
                        Logging.error("Received 'rejected' message with unknown code {0} {1}", rej.code, Crypto.hashToString(rej.data));
                        break;
                }
            }
            catch (Exception e)
            {
                throw new Exception(string.Format("Exception occured while processing 'rejected' message with code {0} {1}", data[0], Crypto.hashToString(data)), e);
            }
        }
    }
}

Create ICLocalStorageCallbacks.cs:

using IXICore.Storage;
using IXICore.Streaming;

namespace IxianClient
{
    internal class ICLocalStorageCallbacks : LocalStorageCallbacks
    {
        public void processMessage(Friend friend, int channel, FriendMessage friendMessage)
        {
        }
    }
}

Create ICTransactionInclusionCallbacks.cs:

using IXICore;
using IXICore.Meta;
using IXICore.Activity;
using System;
using System.Linq;

namespace IxianClient
{
    internal class ICTransactionInclusionCallbacks : TransactionInclusionCallbacks
    {
        public void transactionVerified(Transaction tx)
        {
            var bh = IxianHandler.getBlockHeader(tx.applied);
            Node.activityStorage.updateStatus(tx.id, ActivityStatus.Final, tx.applied, bh.timestamp);
        }

        public void transactionRejected(Transaction tx)
        {
            tx.applied = 0;
            Node.activityStorage.updateStatus(tx.id, ActivityStatus.Rejected, 0);
        }

        public void transactionExpired(Transaction tx)
        {
            tx.applied = 0;
            Node.activityStorage.updateStatus(tx.id, ActivityStatus.Expired, 0);
        }

        public void transactionCannotVerify(Transaction tx)
        {
            tx.applied = 0;
            Node.activityStorage.updateStatus(tx.id, ActivityStatus.Unknown, 0);
        }

        public void receivedBlockHeader(Block blockHeader, bool verified)
        {
            foreach (Balance balance in IxianHandler.balances.Values)
            {
                if (balance.blockChecksum != null && balance.blockChecksum.SequenceEqual(blockHeader.blockChecksum))
                {
                    balance.verified = true;
                }
            }

            IxianHandler.status = NodeStatus.ready;
        }

        public void blockReorg(Block blockHeader)
        {
            var revertedTransactions = Node.activityStorage.revertTransactionsByBlockHeight(blockHeader.blockNum);
            foreach(var revertedTxId in revertedTransactions)
            {
                var activity = Node.activityStorage.getActivityById(revertedTxId, null, true);
                PendingTransactions.addOutgoingTransaction(activity.transaction, activity.transaction.toList.TakeLast(2).Select(x => x.Key).ToList());
            }
        }
    }
}

Create ICPendingMessageProcessor.cs:


using IXICore;
using IXICore.Streaming;

namespace IxianClient
{
    internal class ICPendingMessageProcessor : PendingMessageProcessor
    {
        public ICPendingMessageProcessor(string root_storage_path, bool enable_push_notification_server) : base(root_storage_path, enable_push_notification_server)
        {
        }

        protected override void onMessageSent(Friend friend, int channel, StreamMessage msg)
        {
            friend.setMessageSent(channel, msg.id);
        }

        protected override void onMessageExpired(Friend friend, int channel, StreamMessage msg)
        {
            removeMessage(friend, msg.id);
            friend.setMessageError(channel, msg.id);
        }
    }
}

Step 4: Create Main Program

Edit Program.cs:

using IXICore;
using IXICore.Meta;
using System;
using System.Threading;

namespace IxianClient
{
    class Program
    {
        static Node? node = null;

        static void Main(string[] args)
        {
            Console.WriteLine("Ixian Client Example");
            Console.WriteLine("====================\n");

            Logging.start(AppDomain.CurrentDomain.BaseDirectory, (int)(LogSeverity.warn | LogSeverity.error));
            Logging.consoleOutput = true;
            
            Console.CancelKeyPress += (sender, e) => {
                e.Cancel = true;
                IxianHandler.requestShutdown();
            };

            try
            {
                node = new Node();

                if (IxianHandler.forceShutdown)
                {
                    return;
                }

                node.Start();
                                
                // Display initial state
                DisplayStatus();
                
                // Interactive menu
                RunMenu();
            }
            catch (Exception e)
            {
                Console.WriteLine($"Fatal error: {e}");
            }
            finally
            {
                node?.Stop();
                Logging.stop();
            }
        }

        static void DisplayStatus()
        {
            var myAddress = IxianHandler.getWalletStorage().getPrimaryAddress();
            var balance = IxianHandler.getWalletBalance(myAddress);
            var blockHeight = IxianHandler.getHighestKnownNetworkBlockHeight();
            
            Console.WriteLine($"\nWallet Address: {myAddress}");
            Console.WriteLine($"Balance: {balance} IXI");
            Console.WriteLine($"Block Height: {blockHeight}\n");
        }

        static void RunMenu()
        {
            while (!IxianHandler.forceShutdown)
            {
                Console.WriteLine("\n===========================================");
                Console.WriteLine("Commands:");
                Console.WriteLine("  1 - Show status");
                Console.WriteLine("  2 - Send payment");
                Console.WriteLine("  3 - Check balance");
                Console.WriteLine("  4 - Check presence");
                Console.WriteLine("  5 - Check transaction status");
                Console.WriteLine("  6 - Request balance update");
                Console.WriteLine("  7 - Exit");
                Console.WriteLine("===========================================");
                Console.Write("Choice: ");

                var choice = Console.ReadLine();

                switch (choice)
                {
                    case "1":
                        DisplayStatus();
                        break;

                    case "2":
                        Console.Write("Recipient address: ");
                        var recipient = Console.ReadLine();
                        Console.Write("Amount (IXI): ");
                        var amount = Console.ReadLine();

                        if (!string.IsNullOrEmpty(recipient) && !string.IsNullOrEmpty(amount))
                        {
                            bool success = node?.SendPayment(recipient, amount) ?? false;
                            if (success)
                            {
                                Console.WriteLine("\n✓ Payment sent successfully!");
                                Console.WriteLine("  The transaction is now pending. Check status with option 5.");
                            }
                            else
                            {
                                Console.WriteLine("\n✗ Payment failed. Check the error message above.");
                            }
                        }
                        break;

                    case "3":
                        Console.Write("Your Address (or blank for your primary): ");
                        var addr = Console.ReadLine();

                        if (string.IsNullOrEmpty(addr))
                        {
                            var myBalance = node?.GetMyBalance() ?? new IxiNumber(0);
                            Console.WriteLine($"\nYour balance (cached): {myBalance} IXI");
                        }
                        else
                        {
                            var balance = node?.GetBalance(addr) ?? new IxiNumber(0);
                            Console.WriteLine($"\nYour balance (cached): {balance} IXI");
                        }
                        Console.WriteLine("Note: This is the cached value. Use option 6 to request fresh data from network.");
                        break;

                    case "4":
                        Console.Write("Address to check: ");
                        var presenceAddr = Console.ReadLine();

                        if (!string.IsNullOrEmpty(presenceAddr))
                        {
                            try
                            {
                                // First check cached presence
                                var isOnline = node?.IsAddressOnline(presenceAddr) ?? false;

                                if (!isOnline)
                                {
                                    Console.WriteLine("\nAddress not found in local cache. Requesting sectors from network...");
                                    node?.RequestSector(presenceAddr);
                                    Console.WriteLine("Waiting 2 seconds for network response...");
                                    Thread.Sleep(2000);

                                    Console.WriteLine("\nRequesting Presence from network...");
                                    node?.RequestPresence(presenceAddr);
                                    Console.WriteLine("Waiting 2 seconds for network response...");
                                    Thread.Sleep(2000);
                                }

                                // Display presence information
                                Console.WriteLine($"\nPresence information for {presenceAddr}:");
                                node?.DisplayPresenceInfo(presenceAddr);
                            }
                            catch (Exception ex)
                            {
                                Console.WriteLine($"Error: {ex.Message}");
                            }
                        }
                        break;

                    case "5":
                        Console.Write("Transaction ID: ");
                        var txid = Console.ReadLine();

                        if (!string.IsNullOrEmpty(txid))
                        {
                            try
                            {
                                CoreProtocolMessage.broadcastGetTransaction(Transaction.txIdLegacyToV8(txid), 0);
                                Console.WriteLine("Waiting 2 seconds for network response...");
                                Thread.Sleep(2000);
                                var status = node?.GetTransactionStatus(txid) ?? "Unknown";
                                Console.WriteLine($"\nTransaction status: {status}");
                            }
                            catch (Exception ex)
                            {
                                Console.WriteLine($"Error: {ex.Message}");
                            }
                        }
                        break;

                    case "6":
                        Console.Write("Address (or blank for your primary address): ");
                        var balAddr = Console.ReadLine();

                        try
                        {
                            Address addrToUpdate;
                            if (string.IsNullOrEmpty(balAddr))
                            {
                                addrToUpdate = IxianHandler.getWalletStorage().getPrimaryAddress();
                            }
                            else
                            {
                                addrToUpdate = new Address(balAddr);
                            }

                            Console.WriteLine($"\nRequesting balance update for {addrToUpdate}...");

                            node?.RequestBalanceUpdate(addrToUpdate);
                            Console.WriteLine("Request sent. Balance will update automatically in a few seconds.");
                        }
                        catch (Exception ex)
                        {
                            Console.WriteLine($"Error: {ex.Message}");
                        }
                        break;

                    case "7":
                        Console.WriteLine("\nShutting down...");
                        IxianHandler.requestShutdown();
                        break;

                    default:
                        Console.WriteLine("Invalid choice. Please select 1-7.");
                        break;
                }
            }
        }
    }
}

Step 5: Build and Run

# Build the project
dotnet build

# Run your client
dotnet run

You should see output like:

Starting Ixian Client...
Generated new wallet: 1abc...xyz
Node initialized. Wallet: 1abc...xyz
Node started and connecting to network...

Core Operations

Usage example:

// Send 10 IXI to an address
node.SendPayment("1abc...xyz", "10.00000000");

Receiving Transactions

Transactions sent to your wallet are automatically detected by the network client. To monitor incoming transactions:

// Automatic balance change monitoring
private IxiNumber lastKnownBalance = new IxiNumber(0);

private void CheckBalanceChanges()
{
    var myAddress = IxianHandler.getWalletStorage().getPrimaryAddress();
    var currentBalance = IxianHandler.getWalletBalance(myAddress);

    if (currentBalance != lastKnownBalance)
    {
        Console.WriteLine($"\n*** Balance changed: {lastKnownBalance} -> {currentBalance} IXI ***\n");
        lastKnownBalance = currentBalance;
    }
}

Querying Balances

// Query your own balance (cached)
public IxiNumber GetMyBalance()
{
    var myAddress = IxianHandler.getWalletStorage().getPrimaryAddress();
    return IxianHandler.getWalletBalance(myAddress);
}

// Query any address balance (returns cached value, use RequestBalanceUpdate to refresh)
public IxiNumber GetBalance(string address)
{
    var addr = new Address(address);
    return IxianHandler.getWalletBalance(addr);
}

// Request fresh balance from network for specific address
public void RequestBalanceUpdate(Address address)
{
    byte[] getBalanceBytes;
    using (var ms = new MemoryStream())
    {
        using (var writer = new BinaryWriter(ms))
        {
            writer.WriteIxiVarInt(address.addressNoChecksum.Length);
            writer.Write(address.addressNoChecksum);
        }
        getBalanceBytes = ms.ToArray();
    }

    CoreProtocolMessage.broadcastProtocolMessage(
        new char[] { 'M', 'H', 'R' },
        ProtocolMessageCode.getBalance2,
        getBalanceBytes,
        null
    );
}

Querying Presence Information

Ixian uses the Starling presence model with sector-based routing. To find an address, you first query its sector nodes (nodes responsible for that address's sector), then request presence from those nodes.

// Check if an address is online (from local cache)
public bool IsAddressOnline(string address)
{
    var addr = new Address(address);
    var presence = PresenceList.getPresenceByAddress(addr);
    return presence != null;
}

// Get presence details for an address (from local cache)
public Presence? GetPresence(string address)
{
    var addr = new Address(address);
    return PresenceList.getPresenceByAddress(addr);
}

// Request sector nodes from the network that handle a specific address's sector
// The sector is determined by the first 10 bytes of the address's sector prefix
public void RequestSector(string address)
{
    var addr = new Address(address);
    Console.WriteLine($"Requesting sectors for {address}...");
    
    // Fetch relay nodes (S2 nodes) that are responsible for this address's sector
    CoreProtocolMessage.fetchSectorNodes(addr, CoreConfig.maxRelaySectorNodesToRequest);
    
    // The network will respond with sectorNodes message containing relay node presences
    // This is handled by HandleSectorNodes() which updates RelaySectors and PeerStorage
}

// Request presence information for a specific address from the network
// Uses the sector-based routing system to query the appropriate relay nodes
public void RequestPresence(string address)
{
    var addr = new Address(address);
    Console.WriteLine($"Requesting presence for {address}...");

    // Create a temporary friend object to use the sector-based presence fetching mechanism
    var friend = new Friend(FriendType.Temporary, FriendState.Approved, addr, null, "", null, null, 0, true);
    
    // Get relay nodes responsible for this address's sector from local cache
    List<Peer> peers = new();
    var relays = RelaySectors.Instance.getSectorNodes(addr.sectorPrefix, CoreConfig.maxRelaySectorNodesToRequest);
    foreach (var relay in relays)
    {
        var p = PresenceList.getPresenceByAddress(relay);
        if (p == null) continue;
        
        var pa = p.addresses.First();
        peers.Add(new(pa.address, relay, pa.lastSeenTime, 0, 0, 0));
    }
    
    // Assign sector nodes to the friend and request presence via streaming protocol
    friend.sectorNodes = peers;
    friend.updatedSectorNodes = Clock.getTimestamp();
    CoreStreamProcessor.fetchFriendsPresence(friend);

    // The network will respond with updatePresence messages
}

// Display presence information for an address
public void DisplayPresenceInfo(string address)
{
    var addr = new Address(address);
    var presence = PresenceList.getPresenceByAddress(addr);

    if (presence == null)
    {
        Console.WriteLine($"  Status: Offline or not found in local cache");
        Console.WriteLine($"  Tip: Call RequestSector() and RequestPresence() first.");
        return;
    }

    Console.WriteLine($"  Status: Online");
    Console.WriteLine($"  Wallet: {presence.wallet?.ToString() ?? "N/A"}");
    Console.WriteLine($"  Endpoints: {presence.addresses.Count}");

    foreach (var endpoint in presence.addresses)
    {
        char nodeType = endpoint.type;
        string typeDesc = nodeType switch
        {
            'C' => "Client",
            'M' => "Master (DLT)",
            'H' => "Host (DLT)",
            'R' => "Relay (S2)",
            'W' => "Worker",
            _ => "Unknown"
        };
        Console.WriteLine($"    - {endpoint.address} (type: {nodeType} - {typeDesc})");
    }
}

How Starling Routing Works:

  1. Sector Calculation: Each address has a sector ID (first 10 bytes of SHA3-512 hash).
  2. Sector Nodes: S2 nodes announce their Presence to DLT nodes. DLT nodes determine sectors algorithmically.
  3. Discovery: Query DLT nodes for "which S2 nodes handle sector for address X?".
  4. Presence Announcement: Clients announce themselves to the network by sending Presence packet to S2 nodes that handle their sector.
  5. Presence Query: Connect to a few of those S2 nodes and request the specific presence.
  6. Caching: Presence is cached locally in PresenceList with expiration.

Best Practices

1. Always Use IxiNumber for Financial Values

// WRONG - floating point precision errors
double amount = 100.50;

// CORRECT
IxiNumber amount = new IxiNumber("100.50");
IxiNumber fee = ConsensusConfig.forceTransactionPrice;
IxiNumber total = amount + fee;

2. Use CryptoManager for All Cryptography

// All crypto through CryptoManager.lib
byte[] hash = CryptoManager.lib.sha3_512(data);
byte[] signature = CryptoManager.lib.getSignature(data, privateKey);

3. Validate all user entered Addresses and their Checksums

try
{
    var address = new Address(userInput);
    if (!address.validateChecksum())
    {
        Console.WriteLine("Invalid address checksum");
        return;
    }
}
catch
{
    Console.WriteLine("Invalid address format");
    return;
}

4. Handle Network Time

// WRONG - local time not synchronized
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();

// CORRECT - network-synchronized time
long timestamp = Clock.getNetworkTimestamp();

5. Implement Proper Error Handling

public bool SendTransaction(Transaction tx)
{
    try
    {
        if (tx == null)
        {
            Logging.error("Transaction is null");
            return false;
        }
        
        if (!tx.verifySignature())
        {
            Logging.error("Invalid transaction signature");
            return false;
        }
        
        return IxianHandler.addTransaction(tx, null, true);
    }
    catch (Exception e)
    {
        Logging.error($"Failed to send transaction: {e}");
        return false;
    }
}

Reference Implementation

For a complete, production-ready example, see QuIXI, which implements:

  • Full IxianNode implementation as a light client
  • REST API server
  • MQTT/RabbitMQ message queue integration
  • Contact management
  • Transaction broadcasting
  • Message handling
  • Configuration management

Key files to study:

Troubleshooting

Wallet won't load

  • Check file permissions on wallet.ixi
  • Verify wallet file isn't corrupted
  • Try generating a new wallet

Can't connect to network

  • Verify NetworkType (test vs main)
  • Check firewall settings
  • Ensure seed nodes are reachable

Transactions rejected

  • Verify sufficient balance (amount + fee)
  • Check transaction signature validity
  • Ensure correct blockHeight in transaction

Messages not sending

  • Verify recipient is in friend list
  • Check recipient's presence in PresenceList
  • Ensure streaming connections are active

Next Steps

Additional Resources