Kipt: easy contract management on Starknet

Kipt was develop to considerably ease the smart contracts management on Starknet.

"Ki" is from the Ki energy from a famous manga combined with the work "script", to illustrate the fact that Kipt's simplicity empowers the user to easily script his contracts declare, deploy and transact operations.

Leveraging the simplicity of Lua and the performance of Rust, Kipt abstracts the whole complexity of interacting with the blockchain (with starknet-rs).

Basically, Kipt is capable of executing Lua code, where special functions are made available out of the box for the user like declare, deploy, invoke and call functions related to Starknet.

Installation and run

The easiest way to install Kipt is to use kiptup, a portable script that downloads prebuilt binaries and manages shell configuration for you. However, it might not be available depending on your device's platform and/or architecture.

Using kiptup

If you're on Linux/macOS/WSL, you can install kiptup by running the following command:

curl https://raw.githubusercontent.com/glihm/kipt/main/kiptup/install | sh

You might need to restart your shell session for the kiptup command to become available. Once it's available, run the kiptup command:

kiptup

Running the commands installs kipt for you, and upgrades it to the latest release if it's already installed.

ℹ️ Note

Over time, kiptup itself may change and require upgrading. To upgrade kiptup itself, run the curl command above again.

Prebuilt binaries

Prebuilt binaries are available with GitHub releases for certain platforms.

Prebuilt binaries are best managed with kiptup. However, if you're on a platform where kiptup isn't available (e.g. using kiptup on Windows natively), you can manually download the prebuilt binaries and make them available from PATH.

Run Kipt

For now Kipt as very few options, and you only have to provide the Lua script to execute:

kipt ./scripts/demo.lua

Basics

Some Lua and Kipt basics to get started with the scripting:

Lua basics

In Lua, only 8 types are used. In the context of Kipt, you only need 5 of them if you want to keep it simple:

  • nil: nothing / no value.
  • number
  • string
  • table
  • boolean

Except for table, the other types are very common.

You can notice that there are no array, but table is flexible enough to be used as hashmap and array.

-- nil
local a = nil

-- Number
local b = 1234

-- String
local c = "kipt"

-- Boolean
local d = false

-- Table
local e = { a = 2, b = "abc" }

-- Table (array-like)
local f = { 1, 2, 3, 4 }

The use of local is optional, but it's a good practice as any variable without local is considered as global. So if you use some functions for more advanced Lua programming, keep the use of local.

In case of array, under the hood it's a table, but index starting to 1 (nobody is perfect...).

To access member of an table, you have two notations as showed below. If the key does not exist, it returns nil.

local t = { a = 2, b = "abc" }
print(t.a)
print(t["b"])

if t.h then
    -- h is not nil and has a value, do stuff with it.
end

To test nil, the best practice is to compare with nil, this is because nil and false are evaluated the same way:

local a = false
local b = nil

if not a and not b then
    print("will be printed")
end

if a == nil and b == nil then
    print("will not be printed")
end

This are all the basics you need to get started.

Global variables and setup

To interact with Rust, Kipt must retrieve some variables from Lua. Any variable without the local keyword are accessible from Rust.

For this purposes, some variables are automatically checked by Kipt to add some configuration:

-- No local keyword, they are global variables.
RPC = "GOERLI-1"

ACCOUNT_ADDRESS = "0x1234...."
ACCOUNT_PRIVKEY = "0x8888...."

-- If you're using a cairo 0 account, you will need legacy encoding.
-- ACCOUNT_IS_LEGACY = true

-- You can re-defined anywhere in the script a new value
RPC = "http://0.0.0.0:5050"

As shown, some global variables are expected by Kipt:

  • RPC: can be any valid URL that starts with http or some pre-defined networks. The supported networks are:

    • MAINNET: to use the gateway for the mainnet.
    • GOERLI-1: to use the gateway for goerli-1.
    • GOERLI-2: to use the gateway for goerli-2.
    • KATANA: to use the Katana in local on the default port http://0.0.0.0:5050.
  • ACCOUNT_ADDRESS: The address of the account to use to send transactions.

  • ACCOUNT_PRIVKEY: The private key of the account to use to send transactions.

  • ACCOUNT_IS_LEGACY: Specifies if the account is a cairo 0 account.

ℹ️ Note

Those variables are re-evaluated before each transaction. This means that you can change their value during the script without any problem to send some transactions with different accounts, to different URLs.

As Lua is a scripting language, it also has some capabilities. To not write your private key in plain text or pre-configured (CI for instance) the account and network with environment variables, you can do the following:

RPC = os.getenv("STARKNET_RPC")
ACCOUNT_PRIVKEY = os.getenv("STARKNET_KEY")

Logger

To ensure that all the transactions are recorded automatically, you have to initialize the logger and Kipt will generated a report for you.

-- Without arguments, this will output everything in the file `kipt.out`.
local logger = logger_init()

-- With the name you may prefer:
local logger = logger_init("my_output.txt")

⚠️ Warning

Kipt will overwrite the file if it already exist. Ensure to provide a different name to avoid data loss.

ℹ️ Note

Calling logger_init() multiple times with or without an argument will take effect only at the first call. All other calls are ignored.

