Skip to content

Commit 632ab2b

Browse files
committed
Add stateful property tests for SIP-031
Added `sip-031.stateful.prop.test.ts`, with initial command set: Claim, ClaimErr, Mint, MintInitial, MineBlocks, UpdateRecipient, UpdateRecipientErr. Tests use fast-check's `modelRun` to verify sip-031.clar against a tracked model. Based on: - https://github.com/stacks-network/stacks-core/tree/a5587d13c51829f49a7645d9484469e5ca891e29/contrib/boot-contracts-stateful-prop-tests/tests/pox-4 - https://blog.nikosbaxevanis.com/2022/03/15/clarity-clarity-model-based-testing-primer/ - https://github.com/moodmosaic/counter-invariant-tests/tree/99c9987c446c74feb1acf970993214c7b2350b53/tests/counter !!! NOT A COMPLETE TEST SUITE !!!
1 parent 4850f72 commit 632ab2b

File tree

10 files changed

+441
-0
lines changed

10 files changed

+441
-0
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import fc from "fast-check";
2+
import type { Model, Real } from "./types";
3+
import { calculateClaimable, logCommand, trackCommandRun } from "./utils";
4+
import { expect } from "vitest";
5+
import { txOk } from "@clarigen/test";
6+
7+
export const Claim = (accounts: Real["accounts"]) =>
8+
fc.record({
9+
sender: fc.constantFrom(
10+
...Object.values(accounts).map((x) => x.address),
11+
),
12+
}).map((r) => ({
13+
check: (model: Readonly<Model>) => {
14+
const claimable = calculateClaimable(model);
15+
return model.initialized === true && model.recipient === r.sender &&
16+
claimable > 0n;
17+
},
18+
run: (model: Model, real: Real) => {
19+
trackCommandRun(model, "claim");
20+
21+
const expectedClaim = calculateClaimable(model);
22+
const receipt = txOk(real.contracts.sip031.claim(), r.sender);
23+
expect(receipt.value).toBe(expectedClaim);
24+
25+
model.balance -= expectedClaim;
26+
model.totalClaimed += expectedClaim;
27+
28+
logCommand({
29+
sender: r.sender,
30+
status: "ok",
31+
action: "claim",
32+
value: `amount ${expectedClaim}`,
33+
});
34+
},
35+
toString: () => `claim`,
36+
}));
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import fc from "fast-check";
2+
import type { Model, Real } from "./types";
3+
import { calculateClaimable, logCommand, trackCommandRun } from "./utils";
4+
import { expect } from "vitest";
5+
import { txErr } from "@clarigen/test";
6+
7+
export const ClaimErr = (accounts: Real["accounts"]) =>
8+
fc.record({
9+
sender: fc.constantFrom(
10+
...Object.values(accounts).map((x) => x.address),
11+
),
12+
}).map((r) => ({
13+
check: (model: Readonly<Model>) => {
14+
if (model.initialized !== true) {
15+
return false;
16+
}
17+
18+
if (model.recipient !== r.sender) {
19+
return true;
20+
}
21+
22+
const claimable = calculateClaimable(model);
23+
return claimable === 0n;
24+
},
25+
run: (model: Model, real: Real) => {
26+
trackCommandRun(model, "claim-err");
27+
28+
const expectedError = model.recipient !== r.sender
29+
? model.constants.ERR_NOT_ALLOWED
30+
: model.constants.ERR_NOTHING_TO_CLAIM;
31+
const receipt = txErr(real.contracts.sip031.claim(), r.sender);
32+
expect(receipt.value).toBe(expectedError);
33+
34+
const errString = expectedError === model.constants.ERR_NOT_ALLOWED
35+
? "ERR_NOT_ALLOWED"
36+
: "ERR_NOTHING_TO_CLAIM";
37+
logCommand({
38+
sender: r.sender,
39+
status: "err",
40+
action: "claim-err",
41+
error: errString,
42+
});
43+
},
44+
toString: () => `claim-err as ${r.sender}`,
45+
}));
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import fc from "fast-check";
2+
import type { Model, Real } from "./types";
3+
import { logCommand, trackCommandRun } from "./utils";
4+
5+
export const MineBlocks = () =>
6+
fc.record({
7+
blocks: fc.integer({ min: 1, max: 100 }),
8+
}).map((r) => ({
9+
check: (model: Readonly<Model>) => model.initialized === true,
10+
run: (model: Model, _real: Real) => {
11+
trackCommandRun(model, "mine-blocks");
12+
13+
simnet.mineEmptyBlocks(r.blocks);
14+
15+
logCommand({
16+
sender: undefined,
17+
status: "ok",
18+
action: "mine-blocks",
19+
value: `${r.blocks}`,
20+
});
21+
},
22+
toString: () => `mine-blocks ${r.blocks}`,
23+
}));
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import fc from "fast-check";
2+
import type { Model, Real } from "./types";
3+
import { txOk } from "@clarigen/test";
4+
import { logCommand, trackCommandRun } from "./utils";
5+
6+
export const Mint = () =>
7+
fc.record({
8+
amount: fc.bigInt(1n, 100000000n),
9+
}).map((r) => ({
10+
check: (model: Readonly<Model>) => model.initialized === true,
11+
run: (model: Model, real: Real) => {
12+
trackCommandRun(model, "mint");
13+
14+
txOk(
15+
real.contracts.sip031Indirect.transferStx(
16+
r.amount,
17+
real.contracts.sip031.identifier,
18+
),
19+
real.accounts.wallet_4.address,
20+
);
21+
22+
model.balance += r.amount;
23+
24+
logCommand({
25+
sender: undefined,
26+
status: "ok",
27+
action: "mint",
28+
value: `amount ${r.amount}`,
29+
});
30+
},
31+
toString: () => `mint ${r.amount}`,
32+
}));
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import fc from "fast-check";
2+
import type { Model, Real } from "./types";
3+
import { txOk } from "@clarigen/test";
4+
import { logCommand, trackCommandRun } from "./utils";
5+
6+
export const MintInitial = (accounts: Real["accounts"]) =>
7+
fc.record({}).map(() => ({
8+
check: (model: Readonly<Model>) => model.initialized === false,
9+
run: (model: Model, real: Real) => {
10+
trackCommandRun(model, "mint-initial");
11+
12+
const contracts = real.contracts;
13+
const indirect = contracts.sip031Indirect;
14+
const sip031 = contracts.sip031;
15+
16+
// Split initial mint into two transfers to wallet_4 from wallet_5 and wallet_6.
17+
txOk(
18+
indirect.transferStx(
19+
sip031.constants.INITIAL_MINT_AMOUNT / 2n,
20+
accounts.wallet_4.address,
21+
),
22+
accounts.wallet_5.address,
23+
);
24+
txOk(
25+
indirect.transferStx(
26+
sip031.constants.INITIAL_MINT_AMOUNT / 2n,
27+
accounts.wallet_4.address,
28+
),
29+
accounts.wallet_6.address,
30+
);
31+
32+
// Forward full amount from wallet_4 into the SIP-031 contract.
33+
txOk(
34+
indirect.transferStx(
35+
sip031.constants.INITIAL_MINT_AMOUNT,
36+
sip031.identifier,
37+
),
38+
accounts.wallet_4.address,
39+
);
40+
41+
model.initialized = true;
42+
model.balance = sip031.constants.INITIAL_MINT_AMOUNT;
43+
44+
logCommand({
45+
sender: undefined,
46+
status: "ok",
47+
action: "setup-initial-funding",
48+
value: `amount ${sip031.constants.INITIAL_MINT_AMOUNT}`,
49+
});
50+
},
51+
toString: () => `setup-initial-funding`,
52+
}));
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import fc from "fast-check";
2+
import type { Model, Real } from "./types";
3+
import { expect } from "vitest";
4+
import { txOk } from "@clarigen/test";
5+
import { logCommand, trackCommandRun } from "./utils";
6+
7+
export const UpdateRecipient = (accounts: Real["accounts"]) =>
8+
fc.record({
9+
sender: fc.constantFrom(
10+
...Object.values(accounts).map((x) => x.address),
11+
),
12+
newRecipient: fc.constantFrom(
13+
...Object.values(accounts as Record<string, { address: string }>).map((
14+
acc,
15+
) => acc.address),
16+
),
17+
}).map((r) => ({
18+
check: (model: Readonly<Model>) => {
19+
return model.initialized === true && model.recipient === r.sender;
20+
},
21+
run: (model: Model, real: Real) => {
22+
trackCommandRun(model, "update-recipient");
23+
24+
const receipt = txOk(
25+
real.contracts.sip031.updateRecipient(r.newRecipient),
26+
r.sender,
27+
);
28+
expect(receipt.value).toBe(true);
29+
30+
model.recipient = r.newRecipient;
31+
32+
logCommand({
33+
sender: r.sender,
34+
status: "ok",
35+
action: "update-recipient",
36+
value: `to ${r.newRecipient}`,
37+
});
38+
},
39+
toString: () => `update-recipient to ${r.newRecipient}`,
40+
}));
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import fc from "fast-check";
2+
import type { Model, Real } from "./types";
3+
import { expect } from "vitest";
4+
import { txErr } from "@clarigen/test";
5+
import { logCommand, trackCommandRun } from "./utils";
6+
7+
export const UpdateRecipientErr = (accounts: Real["accounts"]) =>
8+
fc.record({
9+
sender: fc.constantFrom(
10+
...Object.values(accounts).map((x) => x.address),
11+
),
12+
newRecipient: fc.constantFrom(
13+
...Object.values(accounts).map((x) => x.address),
14+
),
15+
}).map((r) => ({
16+
check: (model: Readonly<Model>) => {
17+
return model.initialized === true && model.recipient !== r.sender;
18+
},
19+
run: (model: Model, real: Real) => {
20+
trackCommandRun(model, "update-recipient-err");
21+
22+
const receipt = txErr(
23+
real.contracts.sip031.updateRecipient(r.newRecipient),
24+
r.sender,
25+
);
26+
expect(receipt.value).toBe(model.constants.ERR_NOT_ALLOWED);
27+
28+
logCommand({
29+
sender: r.sender,
30+
status: "err",
31+
action: "update-recipient-err",
32+
error: "ERR_NOT_ALLOWED",
33+
});
34+
},
35+
toString: () => `update-recipient-err as ${r.sender}`,
36+
}));
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { accounts, project } from "../../clarigen-types";
2+
import { projectFactory } from "@clarigen/core";
3+
4+
const contracts = projectFactory(project, "simnet");
5+
6+
export type Real = {
7+
accounts: typeof accounts;
8+
contracts: typeof contracts;
9+
};
10+
11+
export interface Model {
12+
balance: bigint;
13+
blockHeight: bigint;
14+
constants: typeof contracts.sip031.constants;
15+
deployBlockHeight: bigint;
16+
initialized: boolean;
17+
recipient: string;
18+
totalClaimed: bigint;
19+
statistics: Map<string, number>;
20+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { Model } from "./types";
2+
3+
export function calculateClaimable(model: Readonly<Model>): bigint {
4+
const c = model.constants;
5+
6+
const max = c.INITIAL_MINT_VESTING_ITERATIONS;
7+
const amt = c.INITIAL_MINT_VESTING_AMOUNT;
8+
const per = c.STX_PER_ITERATION;
9+
const step = c.INITIAL_MINT_VESTING_ITERATION_BLOCKS;
10+
11+
// If before deployment, nothing vested yet.
12+
const diff = model.blockHeight < model.deployBlockHeight
13+
? 0n
14+
: model.blockHeight - model.deployBlockHeight;
15+
16+
const iter = diff / step;
17+
const vest = iter >= max ? amt : per * iter;
18+
19+
const total = c.INITIAL_MINT_IMMEDIATE_AMOUNT + vest;
20+
const reserved = c.INITIAL_MINT_AMOUNT - total;
21+
22+
return model.balance > reserved ? model.balance - reserved : 0n;
23+
}
24+
25+
export function logCommand({
26+
sender,
27+
status,
28+
action,
29+
value,
30+
error,
31+
}: {
32+
sender?: string;
33+
status: "ok" | "err";
34+
action: string;
35+
value?: string | number | bigint;
36+
error?: string;
37+
}) {
38+
const senderStr = (sender ?? "system").padEnd(41, " ");
39+
const statusStr = status === "ok" ? "✓" : "✗";
40+
const actionStr = action.padEnd(22, " ");
41+
42+
let msg = ${senderStr} ${statusStr} ${actionStr}`;
43+
if (value !== undefined) msg += ` ${String(value)}`;
44+
if (error !== undefined) msg += ` error ${error}`;
45+
46+
console.log(msg);
47+
}
48+
49+
export function trackCommandRun(model: Model, commandName: string) {
50+
const count = model.statistics.get(commandName) || 0;
51+
model.statistics.set(commandName, count + 1);
52+
}
53+
54+
export function reportCommandRuns(model: Model) {
55+
console.log("\nCommand execution counts:");
56+
const orderedStatistics = Array.from(model.statistics.entries()).sort(
57+
([keyA], [keyB]) => {
58+
return keyA.localeCompare(keyB);
59+
},
60+
);
61+
62+
logAsTree(orderedStatistics);
63+
}
64+
65+
function logAsTree(statistics: [string, number][]) {
66+
const tree: { [key: string]: any } = {};
67+
68+
statistics.forEach(([commandName, count]) => {
69+
const split = commandName.split("_");
70+
let root: string = split[0],
71+
rest: string = "base";
72+
73+
if (split.length > 1) {
74+
rest = split.slice(1).join("_");
75+
}
76+
if (!tree[root]) {
77+
tree[root] = {};
78+
}
79+
tree[root][rest] = count;
80+
});
81+
82+
const printTree = (node: any, indent: string = "") => {
83+
const keys = Object.keys(node);
84+
keys.forEach((key, index) => {
85+
const isLast = index === keys.length - 1;
86+
const boxChar = isLast ? "└─ " : "├─ ";
87+
if (key !== "base") {
88+
if (typeof node[key] === "object") {
89+
console.log(`${indent}${boxChar}${key}: x${node[key]["base"]}`);
90+
printTree(node[key], indent + (isLast ? " " : "│ "));
91+
} else {
92+
console.log(`${indent}${boxChar}${key}: ${node[key]}`);
93+
}
94+
}
95+
});
96+
};
97+
98+
printTree(tree);
99+
}

0 commit comments

Comments
 (0)