Skip to content

Instantly share code, notes, and snippets.

@josepot
Last active December 2, 2023 21:46
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save josepot/ee14a037636c569fdb0251d8f4f9e825 to your computer and use it in GitHub Desktop.
Save josepot/ee14a037636c569fdb0251d8f4f9e825 to your computer and use it in GitHub Desktop.

Extrinsics

The last few weeks I've been prototyping an alternative to polkadot-js/api. My goal is to create a more modular, user-friendly, strongly-typed and light-weight version of polkadot-js/api.

I'm still not sure whether I will be able to accomplish those goals. However, I certanly hope that I will be able to share the knowledge and ideas that I gather during this attempt. That's why I'm creating this gist.

I'm not a technical writer, and I don't have a lot of time to spend on writing this. So, this is meant to be a "brain dump". although, hopefully someone who is good at writting documentation can use this for improving the substrate docs site.

When I started prototyping, I was able to make a lot of progress just by digging into the substrate docs: I was able to created a SCALE codec library, a client that's able to read storage values and make basic RPC calls, etc.

Eventually, I reached a point where I wanted to be able to execute transactions and then I realized that the substrate docs don't have any "advanced" material for creating Exstrinsics. Therefore, I started to dig into the code-base of polkadot-js and to debug an application to figure out how to create a transaction... And let me warn you that it is a lot more complicated than I initially anticipated. That's why I've decided to document my findings, in case tha these can be useful to others.

Warning: This is not intended to be a "canonical" guide on how to create Extrinsics, this is just sharing my chaotic thoughts, and findings, which are centanly incomplete.

Analyzing the messages

One of the first things that I did was to create a client with the WsProvider, so that I was able to easily analyze what's happening over the wire when a transaction get's created. I then realized that there are many messages going back and forth before the client finally sends the message with the submission of the transaction.

Having a quick look at calls that preced the submission message, some of them really seem redundant, and to this day I still think that there must be some redundant calls... And also other calls that I would expect to happen earlier or in parallel, but we will dig into that later.

Let's discuss what happens when we use polkadot-js for using Alice's test account to submit a create_comment call to Adz, while being connected via wss://adz-rpc.parity.io.

These are all the messages that went back and forth (in chronological order) from the moment I submitted the transaction, until the submission was sent:

  • Sent:
{"id":147,"jsonrpc":"2.0","method":"chain_getHeader","params":[]}
{"id":148,"jsonrpc":"2.0","method":"chain_getFinalizedHead","params":[]}
  • Received:
{"jsonrpc":"2.0","result":{"digest":{"logs":["0x06617572612067dc200800000000","0x0561757261010160931b90a965057320c19792c82e86c4a92dee1fa82241cea29f04ce40fb8f24c5cb6bf1d348ee8740a58bedc98992e2f3d9537dd429b77cb558c9ee583b6986"]},"extrinsicsRoot":"0xe6a13a7b27868f47d6f00699fba30f963617f63ddba49ae027c56f7be8641cca","number":"0x33ed7","parentHash":"0x0600f4884346a3d895e4456c266d759b75a43722f391a11d53b055ec50f02191","stateRoot":"0x1774fb8227a2867561791c26b4d4227db9e39e80f7ac9adadb9726a1c6e6036c"},"id":147}
{"jsonrpc":"2.0","result":"0x0600f4884346a3d895e4456c266d759b75a43722f391a11d53b055ec50f02191","id":148}
  • Sent
{"id":149,"jsonrpc":"2.0","method":"chain_getHeader","params":["0x0600f4884346a3d895e4456c266d759b75a43722f391a11d53b055ec50f02191"]}
  • Received:
{"jsonrpc":"2.0","result":{"digest":{"logs":["0x06617572612065dc200800000000","0x05617572610101603622bb52fce8a48651d7411984bedc9d96af40228588d5ecc851a4265c824796e7b499623aa26cf62f32f960ec9a02d6675e27e1aa4969571e8da4afa67485"]},"extrinsicsRoot":"0xe6ffb6c8f318d09b41ccece5f676165c2cc8cd327669d37814744c2558ded379","number":"0x33ed6","parentHash":"0x9d325df53f5a4e3ec241a7897c8cadda48e86e50d1f4d9bb7b50ea9e32c4fead","stateRoot":"0xa76d5eb142cda175292d7440b45d96df83584f2c8f305b2bbbfe13590ae7aa47"},"id":149}
  • Sent
{"id":150,"jsonrpc":"2.0","method":"state_getRuntimeVersion","params":["0x9d325df53f5a4e3ec241a7897c8cadda48e86e50d1f4d9bb7b50ea9e32c4fead"]}
  • Received:
{"jsonrpc":"2.0","result":{"apis":[["0xdf6acb689907609b",3],["0x37e397fc7c91f5e4",1],["0x40fe3ad401f8959a",5],["0xd2bc9897eed08f15",3],["0xf78b278be53f454c",2],["0xab3c0572291feb8b",1],["0xdd718d5cc53262d4",1],["0xea93e3f16f3d6962",1],["0xbc9d89904f5b923f",1],["0x37c8bb1350a9a2a8",1]],"authoringVersion":1,"implName":"template-parachain","implVersion":0,"specName":"template-parachain","specVersion":1,"transactionVersion":1},"id":150}
  • Sent
{"id":151,"jsonrpc":"2.0","method":"chain_getHeader","params":["0x0600f4884346a3d895e4456c266d759b75a43722f391a11d53b055ec50f02191"]}
  • Received
{"jsonrpc":"2.0","result":{"digest":{"logs":["0x06617572612065dc200800000000","0x05617572610101603622bb52fce8a48651d7411984bedc9d96af40228588d5ecc851a4265c824796e7b499623aa26cf62f32f960ec9a02d6675e27e1aa4969571e8da4afa67485"]},"extrinsicsRoot":"0xe6ffb6c8f318d09b41ccece5f676165c2cc8cd327669d37814744c2558ded379","number":"0x33ed6","parentHash":"0x9d325df53f5a4e3ec241a7897c8cadda48e86e50d1f4d9bb7b50ea9e32c4fead","stateRoot":"0xa76d5eb142cda175292d7440b45d96df83584f2c8f305b2bbbfe13590ae7aa47"},"id":151}
  • Sent (SUBMISSION MESSAGE)
{"id":152,"jsonrpc":"2.0","method":"author_submitExtrinsic","params":["0x11028400d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d01aea7dbdc0e90544b84b2c5ea08d914db239bb3d2a87ad5d6eab4a732293f1676d3ae4879785486947bf4b79c76e982e97d0ec4515c33aa19195cdae8e3b44d826401b9010036045454657374696e672045787472696e736963732e2e2e22000000"]}
  • Received
{"jsonrpc":"2.0","result":"0x3ab881762c5149726e8ca8e24a6c34b9556a429e0ab315e821e2d6299cf80cb8","id":152}

