Skip to content
Rostislav Litovkin edited this page Dec 24, 2023 · 15 revisions

SubstrateClient

This section explains how to initialize SubstrateClient, which is used to communicate with RPC nodes.

SubstrateClient will allow you to:

  1. make queries
  2. submit extrinsics
  3. listen to events

Scaffolding with Substrate.NET.Toolchain

I recommend scaffolding the SubstrateClient first with Substrate.NET.Toolchain.

You can find the latest docs for the scaffolding process here: https://github.com/SubstrateGaming/Substrate.NET.Toolchain.

Install our .NET template with the following command:

dotnet new install Substrate.DotNet.Template

which makes dotnet new substrate available.

Using a terminal of your choice, create a new directory for your project and execute the following command in that directory:

dotnet new sln
dotnet new substrate \
--sdk_version 0.4.4 \
--rest_service Substrate.NetApi.RestService \
--net_api Substrate.NetApi.NetApiExt \
--rest_client Substrate.NetApi.RestClient \
--metadata_websocket <websocket_url> \
--generate_openapi_documentation false \
--force \
--allow-scripts yes

which generates a new solution and a couple of .NET projects in your project directory. (A description for all command parameters can be found here)

  • Replace the websocket_url with either ws://127.0.0.1:9944 to connect to a local chain or any other url to connect to an RPC node, e.g. wss://rpc.polkadot.io to connect to Polkadot relay chain.

Initialize SubstrateClient

I will assume that you will use the SubstrateClientExt that was generated by the Toolchain instead of the barebones SubstrateClient.

usingSubstrate.NetApi.Model.Extrinsics;usingSubstrate.NetApi.NetApiExt.Generated;varclient=newSubstrateClientExt(newUri("wss://rpc.polkadot.io"),ChargeTransactionPayment.Default());awaitclient.ConnectAsync();

You can also ensure that the client is connected like this:

