Skip to content

Instantly share code, notes, and snippets.

@developedby
Created November 12, 2022 11:02
Show Gist options
  • Save developedby/a6abd3b45dcaaa48d937366af93e8ff1 to your computer and use it in GitHub Desktop.
Save developedby/a6abd3b45dcaaa48d937366af93e8ff1 to your computer and use it in GitHub Desktop.

Writing and deploying programs for the Kindelia network

This guide will provide a short overview on how to write programs that can work on the kindelia network, how to deploy them and how to interact with them after they have been installed on the network.

It doesn't cover instructions on installing the required tools, which can be found on the respective repositories, nor does it provide and in-depth explanation of each of the components. It is recommended to first read the Kindelia network whitepaper to have a basic understanding of how it works.

After reading this, you'll have the required information to be able to write, deploy and use a simple kindelia contract. More complex examples may require some special considerations, but the fundamentals would be the same.

Table of Contents

Introduction

Programs in the Kindelia network are made of functions with an associated state for each of these functions. We can submit new programs to the network and interact with existing programs by running scripts that can access and modify their state.

Although the Kindelia HVM's native assembly-like language is considerably more high level than arm or x86 assembly, it is still too simple to write complex programs, does not provide many mechanisms to check that it's programs are correct. So, we recommend using a different higher-level language that provides more features and is able to compile programs to the Kindelia HVM language.

Here, we will be using Kind, a programming language designed from the ground up to be a good fit for the HVM. With its dependent types we are able to mathematically prove desirable properties about its programs. At the moment it is also the only language capable of compiling to the Kindelia network.

Writing a Kind program

In Kind, like on the Kindelia network, programs are also comprised of a set of functions that may call each other. Unlike the functions on the network, Kind functions don't have state and all expressions have types. The compiler will take care of making exerything compatible, with some extra information we need to provide it.

Defining functions

Functions in Kind are defined like in the following example:

U120.summation (nums: List U120) : U120
U120.summation (List.cons head tail) = U120.add head (U120.summation tail)
U120.summation  List.nil             = 0u120

This defines a function that takes a list of 120 bit unsigned integers and adds up all its elements recursively.

Each function has a name, a sequence of parameters, a return type and then a sequence of rules that define the result of the function call.

In this example, the name of the function is U120.summation, it must receive exactly one argument of type List U120 and return a value of type U120. It has two rewrite rules: the first is applied when the list given as argument has at least one element and the second is applied when the list is empty.

When running some code that calls this function, the runtime environment will compare the "shape" of the arguments to each of the provided rules, replacing the function call by the expression on the right of the = of the first rule that matches.

Following with this example function, when the expression (U120.summation (List.nil)) is executed, the runtime will check the rules of U120.summation, compare List.nil with the first pattern List.cons head tail and see that they don't match. It will then proceed to the next rule and match the argument List.nil and see that it does match. Then, the original expression U120.summation List.nil will be replaced in memory by the expression 0u120, the number zero stored in a 120 bit unsigned integer.

On the other hand, if the expression U120.summation (List.cons 4u120 List.nil) is executed, it will be compared with the first rule of the function which does match. The original expression will be substituted by the right side of the equation U120.add head (U120.summation tail), with variables head and tail being substituted by the two values inside the argument, 4u120 and List.nil respectively. So, the original expression is replaced in memory by the resulting expression U120.add 4u120 (U120.summation List.nil) and the runtime environment will continue it's execution, eventually applying the same process to this new expression.

For more information on what the Kind language provides, you can read this document that describes all of the language's syntax.

Type-checking

Let's say we wrote the following Kind file named MyFile.kind2:

Foo : Type
Foo.new (a: U60) : Foo

Bar : Type
Bar.new (b: Foo) : Bar

MyFun (a: Foo) : Bar
MyFun (Foo.new 0) = Bar.new (Foo.new 1)
MyFun (Foo.new x) = Bar.new (Bar.new (+ x x))

Main {
    MyFun (Foo.new 2)
}