We will talk more the messages that prelude the submission later. For now, though, let's focus on the "submission" message.

Submission Message

As we can see, the submission message is encoded, and I would expect it to contain some signed fields and some unsigned fields... It would be very useful to be able to decode that message to see what's in it. So, after a lot of debugging, and some trial and error, I was able to decode the message using @unstoppablejs/scale-codec, like this:

import {                                                  
  Struct,                                                 
  U8,                                                     
  U32,                                                    
  Tuple,                                                  
  Compat,                                                 
  Enum,                                                   
  Str,                                                    
  Bytes,                                                  
  U16,                                                    
} from "@unstoppablejs/scale-codec";                      
                                                          
import { AccountId } from "./AccountId";                  
                                                          
const NoIdea = Bytes(32);                                 
                                                          
const Signature = Struct({                                
  signer: Enum({ sr25519: AccountId, foo: NoIdea, bar: NoIdea }),   
  signed: Enum({
    ed25519: Bytes(64),
    sr25519: Bytes(64),
    secp256k1: Bytes(64)
  }),
  era: U16,                                               
  nonce: Compat,                                          
  tip: Compat,                                            
});                                                       
                                                          
const Method = Struct({                                   
  callIndex: Tuple(U8, U8),                               
  args: Tuple(Str, U32),                                  
});                                                       
                                                          
const Extrinsic = Struct({                                
  len: Compat,                                            
  version: U8,                                            
  signature: Signature,                                   
  method: Method,                                         
});

const [, decodeExtrinsic] = Extrinsic

console.log(
  decodeExtrinsic(
    "0x11028400d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d01aea7dbdc0e90544b84b2c5ea08d914db239bb3d2a87ad5d6eab4a732293f1676d3ae4879785486947bf4b79c76e982e97d0ec4515c33aa19195cdae8e3b44d826401b9010036045454657374696e672045787472696e736963732e2e2e22000000"
  ).value
)

Which displays this in the console:

{
  len: 132,
  version: 132,
  signature: {
    signer: {
      tag: 'sr25519',
      value: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'
    },
    signed: { tag: 'sr25519', value: [ArrayBuffer] },
    era: 356,
    nonce: 110,
    tip: 0
  },
  method: { callIndex: [ 54, 4 ], args: [ 'Testing Extrinsics...', 34 ] }
}

The first thing that you may have noticed is that the Method codec looks like this:

const Method = Struct({                                   
  callIndex: Tuple(U8, U8),                               
  args: Tuple(Str, U32),                                  
});  