By default, Kipt will write in the output file any transaction hash and output of the declare, deploy and invoke functions. If you want to output additional information, you can do the following:

logger:write("your content here")

Declare

Declare a class-hash on-chain.

declare("contract_name", opts)

-- @param contract_name - The contract name (string).
string

-- @param opts - Options for the transaction (table).
{
  -- The tx watch interval in milliseconds (or nil to not wait the tx receipt).
  watch_interval = number,
  -- The path to locate contract artifacts. For now, this path is relative
  -- to where you execute `kipt`. Be aware of that.
  artifacts_path = string,
  -- If the class is already declared, no error is returned, the declaration
  -- is skipped and the class hash is returned. The default value is false.
  skip_if_declared = bool,

  -- Any other keys in the table are ignored.
}

-- @return - A table on success, string error otherwise.
{
  -- The transaction hash (only if `skip_if_declared` is false).
  tx_hash = string,
  -- The declared class hash (Sierra class hash).
  class_hash = string,
}

There are two class-hash on Starknet:

  • Sierra class-hash, which is the hash of the Sierra representation of the program, including the ABI.
  • Casm class-hash, which is the compiled hash of the program.

By class-hash we regularly speak about the Sierra one. But to declare a contract, we also need the Casm class-hash in order to ensure that the compilation of the Sierra code on-chain is lowered to the expected Casm class-hash.

To ease the process of declaring a new class, you must first use Scarb to compile your contract. Please ensure you've sierra and casm options enabled in your Scarb.toml file.

[[target.starknet-contract]]
# Enable Sierra codegen.
sierra = true
# Enable CASM codegen.
casm = true

This will generated two files (called contracts artifacts):

  • mycontract.contract_class.json (Sierra)
  • mycontract.compiled_contract_class.json (Casm)

The two files will be loaded by Kipt. For this, you only need to provide the contract's name and the path to find the artifacts (usually generated in target/dev directory of your scarb package).

Example

local opts = {
  watch_interval = 300,
  artifacts_path = "./target/dev",
}

local decl_res, _ = declare("mycontract", opts)

print("Declare transaction hash:" .. decl_res.tx_hash)
print("Declared class_hash: " .. decl_res.class_hash)

-- If you want to check the error and exit on error:

local decl_res, err = declare("mycontract", opts)

if err then
  print(err)
  -- Use a non-zero value to indicate an error.
  os.exit(1)
end

ℹ️ Note

Providing the path for artifacts and the contract's name, Kipt will search for <contract_name>_contract_class.json or <contract_name>_sierra.json for the Sierra file. And for the Casm file, Kipt will search for <contract_name>_compiled_contract_class.json or <contract_name>_casm.json.

As you can see, in few lines of code you can control the transactions sent on Starknet. And having the class-hash from the declare, you can now easily deploy an instance of the contract.

Deploy

Deploy a contract instance for the given class-hash.

deploy("class_hash", args, opts)

-- @param class_hash - The sierra class hash to deploy (string).
string

-- @param args - Arguments passed to the constructor during deployment (table array-like of strings).
{ string, string, ... }

-- @param opts - Options for the transaction (table).
{
  -- The tx watch interval in milliseconds (or nil to not wait the tx receipt).
  watch_interval = number,
  -- The salt use to compute the contract address (or nil to use a random salt).
  salt = string,
  -- Any other keys in the table are ignored.
}

-- @return - A table on success, string error otherwise.
{
  -- The transaction hash.
  tx_hash = string,
  -- The address of the deployed contract.
  deployed_address = string,
}

For now, the felt252 type is represented as a string in Lua. So you have to pass the arguments as serialized felts.

Work in progress: in the future, Kipt will also provider some basic scheme as starkli does.

Example

local opts = {
  watch_interval = 300,
}

local decl_res, _ = declare("mycontract", opts)
local class_hash = decl_res.class_hash

-- If the constructor has no arguments, just use an emtpy table.
local args = {}
local depl_res, _ = deploy(class_hash, args, opts)

print("Deploy transaction hash:" .. depl_res.tx_hash)
print("Deployed address: " .. depl_res.deployed_address)

-- Add some arguments in array-like fashion:
local args = { "0x1234", "0x8822" }
local depl_res, _ = deploy(class_hash, args, opts)

Invoke

Sends an invoke transaction to Starknet.

invoke(calls, opts)

-- @param calls - A list of calls to be executed (table).
{
  {
    -- Contract to target.
    to = string,
    -- The function name to invoke.
    func = string,
    -- Arguments for the function (table array-like of strings)
    calldata = { string, string ... },
  },
  ...
}

-- @param opts - Options for the transaction (table).
{
  -- The tx watch interval in milliseconds (or nil to not wait the tx receipt).
  watch_interval = number,
  -- Any other keys in the table are ignored.
}

-- @return - A table on success, string error otherwise.
{
  -- The transaction hash.
  tx_hash = string,
}

For now, the felt252 type is represented as a string in Lua. So you have to pass the arguments as serialized felts.

Work in progress: in the future, Kipt will also provider some basic scheme as starkli does.

Example

