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.
This technique was used for fuzzing production Stacks protocol code.
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)))))
Install dependencies:
npm install fast-check vitest @hirosystems/clarinet-sdk
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`,
}));
tests/counter/types.ts
:
import { Simnet } from "@hirosystems/clarinet-sdk";
export interface Model {
counter: number;
}
export interface Real {
simnet: Simnet;
}
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 },
);
});
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.