The callIndex is always a Tuple(U8, U8) (we will later explain where to get those numbers from). However, the args tuple will be different depending on the transaction. In our case, we need a string (the "comment") and a number (the id of the Ad) to call the create_comment call of Adz.

Ok, now that we know what's inside the message, let's discuss how we can generate a message like that.

Unsigned Fields

Len:

This field is very straight forward: is a Compat encoded number that represents the length of the remaining bytes of the message (after reading it).

Version:

Which in this example its value is also 132. Once you know how to get it, this field is super straight forward... figuring out how the value gets there wasn't fun, though. Anyways, it turns out that's pretty much a hardcoded value. Basically the way it got there is as follows:

  • The latest extrinsic version is hardcoded in this line of code.
  • Then the version getter of the Extrinsic class does this.
  • Which in combination with these constants. Produces 132 for signed extrinsics and 4 for unsigned extrinsics (which I won't cover in this gist).

Signer

This field is an Enum and I still don't know what all its options are. The only thing that I know for sure is that when the public key (Address) is a sr25519 key (like Alice's test account is), then it goes in the first position of the Enum. I suspect that the other Enum options must be reserved for other kinds of public keys, but I still haven't figured that out, yet.

Signed

This is also an Enum which contains the signed payload (that we will cover later). I know a little bit more about this one, this is what I've found: It seems that the first position of the Enum indicates that the payload is signed using ed25519 (aka naclSign), the second position is for when the payload is signed using sr25519 (aka schnorrkelSign) and the 3rd position is for both ecdsa and ethereum signatures, which are both based on secp256k1Sign (while the former uses blake2 and the latter uses keccak).

I find a bit confusing the fact that we have different 2 enums: one for the public key and the other one for the signed payload... That's proabably because I must be missing something about the Signer Enum... However, if the Signer Enum is what I think that it is, then IMO it would make more sense to have one enum that indicates the signing method and then inside that Enum we could have both the public key and the signed payload... But again, that's probably because I must be missing some context on what the Signer Enum is about.

Later we will see what's inside the signed payload and we will discuss how we can get that information and how to sign it.

Era

I had a very hard time getting to understand what this field was about. As it turns out this is a U16 field that's computed from 2 different values:

  • Another number that we will refer to as mortalLen
  • A number that we will refer to as signingHeaderNumber

Once we have those 2 values we can calcualte the Era with the following function:

const calcEra = (                    
  signingHeaderNumber: number,                                      
  mortalLen: number                                                
) => {                                                             
  let calPeriod = Math.pow(2, Math.ceil(Math.log2(mortalLen)));    
  calPeriod = Math.min(Math.max(calPeriod, 4), 1 << 16);           
                                                                   
  const phase = signingHeaderNumber % calPeriod;                    
  const quantizeFactor = Math.max(calPeriod >> 12, 1);             
  const quantizedPhase = (phase / quantizeFactor) * quantizeFactor;
                                                                   
  const trailingZeros = calPeriod.toString(2).split("").reverse().indexOf("1");               
  return (                                                         
    Math.min(15, Math.max(1, trailingZeros - 1)) +                 
    ((quantizedPhase / quantizeFactor) << 4)                       
  );                                                               
};                                                                 

mortalLen

In order to calculate the mortalLen we will first have to retrieve the metadata by making an RPC call to state_getMetadata. Later we will explain how to decode its payload, for now what's important is that we will need to retrieve the following constants from the metadata payload: System.BlockHashCount, Babe.ExpectedBlockTime and Timestamp.MinimumPeriod, which may or may not be present. Once we have those constants we are ready to calculate the mortalLen:

const FALLBACK_MAX_HASH_COUNT = 250;
const FALLBACK_PERIOD = 6_000;      
const MAX_FINALITY_LAG = 5;         
const MORTAL_PERIOD = 5 * 60 * 1000;

function getMortalLen(blockHashCount, expectedBlockTime, minimumPeriod) {
  return Math.min(                                                            
    blockHashCount ?? FALLBACK_MAX_HASH_COUNT,                                
    MORTAL_PERIOD /                                                           
      (expectedBlockTime ?? minimumPeriod === undefined                       
        ? FALLBACK_PERIOD                                                     
        : minimumPeriod * 2) +                                                
      MAX_FINALITY_LAG                                                        
  );                                                                          
});                                                                    

signingHeaderNumber