local opts = {
  watch_interval = 300,
}

local invk_res, _ = invoke(
   {
      {
         to = "0x1111",
         func = "set_a",
         calldata = { "0x1234" },
      },
   },
   opts
)

print("Invoke TX hash: " .. invk_res.tx_hash)

Call

Realizes a function call on Starknet.

call("contract_address", "function_name", args, opts)

-- @param contract_address - The contract to target (string).
string

-- @param function_name - The name of the function to call (string).
string

-- @param args - Arguments passed to function (table array-like of strings).
{ string, string, ... }

-- @param opts - Options for the transaction (table).
{
  -- The block id against which the function call is done. Can be "pending", "latest" or any number in decimal.
  -- Default = "pending".
  block_id = string,
  -- Any other keys in the table are ignored.
}

-- @return - A table array-like of strings on success, string error otherwise.
{ string, string, ... }

For now, the felt252 type is represented as a string in Lua. So you have to pass the arguments as serialized felts.

Work in progress: in the future, Kipt will also provider some basic scheme as starkli does.

The output of the call is also the serialized list of felts as string.

Example

local call_res, _ = call(contract_address, "get_a", {}, { block_id = "latest" })
print_str_array(call_res)

ℹ️ Note

Note the usage of print_str_array, this is a function provided by Kipt to easily print a formatted array of felts returns by a call.

Watch Transaction

Polls the receipt of the given transaction hash.

watch_tx("tx_hash", interval_ms)

-- @param tx_hash - The transaction hash (string).
string

-- @param interval_ms - The interval in milliseconds to poll the receipt (number).
number

As you've seen for transaction based functions (like declare, deploy and invoke) you already have an option you can pass to wait for the transaction receipt before continuing.

However, in some cases, you may want to send several transactions, but you don't want declare, deploy and invoke to block until the receipt is available.

For this, you can set the watch_interval of such functions to nil (or simply remove this key from the options), and then you can manually watch any transaction you would like.

Example

-- Note here the absence of `watch_interval` key as options is empty.
local set_a_res, _ = invoke(
   {
      {
         to = "0x1111",
         func = "set_a",
         calldata = { "0x1234" },
      },
   },
   {}
)

-- the first invoke will not block, and will return
-- once the transaction hash is obtained from the RPC.
-- But the transaction may not be executed at the moment
-- we call this one.
local set_b_res, _ = invoke(
   {
      {
         to = "0x1111",
         func = "set_b",
         calldata = { "0xff" },
      },
   },
   {}
)

watch_tx(set_b_res.tx_hash, 200);

-- once here, we're sure that the second invoke transaction
-- was processed by the sequencer.

All functions

An example using all functions:

RPC = "KATANA"
ACCOUNT_ADDRESS = "0x517ececd29116499f4a1b64b094da79ba08dfd54a3edaa316134c41f8160973"
ACCOUNT_PRIVKEY = "0x1800000000300000180000000000030000000000003006001800006600"

-- No args -> kipt.out
local logger = logger_init()

-- Reuse this opts in each one as each function
-- only looks for it's configuration options. The other
-- are ignored.

local decl_res, err = declare(
    "mycontract",
    { watch_interval = 300, artifacts_path = "./target/dev" }
)

if err then
  print(err)
  os.exit(1)
end

print("Declared class_hash: " .. decl_res.class_hash)

-- Deploy with no constructor args.
local depl_res, err = deploy(decl_res.class_hash, {}, { watch_interval = 300, salt = "0x1234" })

if err then
  print(err)
  os.exit(2)
end

local contract_address = depl_res.deployed_address
print("Contract deployed at: " .. contract_address)

-- Invoke to set a value.
local invk_res, err = invoke(
   {
      {
         to = contract_address,
         func = "set_a",
         calldata = { "0x1234" },
      },
   },
   { watch_interval = 300 }
)

if err then
  print(err)
  os.exit(3)
end

print("Invoke TX hash: " .. invk_res.tx_hash)

local call_res, err = call(contract_address, "get_a", {}, {})
print_str_array(call_res)

The output auto-generated by Kipt due to the use of logger_init() is the following:

-- Friday, November  3, 2023 02:24:58 --

> declare: mycontract
|     tx_hash      |  0x003fc5e719e67eedcefd3f1ac9902f5d09a1e1839d99441b8d52e4564879255f  |
|    class_hash    |  0x06013bb81a928bd226416a34bdfabd7a69b43bab2911a8ae3647a4ba9706bfc0  |

> deploy: 0x06013bb81a928bd226416a34bdfabd7a69b43bab2911a8ae3647a4ba9706bfc0
|     tx_hash      |  0x008fdb1a82b1f5b76597a45919def8d592c6a4de99378897090031d4c63ab712  |
| deployed address |  0x0128c14a284dac9224afff4ad9c8a071e561932166c2ced2544ed0c67b4cecce  |

> invoke: 1
call #0 -> 0x0128c14a284dac9224afff4ad9c8a071e561932166c2ced2544ed0c67b4cecce set_a
|     tx_hash      |  0x00420beed7f7395f29ce81595ffc35cb97732a6d83b298208cf8f3df621e8f95  |