This defines two types, an operation that uses them and a main function that tests one potential use of our new function. However, if you look closely at the second rule of MyFun, you may notice that it has an error, the second rule has a Bar.new where there should instead be a Foo.new.

We can run this code with kind2 run MyFile.kind2 and we'll get an incorrect result. This kind of error could be prevented by checking that all the used types match what was expected. We can do this with the command kind2 check MyFile.kind2 which will point out the error in this code:

Type mismatch
- Expected: Foo
- Detected: Bar
Kind.Context:
- x : U60
On 'test.kind2':
   9 | MyFun (Foo.new x) = Bar.new (Bar.new (+ x x))

Type mismatch
- Expected: Foo
- Detected: U60
Kind.Context:
- x : U60
On 'test.kind2':
   9 | MyFun (Foo.new x) = Bar.new (Bar.new (+ x x))

By taking advantage of the dependent type system we can even prove many things about the code we write. For example, look at this proof that addition is commutative:

Nat.add.comm (a: Nat) (b: Nat) : Equal Nat (Nat.add a b) (Nat.add b a)
Nat.add.comm Nat.zero     b = Nat.add.comm.zero b
Nat.add.comm (Nat.succ a) b =
  // Goal: (b + a) + 1 == (a + (b + 1))
  // We know that a + b == b + a because of induction
  let induction = (Nat.add.comm a b)
  // (a + b) + 1 = (b + a) + 1
  let induction = Equal.apply (x => Nat.succ x) induction
  // (a + b) + 1 = (a + (b + 1))
  let induction_tool = Equal.mirror (Nat.add.comm.succ b a)
  // We can get our goal with this
  Equal.chain induction induction_tool

Just like in this example, we can use the type system to prove many useful things, like that the sum of all tokens stay the same after a transfer or that no one can obtain an item in our game unless certain conditions are met.

Writing Kind programs for the Kindelia network

With this short introduction to the Kind language, let's tryu to use it to make a smart contract for Kindelia. We'll be making a very simple contract that simply counts up when it is executed.

MyCounter (arg: ?) : ?
MyCounter ? = ?

With this function as the base of our contract, we can fill the ? one by one to make something usable.

Reading and writing contract state

First off, we need to be able store a number on the state and to increment it when called. A contract's state can be read using the function Kindelia.IO.take and stored back with a new value with function Kindelia.IO.save new_val. If we check the definition of Kindelia.IO.take, we can see that it returns the state encapsulated in a value of type Kindelia.IO. To access the inside of this value we can use Kind's monadic do syntax, like so:

MyCounter (arg: ?) : Kindelia.IO ?
MyCounter ? =
  do Kindelia.IO {
    ask old_val = Kindelia.IO.take
    let new_val = U120.inc old_val
    ask Kindelia.IO.save new_val
    return ?
  }

One of the thing that differentiates contracts from normal pure functions in Kindelia is that they have state. All contracts must return a Kindelia.IO.done value, and so is the case of MyCounter, that is one of the restrictions on how we must interface with contracts. Here this is done by the return statement, and the return type of the function Kindelia.IO matches it.

This is the bulk of our contract, but we haven't given it a way of reading the state of the contract, we can only interact with it by incrementing the counter. Maybe this is what you want, in this case our finished contract could be:

MyCounter : Kindelia.IO Unit
MyCounter =
  do Kindelia.IO {
    ask old_val = Kindelia.IO.take
    let new_val = U120.inc old_val
    ask Kindelia.IO.save new_val
    return Unit.new
  }

Defining multiple actions for a single contract

Most times we want a way of doing many types of operations in the same contract. We can do this by taking different paths by using pattern-matching on the argument. We could add a second action for reading the counter without changing it like so:

CounterAct : Type
CounterAct.read : CounterAct
CounterAct.inc : CounterAct

MyCounter (action: CounterAct): Kindelia.IO U120
MyCounter CounterAct.inc =
  do Kindelia.IO {
    ask old_val = Kindelia.IO.take
    let new_val = U120.inc old_val
    ask Kindelia.IO.save new_val
    return 0u120
  }