This is the number property of the signingHeader (which will be used for another field that's in the signed payload). Bellow you can find a way to obtain the signingHeader using the current (and precarious) version of @unstoppablejs/client:

interface HeaderResponse {                                         
  digest: {                                                        
    logs: Array<string>;                                           
  };                                                               
  extrinsicsRoot: string;                                          
  number: string;                                                  
  parentHash: string;                                              
  stateRoot: string;                                               
}                                                                  
                                                                   
type ParsedHeaderResponse = Omit<HeaderResponse, "number"> & {     
  number: bigint;                                                  
};                                                                 
                                                                   
const MAX_FINALITY_LAG = 5;                                        
const StateRpc = Rpc("state", client);                             
const ChainRpc = Rpc("chain", client);                             
                                                                   
const getHeader = ChainRpc<                                        
  [hash?: string],                                                 
  HeaderResponse,                                                  
  ParsedHeaderResponse                                             
>("getHeader", (input: HeaderResponse) => ({                       
  ...input,                                                        
  number: BigInt(input.number),                                    
}));                                                               
const getFinalized = ChainRpc<string>("getFinalizedHead");         
                                                                   
export const getSigningHeader = async () => {                            
  const aborter = new AbortController();                           
                                                                   
  const finalizedPromise = getFinalized(aborter.signal).then((h) =>
    getHeader(h, aborter.signal)                                   
  );                                                               
                                                                   
  const current = await getHeader().then((currentHeader) => {      
    if (!currentHeader.parentHash) {                               
      aborter.abort();                                             
      return currentHeader;                                        
    }                                                              
    return getHeader(currentHeader.parentHash);                    
  });                                                              
                                                                   
  if (aborter.signal.aborted) return current                       
                                                                   
  const finalized = await finalizedPromise;                        
                                                                   
  return current.number - finalized.number > MAX_FINALITY_LAG      
    ? current                                                      
    : finalized                                                    
}

nonce

The nonce of the account sending the transaction... basically, this:

import { BlakeTwo128Concat, EncodedArgs, Storage } from "@unstoppablejs/client";
import { Compat, Struct, U32 } from "@unstoppablejs/scale-codec";               
import { AccountId } from "./AccountId";                                        
import { client } from "./client";                                              
                                                                                
const SystemStorage = Storage("System", client);                                
                                                                                
const AccountInfo = Struct({                                                    
  nonce: U32,                                                                   
  consumers: U32,                                                               
  providers: U32,                                                               
  data: Struct({                                                                
    free: Compat,                                                               
    reserved: Compat,                                                           
    miscFrozen: Compat,                                                         
    feeFrozen: Compat,                                                          
  }),                                                                           
});                                                                             
                                                                                
const args: EncodedArgs<[accountId: string]> = [BlakeTwo128Concat(AccountId)];  
const Account = SystemStorage("Account", AccountInfo, ...args);                 
                                                                                
export const getNonce = (address: string) =>                                                   
  Account.get(address).then(         
    (x) => x?.nonce ?? 0                                                             
  );                                                                            

tip

Depends on how generous you feel that day :-), usually 0.

method.callIndex

The corresponding index that's found on the payload of the metadata call. I will expand on this later when I discuss the metadata.

method.args

A tuple with the expected arguments, encoded according to what's described on the metadata.

Signed Fields

Now lets discuss what's inside that signed payload. After some live debugging, I was able to console.log the payload before it gets signed, and again after some digging around into the code and debugging I realized that the taxonomy of the payload is encoded like this:

const Signature = Struct({                                       
  method: Method,                                                
  era: U16,                                                      
  nonce: Compat,                                                 
  tip: Compat,                                                   
  specVersion: U32,
  genesisHash: Bytes(32),               
  blockHash: Bytes(32),                         
});                                                              

We've already explained the first 4 fields, so let's focus on the new ones:

specVersion

It comes from an RPC call to state_getRunTimeVersion, in the response to that call there is a field named specVersion, so that's it.

genesisHash

It also comes from another RPC call, this time to: chain_getBlockHash while passing 0 as the parameter.

blockHash:

Remember that before when we were discussing how to calculate the Era we talked about the signingHeader? Well, this is the hash of that header... Which we will have to compute ourselves. TODO: explain how to has the header.

So, once we have all those fields we should SCALE encode them, and then we will have to sign those bytes using the corresponding signing method (usually sr25519Sign), but ofc we should allow the holder of the key to pick the correct signing method. Once signed, we then check whether the byte length is greater than 256, if that's the case we will hash those bytes using blake2b with a dkLen of 32, otherwise we won't do anything else to them.

Final step

Now that we have all the necessary fields we are ready to encode the whole Extrinsic, except for the len, ofc. So, after we have encoded the Extrinsic, then we check its byte length and then we Compat encode that value and we prepend those bytes to the previous ones.

Metadata

TODO: explain it

Misc

Explain why I think that there are bogus calls before submitting the Extrinsic, and why some calls that look sequential should probably run in parallel instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment