moodmosaic

Invariant testing from TypeScript

UPDATE (August 7, 2025): Added real-world examples from SIP-031 and PoX-4 protocol testing.

This article is part of the Pandora series of articles.

Pandora enables property-based testing, fuzzing, and invariant testing for smart contracts that run on the Stacks 2.x layer-1 blockchain. Pandora discovers and run tests written in Clarity and TypeScript.

Automate invariant testing using fast-check commands to model state transitions.

Real-World Impact

This technique was used for fuzzing production Stacks protocol code.

The Counter Contract

contracts/counter.clar:

(define-data-var counter uint u0)

(define-constant ERR_COUNTER_MUST_BE_POSITIVE (err u401))
(define-constant ERROR_ADD_MORE_THAN_ONE (err u402))

(define-read-only (get-counter)
  (var-get counter))

(define-public (increment)
  (ok (var-set counter (+ (var-get counter) u1))))

(define-public (decrement)
  (let ((current-counter (var-get counter)))
    (asserts! (> current-counter u0) ERR_COUNTER_MUST_BE_POSITIVE)
    (ok (var-set counter (- current-counter u1)))))

(define-public (add (n uint))
  (begin
    (asserts! (> n u1) ERROR_ADD_MORE_THAN_ONE)
    (ok (var-set counter (+ (var-get counter) n)))))

Setup

Install dependencies:

npm install fast-check vitest @hirosystems/clarinet-sdk

Command Structure

Each contract operation becomes a command:

tests/counter/CounterAdd.ts:

import { tx } from "@hirosystems/clarinet-sdk";
import { Cl } from "@stacks/transactions";
import { expect } from "vitest";
import { Model, Real } from "./types";
import fc from "fast-check";

export const CounterAdd = (accounts: Map<string, string>) => fc
  .record({
    number: fc.integer(),
    sender: fc.constantFrom(...accounts.values()),
  })
  .map((r) => ({
    check: (_model: Readonly<Model>) => {
      return r.number > 1;
    },
    run: (model: Model, real: Real) => {
      const block = real.simnet.mineBlock([
        tx.callPublicFn("counter", "add", [Cl.uint(r.number)], r.sender),
      ]);
      expect(block[0].result).toBeOk(Cl.bool(true));

      model.counter = model.counter + r.number;

      console.log(
        `Ӿ tx-sender ${r.sender.padStart(41, " ")}${
          "add".padStart(11, " ")
        } ${r.number.toString().padStart(10, " ")}`,
      );
    },
    toString: () => `add ${r.number}`,
  }));

tests/counter/CounterIncrement.ts:

export const CounterIncrement = (accounts: Map<string, string>) => fc
  .record({
    sender: fc.constantFrom(...accounts.values()),
  })
  .map((r) => ({
    check: (_model: Readonly<Model>) => {
      return true;
    },
    run: (model: Model, real: Real) => {
      const block = real.simnet.mineBlock([
        tx.callPublicFn("counter", "increment", [], r.sender),
      ]);
      expect(block[0].result).toBeOk(Cl.bool(true));

      model.counter = model.counter + 1;

      console.log(
        `Ӿ tx-sender ${r.sender.padStart(41, " ")}${
          "increment".padStart(11, " ")
        }`,
      );
    },
    toString: () => `increment`,
  }));

tests/counter/CounterDecrement.ts:

export const CounterDecrement = (accounts: Map<string, string>) => fc
  .record({
    sender: fc.constantFrom(...accounts.values()),
  })
  .map((r) => ({
    check: (model: Readonly<Model>) => {
      return model.counter > 0;
    },
    run: (model: Model, real: Real) => {
      const block = real.simnet.mineBlock([
        tx.callPublicFn("counter", "decrement", [], r.sender),
      ]);
      expect(block[0].result).toBeOk(Cl.bool(true));

      model.counter = model.counter - 1;

      console.log(
        `Ӿ tx-sender ${r.sender.padStart(41, " ")}${
          "decrement".padStart(11, " ")
        }`,
      );
    },
    toString: () => `decrement`,
  }));

Types

tests/counter/types.ts:

import { Simnet } from "@hirosystems/clarinet-sdk";

export interface Model {
  counter: number;
}

export interface Real {
  simnet: Simnet;
}

Main Test

tests/counter/counter.invariant.test.ts:

import fc from "fast-check";
import { it } from "vitest";

import { CounterAdd } from "./CounterAdd";
import { CounterDecrement } from "./CounterDecrement";
import { CounterIncrement } from "./CounterIncrement";
import { CounterGet } from "./CounterGet";

it("runs invariant test", async () => {
  const accounts = simnet.getAccounts();

  const invariants = [
    CounterAdd(accounts),
    CounterDecrement(accounts),
    CounterIncrement(accounts),
  ];

  const model = {
    counter: 0,
  };

  fc.assert(
    fc.property(
      fc.commands(invariants, { size: "+1" }),
      (cmds) => {
        const state = () => ({ model: model, real: { simnet } });
        fc.modelRun(state, cmds);
      },
    ),
    { numRuns: 100, verbose: 2 },
  );
});

Running

npm test

Output shows command execution:

Ӿ tx-sender ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM ✓   increment          1
Ӿ tx-sender ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG ✓         add        123
Ӿ tx-sender ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5 ✓   decrement          1

Fast-check generates sequences of these commands, creating random command sequences. Each command updates both model and contract state, then verifies they match.


Next: Invariant testing from Clarity.