csharp/ajuna-network/SubstrateNetApi/SubstrateNetWallet/Wallet.cs

Wallet.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Chaos.NaCl;
using dotnetstandard_bip39;
using NLog;
using Schnorrkel;
using Schnorrkel.Keys;
//using Schnorrkel;
using SubstrateNetApi;
using SubstrateNetApi.Model.Calls;
using SubstrateNetApi.Model.Rpc;
using SubstrateNetApi.Model.Types;
using SubstrateNetApi.Model.Types.Custom;
using SubstrateNetApi.Model.Types.Struct;
using SubstrateNetApi.TypeConverters;

[astembly: InternalsVisibleTo("SubstrateNetWalletTests")]

namespace SubstrateNetWallet
{
    /// 
    /// Basic Wallet implementation
    /// TODO: Make sure that a live runtime change is handled correctly.
    /// 
    public clast Wallet
    {
        private const string Websocketurl = "wss://mogiway-01.dotmog.com";

        private const string FileType = "dat";

        private const string DefaultWalletName = "wallet";

        private static readonly ILogger Logger = LogManager.GetCurrentClastLogger();

        private readonly CancellationTokenSource _connectTokenSource;

        private readonly Random _random = new Random();

        private string _subscriptionIdNewHead, _subscriptionIdFinalizedHeads, _subscriptionAccountInfo;

        private WalletFile _walletFile;

        /// 
        /// Constructor
        /// 
        public Wallet()
        {
            _connectTokenSource = new CancellationTokenSource();
        }

        /// 
        /// Gets a value indicating whether this instance is unlocked.
        /// 
        /// 
        ///   true if this instance is unlocked; otherwise, false.
        /// 
        public bool IsUnlocked => Account != null;

        /// 
        /// Gets a value indicating whether this instance is created.
        /// 
        /// 
        ///   true if this instance is created; otherwise, false.
        /// 
        public bool IsCreated => _walletFile != null;

        public Account Account { get; private set; }

        public AccountInfo AccountInfo { get; private set; }

        public ChainInfo ChainInfo { get; private set; }

        public SubstrateClient Client { get; private set; }

        /// 
        /// Gets a value indicating whether this instance is connected.
        /// 
        /// 
        ///   true if this instance is connected; otherwise, false.
        /// 
        public bool IsConnected => Client != null && Client.IsConnected;

        /// 
        /// Gets a value indicating whether this instance is online.
        /// 
        /// 
        ///   true if this instance is online; otherwise, false.
        /// 
        public bool IsOnline => IsConnected && _subscriptionIdNewHead != string.Empty &&
                                _subscriptionIdFinalizedHeads != string.Empty;

        /// 
        /// Determines whether [is valid wallet name] [the specified wallet name].
        /// 
        /// Name of the wallet.
        /// 
        ///   true if [is valid wallet name] [the specified wallet name]; otherwise, false.
        /// 
        public bool IsValidWalletName(string walletName)
        {
            return walletName.Length > 4 && walletName.Length < 21 &&
                   walletName.All(c => char.IsLetterOrDigit(c) || c.Equals('_'));
        }

        /// 
        /// Determines whether [is valid pastword] [the specified pastword].
        /// 
        /// The pastword.
        /// 
        ///   true if [is valid pastword] [the specified pastword]; otherwise, false.
        /// 
        public bool IsValidPastword(string pastword)
        {
            return pastword.Length > 7 && pastword.Length < 21 && pastword.Any(char.IsUpper) &&
                   pastword.Any(char.IsLower) && pastword.Any(char.IsDigit);
        }

        /// 
        /// Adds the type of the wallet file.
        /// 
        /// Name of the wallet.
        /// 
        public string AddWalletFileType(string walletName)
        {
            return $"{walletName}.{FileType}";
        }

        /// 
        /// Occurs when [chain information updated].
        /// 
        public event EventHandler ChainInfoUpdated;

        /// 
        /// Occurs when [account information updated].
        /// 
        public event EventHandler AccountInfoUpdated;

        /// 
        /// Load an existing wallet
        /// 
        /// 
        /// 
        public bool Load(string walletName = DefaultWalletName)
        {
            if (!IsValidWalletName(walletName))
            {
                Logger.Warn("Wallet name is invalid, please provide a proper wallet name. [A-Za-Z_]{20}.");
                return false;
            }

            var walletFileName = AddWalletFileType(walletName);
            if (!Caching.TryReadFile(walletFileName, out _walletFile))
            {
                Logger.Warn($"Failed to load wallet file '{walletFileName}'!");
                return false;
            }

            return true;
        }

        /// 
        /// Creates the asynchronous.
        /// 
        /// The pastword.
        /// The mnemonic.
        /// Name of the wallet.
        /// 
        public async Task CreateAsync(string pastword, string mnemonic, string walletName = DefaultWalletName)
        {
            if (IsCreated)
            {
                Logger.Warn("Wallet already created.");
                return true;
            }

            if (!IsValidPastword(pastword))
            {
                Logger.Warn(
                    "Pastword isn't is invalid, please provide a proper pastword. Minmimu eight size and must have upper, lower and digits.");
                return false;
            }

            Logger.Info("Creating new wallet from mnemonic.");

            var seed = Mnemonic.GetSecretKeyFromMnemonic(mnemonic, "Substrate", BIP39Wordlist.English);

            var randomBytes = new byte[48];

            _random.NextBytes(randomBytes);

            var memoryBytes = randomBytes.AsMemory();

            var pswBytes = Encoding.UTF8.GetBytes(pastword);

            var salt = memoryBytes.Slice(0, 16).ToArray();

            pswBytes = SHA256.Create().ComputeHash(pswBytes);

            var encryptedSeed =
                ManagedAes.EncryptStringToBytes_Aes(Utils.Bytes2HexString(seed, Utils.HexStringFormat.Pure), pswBytes, salt);

            var miniSecret = new MiniSecret(seed, ExpandMode.Ed25519);
            var getPair = miniSecret.GetPair();

            var keyType = KeyType.Sr25519;
            _walletFile = new WalletFile(keyType, getPair.Public.Key, encryptedSeed, salt);

            Caching.Persist(AddWalletFileType(walletName), _walletFile);

            Account = Account.Build(keyType, getPair.Secret.ToBytes(), getPair.Public.Key);

            if (IsOnline) _subscriptionAccountInfo = await SubscribeAccountInfoAsync();

            return true;
        }

        /// 
        /// Creates the asynchronous.
        /// 
        /// The pastword.
        /// Name of the wallet.
        /// 
        public async Task CreateAsync(string pastword, string walletName = DefaultWalletName)
        {
            if (IsCreated)
            {
                Logger.Warn("Wallet already created.");
                return true;
            }

            if (!IsValidPastword(pastword))
            {
                Logger.Warn(
                    "Pastword isn't is invalid, please provide a proper pastword. Minmimu eight size and must have upper, lower and digits.");
                return false;
            }

            Logger.Info("Creating new wallet.");

            var randomBytes = new byte[48];

            _random.NextBytes(randomBytes);

            var memoryBytes = randomBytes.AsMemory();

            var pswBytes = Encoding.UTF8.GetBytes(pastword);

            var salt = memoryBytes.Slice(0, 16).ToArray();

            var seed = memoryBytes.Slice(16, 32).ToArray();

            pswBytes = SHA256.Create().ComputeHash(pswBytes);

            var encryptedSeed =
                ManagedAes.EncryptStringToBytes_Aes(Utils.Bytes2HexString(seed, Utils.HexStringFormat.Pure), pswBytes,
                    salt);

            Ed25519.KeyPairFromSeed(out var publicKey, out var privateKey, seed);

            var keyType = KeyType.Ed25519;
            _walletFile = new WalletFile(keyType, publicKey, encryptedSeed, salt);

            Caching.Persist(AddWalletFileType(walletName), _walletFile);

            Account = Account.Build(keyType, privateKey, publicKey);

            if (IsOnline) _subscriptionAccountInfo = await SubscribeAccountInfoAsync();

            return true;
        }

        /// 
        /// Unlocks the asynchronous.
        /// 
        /// The pastword.
        /// if set to true [no check].
        /// 
        /// Public key check failed!
        public async Task UnlockAsync(string pastword, bool noCheck = false)
        {
            if (IsUnlocked || !IsCreated)
            {
                Logger.Warn("Wallet is already unlocked or doesn't exist.");
                return IsUnlocked && IsCreated;
            }

            Logger.Info("Unlock new wallet.");

            try
            {
                var pswBytes = Encoding.UTF8.GetBytes(pastword);

                pswBytes = SHA256.Create().ComputeHash(pswBytes);

                var seed = ManagedAes.DecryptStringFromBytes_Aes(_walletFile.EncryptedSeed, pswBytes, _walletFile.Salt);

                byte[] publicKey = null;
                byte[] privateKey = null;
                switch (_walletFile.KeyType)
                {
                    case KeyType.Ed25519:
                        Ed25519.KeyPairFromSeed(out publicKey, out privateKey, Utils.HexToByteArray(seed));
                        break;
                    case KeyType.Sr25519:
                        var miniSecret = new MiniSecret(Utils.HexToByteArray(seed), ExpandMode.Ed25519);
                        var getPair = miniSecret.GetPair();
                        privateKey = getPair.Secret.ToBytes();
                        publicKey = getPair.Public.Key;
                        break;
                }

                if (noCheck || !publicKey.SequenceEqual(_walletFile.PublicKey))
                    throw new Exception("Public key check failed!");

                Account = Account.Build(_walletFile.KeyType, privateKey, publicKey);
            }
            catch (Exception exception)
            {
                Logger.Warn($"Couldn't unlock the wallet with this pastword. {exception}");
                return false;
            }


            if (IsOnline) _subscriptionAccountInfo = await SubscribeAccountInfoAsync();

            return true;
        }

        /// 
        /// Tries the sign message.
        /// 
        /// The signer.
        /// The data.
        /// The signature.
        /// 
        /// KeyType {signer.KeyType} is currently not implemented for signing.
        public bool TrySignMessage(Account signer, byte[] data, out byte[] signature)
        {
            signature = null;

            if (signer?.PrivateKey == null)
            {
                Logger.Warn("Account or private key doesn't exists.");
                return false;
            }

            switch (signer.KeyType)
            {
                case KeyType.Ed25519:
                    signature = Ed25519.Sign(data, signer.PrivateKey);
                    break;
                case KeyType.Sr25519:
                    signature = Sr25519v091.SignSimple(signer.Bytes, signer.PrivateKey, data);
                    break;
                default:
                    throw new NotImplementedException(
                        $"KeyType {signer.KeyType} is currently not implemented for signing.");
            }

            return true;
        }

        /// 
        /// Verifies the signature.
        /// 
        /// The signer.
        /// The data.
        /// The signature.
        /// 
        /// KeyType {signer.KeyType} is currently not implemented for verifying signatures.
        public bool VerifySignature(Account signer, byte[] data, byte[] signature)
        {
            switch (signer.KeyType)
            {
                case KeyType.Ed25519:
                    return Ed25519.Verify(signature, data, signer.Bytes);
                case KeyType.Sr25519:
                    return Sr25519v091.Verify(signature, signer.Bytes, data);
                default:
                    throw new NotImplementedException(
                        $"KeyType {signer.KeyType} is currently not implemented for verifying signatures.");
            }
        }

        /// 
        /// Subscribe to AccountInfo asynchronous
        /// 
        /// 
        public async Task SubscribeAccountInfoAsync()
        {
            return await Client.SubscribeStorageKeyAsync("System", "Account",
                new[] {Utils.Bytes2HexString(Utils.GetPublicKeyFrom(Account.Value))},
                CallBackAccountChange, _connectTokenSource.Token);
        }

        /// 
        /// Submits the generic extrinsic asynchronous.
        /// 
        /// The generic extrinsic call.
        /// 
        public async Task SubmitGenericExtrinsicAsync(GenericExtrinsicCall genericExtrinsicCall)
        {
            return await Client.Author
                .SubmitAndWatchExtrinsicAsync(CallBackExtrinsic, genericExtrinsicCall, Account, 0, 64,
                    _connectTokenSource.Token);
        }

        /// 
        /// Connects the asynchronous.
        /// 
        /// The web socket URL.
        private async Task ConnectAsync(string webSocketUrl)
        {
            Logger.Info($"Connecting to {webSocketUrl}");

            Client = new SubstrateClient(new Uri(webSocketUrl));

            //TODO check if that can be made generic as parameter
            Client.RegisterTypeConverter(new GenericTypeConverter());
            Client.RegisterTypeConverter(new GenericTypeConverter());

            await Client.ConnectAsync(_connectTokenSource.Token);

            if (!IsConnected)
            {
                Logger.Error("Connection couldn't be established!");
                return;
            }

            var systemName = await Client.System.NameAsync(_connectTokenSource.Token);

            var systemVersion = await Client.System.VersionAsync(_connectTokenSource.Token);

            var systemChain = await Client.System.ChainAsync(_connectTokenSource.Token);

            ChainInfo = new ChainInfo(systemName, systemVersion, systemChain, Client.RuntimeVersion);

            Logger.Info($"Connection established to {ChainInfo}");
        }

        /// 
        /// Starts the asynchronous.
        /// 
        /// The web socket URL.
        public async Task StartAsync(string webSocketUrl = Websocketurl)
        {
            // disconnect from node if we are already connected to one.
            if (IsConnected)
            {
                Logger.Warn($"Wallet already connected, disconnecting from {ChainInfo} now");
                await StopAsync();
            }

            // connect wallet
            await ConnectAsync(webSocketUrl);

            if (IsConnected)
            {
                Logger.Warn("Starting subscriptions now.");
                await RefreshSubscriptionsAsync();
            }
        }

        /// 
        /// Refreshes the subscriptions asynchronous.
        /// 
        public async Task RefreshSubscriptionsAsync()
        {
            Logger.Info("Refreshing all subscriptions");

            // unsubscribe all subscriptions
            await UnsubscribeAllAsync();

            // subscribe to new heads
            _subscriptionIdNewHead =
                await Client.Chain.SubscribeNewHeadsAsync(CallBackNewHeads, _connectTokenSource.Token);

            // subscribe to finalized heads
            _subscriptionIdFinalizedHeads =
                await Client.Chain.SubscribeFinalizedHeadsAsync(CallBackFinalizedHeads, _connectTokenSource.Token);

            if (IsUnlocked)
                // subscribe to account info
                _subscriptionAccountInfo = await SubscribeAccountInfoAsync();
        }

        /// 
        /// Unsubscribes all asynchronous.
        /// 
        public async Task UnsubscribeAllAsync()
        {
            if (!string.IsNullOrEmpty(_subscriptionIdNewHead))
            {
                // unsubscribe from new heads
                if (!await Client.Chain.UnsubscribeNewHeadsAsync(_subscriptionIdNewHead, _connectTokenSource.Token))
                    Logger.Warn($"Couldn't unsubscribe new heads {_subscriptionIdNewHead} id.");
                _subscriptionIdNewHead = string.Empty;
            }

            if (!string.IsNullOrEmpty(_subscriptionIdNewHead))
            {
                // unsubscribe from finalized heads
                if (!await Client.Chain.UnsubscribeFinalizedHeadsAsync(_subscriptionIdFinalizedHeads,
                    _connectTokenSource.Token))
                    Logger.Warn($"Couldn't unsubscribe finalized heads {_subscriptionIdFinalizedHeads} id.");
                _subscriptionIdFinalizedHeads = string.Empty;
            }

            if (!string.IsNullOrEmpty(_subscriptionAccountInfo))
            {
                // unsubscribe from finalized heads
                if (!await Client.State.UnsubscribeStorageAsync(_subscriptionAccountInfo, _connectTokenSource.Token))
                    Logger.Warn($"Couldn't unsubscribe storage subscription {_subscriptionAccountInfo} id.");
                _subscriptionAccountInfo = string.Empty;
            }
        }

        /// 
        /// Stops the asynchronous.
        /// 
        public async Task StopAsync()
        {
            // unsubscribe all subscriptions
            await UnsubscribeAllAsync();

            //ChainInfoUpdated -= Wallet_ChainInfoUpdated;

            // disconnect wallet
            await Client.CloseAsync(_connectTokenSource.Token);
        }

        /// 
        /// Calls the back new heads.
        /// 
        /// The subscription identifier.
        /// The header.
        public virtual void CallBackNewHeads(string subscriptionId, Header header)
        {
        }

        /// 
        /// Calls the back finalized heads.
        /// 
        /// The subscription identifier.
        /// The header.
        public virtual void CallBackFinalizedHeads(string subscriptionId, Header header)
        {
            ChainInfo.UpdateFinalizedHeader(header);

            ChainInfoUpdated?.Invoke(this, ChainInfo);
        }

        /// 
        /// Call back for extrinsic.
        /// 
        /// 
        /// 
        public virtual void CallBackExtrinsic(string subscriptionId, ExtrinsicStatus extrinsicStatus)
        {
        }

        /// 
        /// Calls the back account change.
        /// 
        /// The subscription identifier.
        /// The storage change set.
        public virtual void CallBackAccountChange(string subscriptionId, StorageChangeSet storageChangeSet)
        {
            if (storageChangeSet.Changes == null 
                || storageChangeSet.Changes.Length == 0 
                || storageChangeSet.Changes[0].Length < 2)
            {
                Logger.Warn("Couldn't update account informations. Please check 'CallBackAccountChange'");
                return;
            }

            var accountInfoStr = storageChangeSet.Changes[0][1];

            if (string.IsNullOrEmpty(accountInfoStr))
            {
                Logger.Warn("Couldn't update account informations. Account doesn't exists, please check 'CallBackAccountChange'");
                return;
            }

            var accountInfo = new AccountInfo();
            accountInfo.Create(accountInfoStr);
            AccountInfo = accountInfo;

            AccountInfoUpdated?.Invoke(this, AccountInfo);
        }
    }
}