if(!client.IsConnected){// Ensure that the client connects}

Account

To interact with Substrate chains, you will need an Account. Currently, there are 3 ways to getting the account

Mnemonics

Unless you are building a crypto wallet, I do not recommend using this in the production. It is not User friendly and is the least secure out of all of the options.

Generate a new mnemonic:

varrandom=RandomNumberGenerator.Create();// Cryptographically secure RNGvarentropyBytes=newbyte[16];random.GetBytes(entropyBytes);stringmnemonics=string.Join(" ",Mnemonic.MnemonicFromEntropy(entropyBytes,BIP39Wordlist.English));// You might want to display the mnemonicsConsole.WriteLine(mnemonics);

Create an Account from mnemonic:

ExpandModeexpandMode=ExpandMode.Ed25519;// Currently preferred in Substratestringpassword="please_put_here_something_better_than_password123";varsecret=Mnemonic.GetSecretKeyFromMnemonic(mnemonics,password,BIP39Wordlist.English);varminiSecret=newMiniSecret(secret,expandMode);// Our actual Account, that will be used for signing extrinsics/messagesAccountaccount=Account.Build(KeyType.Sr25519,miniSecret.ExpandToSecret().ToBytes(),miniSecret.GetPair().Public.Key);

Import JSON file

Connecting via Plutonication

Docs currently unavailable

Substrate types

Substrate uses Rust. Rust is a strictly typed language. To be sure that you will use the right types, you need to use the Substrate types, that are either included in the Substrate.NetApi or generated by the Substrate.NET.Toolchain. These Substrate types can be easily encoded to and decoded from SCALE codec.

Primitive Types

  • Bool = bool
  • U8 = byte
  • U16 = ushort
  • U32 = uint - Very often used for indexing/ids
  • U64 = ulong
  • U128 = BigInteger - Very often used for tracking Balance
  • U256 = BigInteger - Almost never used
  • PrimChar = char
  • Str = string - Never used in Substrate, but is useful for correct SCALE encoding/decoding.

Composite types

I will mention a few of the compound types that are often present in Substrate

  • Vec<U8> - used as string in Substrate. It is equal to UTF-8 encoded/decoded string.
  • AccountId32 - used for representing an account address
  • EnumMultiAddress
  • BaseCom - Used for compacting types

Examples

// U128 numberU128number=newU128(1000);// The same 1000 number in type U128U128number2=newU128();number2.Create("0xE8030000000000000000000000000000");// string messageBaseVec<U8>message=newBaseVec<U8>();message.Create(newStr("Hello Substrate").Encode());// account idAccountId32accountId=newAccountId32();accountId.Create(Utils.GetPublicKeyFrom("5EU6EyEq6RhqYed1gCYyQRVttdy6FC9yAtUUGzPe3gfpFX8y"));

SCALE Encode() and Decode()

All Substrate types support .Encode() and .Decode(byte[] byteArray, ref int p) methods.

  1. Encode() encodes the Substrate type and returns a SCALE encoded byte[]
  2. Decode(byte[] byteArray, ref int p) decodes a SCALE encoded byte[] into the desired Substrate type. The p variable is used for tracking the current index of the byte array. It may be handy when decoding Composite types.

Utils

Utils class is useful when working with byte[]. It is not only useful for translating strings to byte[] and back, but it can also help you when working with addresses. Here are a few methods that you should be on a lookout.

  1. Bytes2HexString(byte[] bytes, HexStringFormat format = HexStringFormat.Prefixed) - Converts byte[] to hexadecimal string. Useful when wanting to display the hex values.
  2. HexToByteArray(string hex, bool evenLeftZeroPad = false) - Converts hexadecimal string to byte array.
  3. SizePrefixedByteArray(List<byte> list) - Converts List<byte> to byte[] with a CompactInteger encoded value at the start, indicating the length of the list.
  4. GetPublicKeyFrom(string address, out short network) - returns the hex value of SS58 encoded address, which is handy when creating AccountId object. This method also gives SS58 prefix (network variable), which might indicate the chain the user might be using.
  5. GetAddressFrom(byte[] bytes, short ss58Prefix = 42) - Encodes a hex public key into an SS58 encoded one.

Building a method

Substrate.NetApi has got a Method type which describes the actions you want to do in a submitted extrinsic.

Luckily, Substrate.NET.Toolchain has generated helper classes that help us get the Method, that will be called.

To get the Method, you will use <name-of-the-pallet>Calls.<name-of-the-call>(<params>). Here is an example for System.remark("Hello Remark"), which takes as a parameter BaseVec<U8>:

// Creating the parameter messageBaseVec<U8>message=newBaseVec<U8>();message.Create(Encoding.UTF8.GetBytes("Hello Remark"));// Getting the actual MethodMethodvote=SystemCalls.Remark(message);

Submit Extrinsics

Interacting with the chain requires the signing and submission of extrinsics (akin to transactions).

To effectively submit an extrinsic, you must be familiar with:

  1. Acquiring an Account
  2. Constructing a Method

Below is an example of the Balances.transferKeepAlive extrinsic:

// For details on acquiring an account, see: https://github.com/SubstrateGaming/Substrate.NET.API/wiki/Docs#accountAccountaccount=<way_to_get_your_account>;varaccountId=newAccountId32();accountId.Create(Utils.GetPublicKeyFrom("5EU6EyEq6RhqYed1gCYyQRVttdy6FC9yAtUUGzPe3gfpFX8y"));varmultiAddress=newEnumMultiAddress();multiAddress.Create(0,accountId);varamount=newBaseCom<U128>(10000000000);// This is equivalent to 1 DOT (10^10 planks)// Building the transferKeepAlive MethodMethodtransfer=BalancesCalls.TransferKeepAlive(multiAddress,amount);// The charge determines the tip you wish to pay (and its currency)ChargeTypecharge=true?ChargeTransactionPayment.Default():ChargeAssetTxPayment.Default();uintlifeTime=64;// For details, refer to: https://polkadot.js.org/docs/api/FAQ/#how-long-do-transactions-liveCancellationTokentoken=CancellationToken.None;// Consider using a CancellationToken if neededawaitclient.Author.SubmitExtrinsicAsync(transfer,account,charge,lifeTime,token);

SubmitAndWatch

SubmitAndWatch is a specialized function within the Substrate RPC calls. It lets users submit an extrinsic and then track its inclusion within the blockchain. Upon invoking this function, a subscription ID is returned. This ID is crucial for monitoring the block inclusion trajectory of the extrinsic, facilitated through callbacks.

Callbacks provide information on the block inclusion status, which is structured using the ExtrinsicStatus model. The sequence for a successful callback is as follows:

  • None
  • Ready
  • None + InBlock(Hash)
  • None + Finalized(Hash)

A critical point to remember is that SubmitAndWatch only informs if the extrinsic was processed and added to a block. It doesn't convey the result of the extrinsic's execution (i.e., success or failure).

Query chain State

To get the chain state, like what is the balance of an account.

Fortunately, Substrate.NET.Toolchain has generated helper classes that live in the client. To use them, just type client.<pallet-name>Storage.<storage-name>(<params>). Just like this:

varaccountId=newAccountId32();accountId.Create(Utils.GetPublicKeyFrom("5EU6EyEq6RhqYed1gCYyQRVttdy6FC9yAtUUGzPe3gfpFX8y"));// Query chain statevaraccountBalance=awaitclient.SystemStorage.Account(accountId,token);// Polkadot has got 10 decimals// Do not forget to check if the accountBalance is not nulldoubleassetBalance=accountBalance!=null?(double)accountBalance.Data.Free.Value/Math.Pow(10,10):0;// You might want to show the balanceConsole.WriteLine(String.Format("Account balance: {0:0.00} DOT",assetBalance));

Instead of using "5EU6..pFX8y", you can use account.Value to get an address of your account.

accountBalance variable is of type AccountInfo, which is a rust struct translated to c# class by Substrate.NET.Toolchain.

Also, bear in mind that if nothing is saved at that Storage position, await client.SystemStorage.Account(accountId, token) will return null.

Query StorageMaps

Querying data over a StorageMap is a little bit more difficult. To get a full understanding how StorageMaps work in Substrate, I recommend reading this blog post by Shawn: https://www.shawntabrizi.com/substrate/transparent-keys-in-substrate/.

The will need to:

  1. Get the Storage prefix
  2. Get List of all of the keys starting with the Storage prefix
  3. Query the List of keys to get the Storage changes.
  4. Decode Storage changes to the desired type.

The following code shows how to query balances of all Accounts:

List<AccountInfo>accounts=awaitGetSystemAccountsAsync(client,CancellationToken.None);foreach(varainaccounts){// Polkadot has got 10 decimalsdoubleaBalance=(double)a.Data.Free.Value/Math.Pow(10,10);// You might want to show the balanceConsole.WriteLine(String.Format("Account balance: {0:0.00} DOT",aBalance));}/// <summary>/// Helper method that queries all System.Account values/// </summary>/// <param name="client">Your SubstrateClient</param>/// <param name="token">CancellationToken</param>/// <returns>List<AccountInfo></returns>staticasyncTask<List<AccountInfo>>GetSystemAccountsAsync(SubstrateClientExtclient,CancellationTokentoken){// Get the Storage prefixbyte[]prefix=RequestGenerator.GetStorageKeyBytesHash("System","Account");// First startKey is unknownbyte[]startKey=null;List<string[]>storageChanges=newList<string[]>();while(true){// Get List of all of the keys starting with the Storage prefixvarkeysPaged=awaitclient.State.GetKeysPagedAtAsync(prefix,1000,startKey,string.Empty,token);if(keysPaged==null||!keysPaged.Any()){break;}else{// temp variablevartt=awaitclient.State.GetQueryStorageAtAsync(keysPaged.Select(p =>Utils.HexToByteArray(p.ToString())).ToList(),string.Empty,token);storageChanges.AddRange(newList<string[]>(tt.ElementAt(0).Changes));// update the startKey to query next keysstartKey=Utils.HexToByteArray(tt.ElementAt(0).Changes.Last()[0]);}}// Result listvaraccountInfoList=newList<AccountInfo>();if(storageChanges!=null){foreach(varstorageChangeSetinstorageChanges){// Decoding Storage changes to the desired type.AccountInfoaccountInfo=newAccountInfo();accountInfo.Create(storageChangeSet[1]);accountInfoList.Add(accountInfo);}}returnaccountInfoList;}

Also keep in mind that you might query thousands of storage keys - this might take a while or may not complete at all.

Query DoubleStorageMaps and StorageNMaps

Querying over a DoubleStorageMap is very similar to the StorageMap. The only difference is that you do not have to query all of the keys, but you can just query data under a certain key.

The will need to:

  1. Get the Storage prefix
  2. Get List of all of the keys starting with the Storage prefix
  3. Query the List of keys to get the Storage changes.
  4. Decode Storage changes to the desired type.

The following code shows how to query all conviction votings for a given address:

List<EnumVoting>votings=awaitGetConvictionVotingsForAccountAsync(client,accountId,CancellationToken.None);// I want to print the polkadot encoded addressstringss58PolkadotAddress=Utils.GetAddressFrom(accountId.Encode(),0);foreach(varvinvotings){if(v.Value==Voting.Casting){foreach(varvotein((Casting)v.Value2).Votes.Value.Value){uintreferendumId=((U32)vote.Value[0]).Value;Console.WriteLine("Account "+ss58PolkadotAddress+" casted a vote for "+referendumId);}}}/// <summary>/// Helper method that queries all votings for a given AccountId/// </summary>/// <param name="client">Your SubstrateClient</param>/// <param name="accountId">The respective AccountId for which you want to query conviction votings</param>/// <param name="token">CancellationToken</param>/// <returns>List<EnumVoting></returns>staticasyncTask<List<EnumVoting>>GetConvictionVotingsForAccountAsync(SubstrateClientExtclient,AccountId32accountId,CancellationTokentoken){// Get the Storage prefixvarkeyBytes=RequestGenerator.GetStorageKeyBytesHash("ConvictionVoting","VotingFor");byte[]prefix=keyBytes.Concat(HashExtension.Hash(Hasher.Twox64Concat,accountId.Encode())).ToArray();// First startKey is unknownbyte[]startKey=null;List<string[]>storageChanges=newList<string[]>();while(true){// Get List of all of the keys starting with the Storage prefixvarkeysPaged=awaitclient.State.GetKeysPagedAtAsync(prefix,1000,startKey,string.Empty,token);if(keysPaged==null||!keysPaged.Any()){break;}else{// temp variablevartt=awaitclient.State.GetQueryStorageAtAsync(keysPaged.Select(p =>Utils.HexToByteArray(p.ToString())).ToList(),string.Empty,token);storageChanges.AddRange(newList<string[]>(tt.ElementAt(0).Changes));// update the startKey to query next keysstartKey=Utils.HexToByteArray(tt.ElementAt(0).Changes.Last()[0]);}}// Result listvarvotings=newList<EnumVoting>();if(storageChanges!=null){foreach(varstorageChangeSetinstorageChanges){// Decoding Storage changes to the desired type.EnumVotingvoting=newEnumVoting();voting.Create(storageChangeSet[1]);votings.Add(voting);}}returnvotings;}

Get Chain Metadata

Console.WriteLine(client.MetaData.Serialize());