MyCounter CounterAct.read =
  do Kindelia.IO {
    ask counter = Kindelia.IO.load
    return counter
  }

Now we have a more complete example of a smart contract. By using an enumeration type we can define different actions for a contract, as if it were made up of different functions. Another restriction of the contract interface is that it must have at most one argument, and this argument must be a constructor that only encapsulates other numbers. In our case, we have two constructors encapsulating zero numbers, so it meets this criteria.

Some other things of note here:

  • The Kindelia.IO.load function is similar to Kindelia.IO.take, except it takes the state, gives you one copy and stores back another copy.
  • All rules must return the same type of value. In this example, we return a 0 in the incrementing branch that can be safely discarded by whoever called the contract. In more complicated cases we can devise types that handle more complex returns.
  • We are using U120 for our numbers. This is the native type of numbers in kindelia and is usually the type of value that can use most efficiently time (mana) and space (bits) on the Kindelia network. We can use it to make serializations, buffers, etc.

Function attributes for Kindelia programs

Our example is almost done, but we never defined the initial value of our counter. By default, all functions are created with state 0u120, but we usually want to set it to some more useful initial value. In this example we actually want it to be zero, but we should go ahead and make it explicit for better maintainability. We can do this with a function attribute, a special annotation that tells something important to the compiler. In our case, we want to tell it that one Kind function is actually the initial value for the state of our Kindelia contract. This attribute is called kdl_state and we use it like this:

#kdl_state = MyCounter.state
MyCounter (action: CounterAct): Kindelia.IO U120
  ... // Omitted for ease of reading, but the inside is the same as above

MyCounter.state : U120 {
  0u120
}

Instead of compiling MyCounter.state to a Kindelia function, it instead becoes the initial state of MyCounter, setting it to 0u120.

There are some other attributes that you might want to use for your programs, the most important ones are:

  • kdl_state: Defines that another function is the initial state of the annotated one. Used like #kdl_state = <Function name>.
  • kdl_name: Defines the name that the function will be compiled with on the Kindelia network. This is very useful to meet the name length restriction of Kindelia names, they must have at most 12 characters. If we don't give a function a kdl_name and it exceeds 12 characters, it's name will be swapped by the first 72bits of the hash of the original name. This is pretty unreadable, so if we want to be able to understand the generated kindelia code, we probably want to give it a custom name. Kindelia names also don't need to start with a capital letter like Kind functions, so we can have more name options. Used like #kdl_name = <Name it'll be compiled with>.
  • kdl_erase: Usually, the compiler is smart enough to understand what it should compile, and what should be left out. Sometimes, it unfortunately fail. In these cases, we can mark a function as kindelia erased, meaning it won't ever be compiled. Be careful, the use case for this is very rare and you should only have to resort to it in case you're having actual problems. In the future, as the compiler gets more intelligent this attribute will be needed in less cases. Used like #kdl_erase.
  • inline: By marking a function as inlined, any time it is used, the function application will be directly substituted by the body on compile time. This can be a nice optimization in some cases or can also make a function less efficient in others. Used like #inline.

Attributes are used by putting them just before the function that they're applied to, like in the example above.

Putting everything together, our whole correct and compilable example is:

CounterAct : Type

#kdl_name = cntActionRd
CounterAct.read : CounterAct

#kdl_name = cntActionInc
CounterAct.inc : CounterAct

#kdl_state = MyCounter.state
#kdl_name = cntExample
MyCounter (action: CounterAct): Kindelia.IO U120
MyCounter CounterAct.inc =
  do Kindelia.IO {
    ask old_val = Kindelia.IO.take
    let new_val = U120.inc old_val
    ask Kindelia.IO.save new_val
    return 0u120
  }
MyCounter CounterAct.read =
  do Kindelia.IO {
    ask counter = Kindelia.IO.load
    return counter
  }

MyCounter.state : U120 {
  0u120
}

Compiling Kind programs to kdl

Now that we have written a simple program (and type-checked it, hopefully also proved its most important properties), we want to put it on the blockchain. The first step is to compile our Kind code to Kindelia code. This is done with the kind2 to-kdl command.

If our program was saved in the file MyCounter.kind2 we compile it like kind2 to-kdl MyCounter.kind2 > mycounter.kdl. This compiles the code and saves it to mycounter.kdl, which is a file that can be sent to a Kindelia node to be included in the network.

Testing your Kindelia program

Although we checked our Kind program before, it could still have some problems, especially in details that are specific to the Kindelia compilation of our program. For example, there could already be a different function with the same name on the blockchain, or maybe we didn't fulfill all the requirements of the smart contract interface. We can run some tests to try and catch some of the problems before doing an actual deploy, which could be expensive and cannot be undone.

The kindelia application provides some tooling that allows us to test our newly created functions.

Offline tests

TODO: run statements.

First off, we can make a local and offline (without taking the blockchain state into consideration) test with the kindelia test command. This executes your code, giving some important stats like how much size and mana it costs to run your file. It can also detect some simple errors like invalid syntax (if you for example made an incorrect manual edit to the compiled file). We can use it like kindelia test mycounter.kdl.

We can check that our program actually fits in a block, which has a size limit of about 1300 bytes, by using one of the kindelia check commands like so: kindelia check mycounter.kdl publish. This will tell us how many bytes each of our functions takes and we can calculate how many blocks are needed to include all of them in the blockchain.

With just these two, it would be hard to catch any actual runtime errors, like bugs in the logic of our code or wrong assumptions we made about the problem we're trying to solve. But it is possible to add an extra element to run test code for the Kindelia network just like we would for normal computer programs we can write run blocks that work as a test suite for the contract we're planning on deploying. With this, it is possible to simulate interactions with contracts and check out what happens after some specific series of contract invocations.

For example, look at this example that tests the cntExample contract we developed before:

run {
  ask (Call 'cntExample' {cntActionInc});
  ask (Call 'cntExample' {cntActionInc});
  ask (Call 'cntExample' {cntActionInc});
  ask res = (Call 'cntExample' {cntActionRd});
  let pass = (== res #3);
  (Done pass)
}

What it does is increment the counter 3 times and then check if we actually get a 3 back. We just need to put this run block at the end of mycounter.kdl and then test it with kindelia test.

These tests can be as long and complicated as we need them to be. However, if a run block gets too big, it is no longer deployable and will fail the size check. During tests only we can ignore this restriction by calling kindelia test mycounter.kdl --sudo.

Online tests

Online tests, meaning it'll run with the current network state of a node and not with only the genesis block like was the case for kindelia test, are also possible. To make a dry-run of a file, executing on top of the current network state, we can use the kindelia run-remote command.

Testing on a local node

Since kindelia test doesn't do the runtime checks and doesn't apply all restrictions, running kindelia run-remote on a local node disconnected from the network, running at the genesis state, may actually give us more errors.

To start a node disconnected from the network:

  • From a clean install of kindelia, run kindelia node start. Notice that it will display a message about creating a configuration file;
  • Stop the node and open this configuration file;
  • Erase all values inside initial_peers. Keep a copy if you want to connect to network in the future.
  • Restart the node with kindelia node start. Notice that in the heartbeat messages, the peers field will have 0 other nodes.
  • Then, we can send our local node our file to dry-run with kindelia run-remote mycounter.kdl.

But probably it is more useful to run our test on the current state of the network. To connect a local node to the testnet, the original default values of the config files should work. Just leave the local node running until the number of pending blocks go to zero. If we run the run-remote command now, it'll be executed on the latest state of the network.

Just like with kindelia test, we can use run-remote to run some test scripts. But now we can test already running programs, interacting with their state in real time.

Testing on a remote node

Most users don't want to run a node in their own machine, since it requires a lot of RAM and storage space. For most people, what they'll actually want to do is send an external node a file to be checked. As the name run-remote indicates, this is exactly what this command was made to do. To point at a non-local node, we can either set the environment variable KINDELIA_API_URL like so:

KINDELIA_API_URL=https://node-ams3.testnet.kindelia.org kindelia run-remote mycounter.kdl

Or we can pass the api url as an option to the kindelia command, like this:

kindelia --api "https://node-ams3.testnet.kindelia.org" run-remote mycounter.kdl

The env var / api option applies to any command that communicates with a kindelia node.

Deploying to the kindelia network

Once you have you .kdl file well defined, tested and checked, sending it to a node to be published is very simple. We simply call kindelia publish mycounter.kdl and check that all transactions are published. If our file ran correctly using run-remote, there should be no problems when publishing it.

The same considerations about publishing to local or remote node apply here as well.

Right now, the node doesn't notify us at the moment our transactions are included in the blockchain, so we need to keep checking until everything is in. As the web api for the node gets refined, these things will be made simpler.

Interacting with programs on the network

So far, we successfully published a smart contract on the Kindelia chain. Now, we or our users will probably want to interact with our program on the network. Just like we did for our test, the way to interact with the network state is to write run statements that Call the desired contracts. We could, for example use the cntExample contract to act as a visitor count for our website, publishing a transaction with a single !Call 'cntExample' {cntActionInc} inside of a run every time a new IP connects to a web server. We can be sure that this information will never be lost because it written immutably on the Kindelia blockchain.

The code for this transaction could be:

run {
  ask (Call 'cntExample' {cntActionInc});
  (Done #0)
}

And we could run kindelia --api "https://node-sfo3.testnet.kindelia.org" run-remote inc_counter.kdl in the server to execute this action. Unlike functions that can only be added once, we can run the same run statement as many times as we want, we just have to keep publishing it many times.

Signing transactions

Sometimes, we may need to sign some of the transactions we want to publish. Signing a transaction puts the signer as the subject, and this can be required to execute certain programs with your account or to deploy functions inside of a namespace. This guide won't be covering how to do either of these things as it would be too long, but it is sufficient to say that is important to know how to sign transactions.

We could for example only allow certain whitelisted subjects to be able to increase our counter. For them to be able to correctly interact with the contract, they would need to sign the run statement that does the incrementing.

This is done with the kindelia sign command. To use it, you first need a private key with which you'll be doing the signing. This private key acts as your account number, and any transactions signed with this key "belong" to you.

As the name private implies, you should make it a secret from anyone else and store it securely. If anyone were to have access to your key they could steal your identity on the Kindelia network and cause all sorts of havoc, like stealing all of your tokens.

The first step then is to create this private key. Keys are 256bit long and must be stored as a hexadecimal string of 64 ascii characters in a text file. Let's say for example that you generated a secure random number 0xD94245287EDE9302C884C8B256A5C87507D7E442FF300F975947669549B255AD. You need to store this number in a secret file, say my_secret_key.txt with content D94245287EDE9302C884C8B256A5C87507D7E442FF300F975947669549B255AD.

Then you can use this file to sign your kindelia code using the command kindelia sign inc_counter.kdl --secret_file "my_secret_key.txt" > inc_counter_signed.kdl, which will output the signed statement on file inc_counter_signed.kdl.

Now you just need to publish this new signed file like we did for the previous ones.

TL;DR

To sum up everything that was said here, to make contracts for the Kindelia network you should:

  1. Make a program in Kind, using the Kindelia.IO functions to access kindelia specific functionalities like accessing the state, calling other contracts and getting block metadata.

  2. Annotate your functions with compiler directives kdl_name, kdl_state, kdl_run and kdl_erase to indicate kindelia-only aspects of these functions.

  3. Typecheck your program with kind2 check, write proofs about it and, if everything is correct, compile it with kind2 to-kdl.

  4. Optionally, sign your statements with the kindelia sign command. This may be required or not depending on what you're doing.

  5. Do an offline test of your contract using the kindelia test command. You may want to include a testing script in the kdl file that can be compiled by writing a function annotated with kdl_run.

  6. Do an online test of your contract by using the kindelia run-remote command.

  7. Send your statements to a node connected to the network using the kindelia publish command and wait for them to be included in the blockchain.

  8. Write a script for interacting with your new contract. Repeat the instructions above for compiling, testing and deploying it.

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