Language Tour
Troubleshooting

Troubleshooting

On-chain programming can be a bit tedious and shares a lot of commonalities with embedded programming. Because the execution environment is so constrained, programs have to be optimized and usually leave little room for troubleshooting errors.

Aiken tries its best to provide developers with extra tools and debugging capabilities. Let's explore them.

Traces

Your first ally in this journey are traces. Think of a trace as a log message, that is captured by the virtual machine at specific moment. You can add traces to top-level expressions in Aiken using the trace keyword.

For example:

fn is_even(n: Int) -> Bool {
  trace "is_even"
  n % 2 == 0
}
 
fn is_odd(n: Int) -> Bool {
  trace "is_odd"
  n % 2 != 0
}

Traces can be a little hard to grasp initially since Plutus -- and therefore Aiken -- is a purely functional execution engine. There's therefore no statements in a compiled program. There's only expressions. A trace will be collected if it is evaluated by the virtual machine. There are two common ways to capture traces in Aiken: when running tests via aiken check or when simulating a transaction using aiken tx simulate. In both cases, traces captured during evaluation will be printed on screen.

For example, in the following program:

let n = 10
is_even(n) || is_odd(n)

Only the trace is_even will be captured, because is_odd is in fact never evaluated (there's no need because the left-hand side already returns True).

⚠️

Note that traces are:

  • Removed by default when building your project with aiken build. They can be preserved using --trace-level verbose;
  • Kept by default when checking your project with aiken check. They can be left out using --trace-level silent.

This is because tracing makes compiled code bigger and can add an extra overhead which is often undesired for final production-ready validators. Yet, they are useful for development and when testing. The command-line is thus geared towards those use-cases. Beware that while enabling or disabling traces doesn't change the semantic of your program, it effectively changes its hash value, and thus its associated addresses.

? operator

On-chain programs are fundamentally nothing more than predicates. Said differently, they are functions that return True or False. Hence, it is common practice to structure on-chain programs as conjunctions and disjunctions of booleans expressions.

This can be a little hard to reason about however because booleans are "blind". That is, you lose information about the original context as you evaluate complex boolean expressions.

Take for example the following simple expression:

let must_be_after = True
let must_spend_token = False
 
must_be_after && must_spend_token

It evaluates to False. From just False, you can't really tell which branch was actually False in the original expression. Yet it is often useful to reason about even larger expressions to troubleshoot an issue.

This is why Aiken provides the ? operator (reads as "trace-if-false operator"). This postfix operator can be appended to any boolean expression and will trace the expression only if it evaluates to False. This is useful to trace an entire evaluation path that led to a final expression being False. In the example above, we could have written:

must_be_after? && must_spend_token?

Which would have generated the trace "must_spend_token ? False".

Handy, right?

Incidentally, the ? operator works like trace and is therefore affected by the --keep-traces and --no-traces options. When compiling for production, it has therefore no effect on the program and behaves as if it wasn't there at all.

CBOR diagnostic

This is all great but sometimes, you need more. Sometimes, you need to inspect the value of some specific object at runtime. This is harder than it seems because a compiled Aiken program has erased any context and any notion of types. Even functions and variable names are replaced by compact indices which makes it relatively hard to inspect programs and values at runtime. For example, this is what a compiled function may look like in UPLC:

(lam i_31
  (lam i_32
    (lam i_33
      (force
        [ [ [ i_2 i_32 ] (delay (con unit ())) ]
          (delay
            [ [ i_4 [ i_33 [ i_1 i_32 ] ] ]
              [ [ [ i_31 i_31 ] [ i_0 i_32 ] ] i_33
              ]
            ]
          )
        ]
      )
    )
  )
)

Note quite easy to read, huh? But there's hope! Aiken's standard library (opens in a new tab) provides a convenient method to inspect any value at runtime and obtain a String representation of them. The syntax used for this representation is called a CBOR diagnostic (opens in a new tab). Think of it as a high-level syntax that resembles JSON and that can represent binary data.

aiken/cbor
pub fn diagnostic(data: Data) -> String

Why use CBOR diagnostics you may ask?

Well, because it is what most faithfully captures the representation of objects present at runtime and in the interface of on-chain validators. Getting familiar with CBOR diagnostics requires a bit of practice but can be a useful skill to master when working with Cardano in general. CBOR is everywhere in Cardano, including in on-chain validators. Datum and redeemers are, for example, provided as CBOR objects to the validator by the ledger. Transactions are also themselves encoded as CBOR when serialized and propagated to the network.

A CBOR diagnostic is merely a slightly more human-friendly way to visualize a binary object. For example, a serialized list of integers such as 83010203 is represented as [1, 2, 3] in diagnostic notation.

In addition, most tools and libraries that deal with CBOR make it easy to go back-and-forth between the raw encoding and the diagnostic notation. This is the case of cbor.me (opens in a new tab) or cbor-diag (opens in a new tab) for instance.

Here's a little cheatsheet to help you decipher CBOR diagnostics:

TypeExamples
Int1, -14, 42
ByteArrayh'FF00', h'666f6f'
List[], [1, 2, 3], [_ 1, 2, 3]
Map{}, { 1: h'FF', 2: 14 }, {_ 1: "AA" }
Tag42(1), 10(h'ABCD'), 1280([1, 2])

While most are pretty transparent, the use-case for Tag may not strike many as obvious. In fact, Tag is used to encode custom types on-chain, starting from tag 121 for the first constructor of a data-type, 122 for the next, and so forth. What is tagged corresponds to the fields of the constructors, as a list of objects.

Let's see some more examples of diagnostics from real Aiken values.

use aiken/cbor
 
 
// An Int becomes a CBOR int
cbor.diagnostic(42) == @"42"
 
// A ByteArray becomes a CBOR bytestring
cbor.diagnostic("foo") == @"h'666F6F'"
 
// A List becomes a CBOR array
cbor.diagnostic([1, 2, 3]) == @"[_ 1, 2, 3]"
 
// A Tuple becomes a CBOR array
cbor.diagnostic((1, 2)) == @"[_ 1, 2]"
 
// A List of 2-tuples becomes a CBOR map
cbor.diagnostic([(1, #"ff")]) == @"{ 1: h'FF' }"
 
// 'Some' is the first constructor of Option → tagged as 121
cbor.diagnostic(Some(42)) == @"121([_ 42])"
 
// 'None' is the second constructor of Option → tagged as 122
cbor.diagnostic(None) == @"122([])"

Diagnostics are meant to be used only in development or for testing; in combination with trace for example. Incidentally, they also make for a convenient way to double-check the binary representation of some instance of your datum or redeemer through tests. Imagine the following type:

type MyDatum {
  foo: Int,
  bar: ByteArray
}

Eventually, you will need to construct compatible values for building an associated transaction. Aiken provides blueprints as build outputs to help with that. Yet you may also control some chosen values directly using cbor.diagnostic and a test:

use aiken/cbor
 
test my_datum_1() {
  let datum = MyDatum { foo: 42, bar: "Hello, World!" }
  cbor.diagnostic(datum) == @"121([42, h'48656c6c6f2c20576f726c6421'])"
}

You can turn this diagnostic into raw CBOR using tools such as cbor.me (opens in a new tab).