Skip to content

Commit a79f9e1

Browse files
committed
test: add some fast-check tests for SIP-031
1 parent 1d91318 commit a79f9e1

File tree

1 file changed

+223
-0
lines changed

1 file changed

+223
-0
lines changed
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { project, accounts } from '../clarigen-types';
2+
import { projectFactory } from '@clarigen/core';
3+
import { rov, txErr, txOk } from '@clarigen/test';
4+
import { test, expect } from 'vitest';
5+
import * as fc from 'fast-check';
6+
import { Cl } from '@stacks/transactions';
7+
8+
const contracts = projectFactory(project, 'simnet');
9+
const contract = contracts.sip031;
10+
const constants = contract.constants;
11+
const indirectContract = contracts.sip031Indirect;
12+
13+
/**
14+
* "Mint" STX to the contract
15+
*/
16+
function mint(amount: number | bigint) {
17+
txOk(
18+
indirectContract.transferStx(amount, contract.identifier),
19+
accounts.wallet_4.address,
20+
);
21+
}
22+
23+
// Helper function to mint the initial 200M STX to the contract
24+
function mintInitial() {
25+
// First make sure wallet_4 has enough STX to mint the initial amount
26+
txOk(
27+
indirectContract.transferStx(
28+
constants.INITIAL_MINT_AMOUNT / 2n,
29+
accounts.wallet_4.address,
30+
),
31+
accounts.wallet_5.address,
32+
);
33+
txOk(
34+
indirectContract.transferStx(
35+
constants.INITIAL_MINT_AMOUNT / 2n,
36+
accounts.wallet_4.address,
37+
),
38+
accounts.wallet_6.address,
39+
);
40+
// Mint the entire INITIAL_MINT_AMOUNT to the vesting contract
41+
mint(constants.INITIAL_MINT_AMOUNT);
42+
}
43+
44+
function months(n: number) {
45+
return n * Number(constants.INITIAL_MINT_VESTING_ITERATION_BLOCKS);
46+
}
47+
48+
test('property: vesting calculations are always mathematically correct', async () => {
49+
await fc.assert(
50+
fc.asyncProperty(
51+
fc.integer({
52+
min: 0,
53+
max: 100 * Number(constants.INITIAL_MINT_VESTING_ITERATION_BLOCKS),
54+
}), // blocks elapsed (0-100 months)
55+
fc.bigInt({ min: 1n, max: 1000000n * 1000000n }), // extra deposit (1 micro-STX to 1M STX)
56+
async (blocksElapsed, extraDeposit) => {
57+
const manifestPath = global.options.clarinet.manifestPath;
58+
await simnet.initSession(process.cwd(), manifestPath);
59+
60+
const monthsElapsed = Math.floor(
61+
blocksElapsed /
62+
Number(constants.INITIAL_MINT_VESTING_ITERATION_BLOCKS),
63+
);
64+
65+
console.log(
66+
`Running vesting calculation test for ${blocksElapsed} blocks and extra deposit ${extraDeposit}`,
67+
);
68+
mintInitial();
69+
70+
// Add extra deposit
71+
if (extraDeposit > 0n) {
72+
mint(extraDeposit);
73+
}
74+
75+
// Advance time
76+
if (monthsElapsed > 0) {
77+
simnet.mineEmptyBlocks(months(monthsElapsed));
78+
}
79+
80+
// Calculate expected vested amount
81+
const effectiveMonths = Math.min(monthsElapsed, 24);
82+
const expectedVested =
83+
effectiveMonths < 24
84+
? (constants.INITIAL_MINT_VESTING_AMOUNT /
85+
constants.INITIAL_MINT_VESTING_ITERATIONS) *
86+
BigInt(effectiveMonths)
87+
: constants.INITIAL_MINT_VESTING_AMOUNT;
88+
89+
const expectedTotal =
90+
constants.INITIAL_MINT_IMMEDIATE_AMOUNT +
91+
expectedVested +
92+
extraDeposit;
93+
94+
// Claim and verify
95+
const receipt = txOk(contract.claim(), accounts.deployer.address);
96+
97+
// Properties that must always hold:
98+
// 1. Claimed amount should match calculation
99+
expect(receipt.value).toBe(expectedTotal);
100+
101+
// 2. Remaining balance should be correct
102+
const remainingBalance = rov(
103+
indirectContract.getBalance(contract.identifier),
104+
);
105+
const expectedRemaining =
106+
effectiveMonths < 24
107+
? constants.INITIAL_MINT_VESTING_AMOUNT - expectedVested
108+
: 0n;
109+
expect(remainingBalance).toBe(expectedRemaining);
110+
111+
// 3. Total funds should be conserved
112+
const totalFunds = receipt.value + remainingBalance;
113+
expect(totalFunds).toBe(constants.INITIAL_MINT_AMOUNT + extraDeposit);
114+
},
115+
),
116+
{ numRuns: 50 },
117+
);
118+
});
119+
120+
test('property: recipient changes maintain access control invariants', async () => {
121+
await fc.assert(
122+
fc.asyncProperty(
123+
fc.integer({
124+
min: 0,
125+
max: 10 * Number(constants.INITIAL_MINT_VESTING_ITERATION_BLOCKS),
126+
}), // blocks elapsed (0-10 months)
127+
fc.array(fc.integer({ min: 0, max: 9 }), { minLength: 1, maxLength: 20 }), // sequence of wallet indices
128+
async (blocksElapsed, walletIndices) => {
129+
// Reset state for each property test run
130+
const manifestPath = global.options.clarinet.manifestPath;
131+
await simnet.initSession(process.cwd(), manifestPath);
132+
133+
const wallets = [
134+
accounts.deployer.address,
135+
accounts.wallet_1.address,
136+
accounts.wallet_2.address,
137+
accounts.wallet_3.address,
138+
accounts.wallet_4.address,
139+
accounts.wallet_5.address,
140+
accounts.wallet_6.address,
141+
accounts.wallet_7.address,
142+
accounts.wallet_8.address,
143+
accounts.wallet_9.address,
144+
];
145+
146+
let currentRecipient: string = accounts.deployer.address;
147+
148+
// Perform sequence of recipient changes, advancing blocks between changes
149+
for (const walletIndex of walletIndices) {
150+
simnet.mineEmptyBlocks(blocksElapsed);
151+
const newRecipient = wallets[walletIndex];
152+
if (newRecipient !== currentRecipient) {
153+
txOk(contract.updateRecipient(newRecipient), currentRecipient);
154+
currentRecipient = newRecipient;
155+
}
156+
157+
// Invariant: only current recipient can perform operations
158+
expect(rov(contract.getRecipient())).toBe(currentRecipient);
159+
160+
const otherWallets = wallets.filter((w) => w !== currentRecipient);
161+
for (const otherWallet of otherWallets) {
162+
// Invariant: other wallets cannot update recipient
163+
const receipt = txErr(
164+
contract.updateRecipient(accounts.deployer.address),
165+
otherWallet,
166+
);
167+
expect(receipt.value).toBe(constants.ERR_NOT_ALLOWED);
168+
169+
// Invariant: other wallets cannot claim
170+
const claimReceipt = txErr(contract.claim(), otherWallet);
171+
expect(claimReceipt.value).toBe(constants.ERR_NOT_ALLOWED);
172+
}
173+
}
174+
},
175+
),
176+
{ numRuns: 20 },
177+
);
178+
});
179+
180+
test('property: calc-total-vested is always correct', () => {
181+
fc.assert(
182+
fc.property(
183+
fc.array(
184+
fc.bigInt({
185+
min: 0n,
186+
max: 1000n * constants.INITIAL_MINT_VESTING_ITERATION_BLOCKS,
187+
}),
188+
{
189+
minLength: 2,
190+
maxLength: 50,
191+
},
192+
),
193+
(burnHeights) => {
194+
const deployBlockHeight = rov(contract.getDeployBlockHeight());
195+
for (const burnHeight of burnHeights) {
196+
// This function cannot be called before the contract is deployed
197+
if (burnHeight < deployBlockHeight) {
198+
continue;
199+
}
200+
201+
const diff = burnHeight - deployBlockHeight;
202+
const monthsElapsed =
203+
diff / constants.INITIAL_MINT_VESTING_ITERATION_BLOCKS;
204+
const expectedVested =
205+
constants.INITIAL_MINT_IMMEDIATE_AMOUNT +
206+
(monthsElapsed < 24
207+
? (constants.INITIAL_MINT_VESTING_AMOUNT /
208+
constants.INITIAL_MINT_VESTING_ITERATIONS) *
209+
BigInt(monthsElapsed)
210+
: constants.INITIAL_MINT_VESTING_AMOUNT);
211+
const actual = simnet.callPrivateFn(
212+
contract.identifier,
213+
'calc-total-vested',
214+
[Cl.uint(burnHeight)],
215+
accounts.deployer.address,
216+
);
217+
expect(actual.result).toBeUint(expectedVested);
218+
}
219+
},
220+
),
221+
{ numRuns: 1000 },
222+
);
223+
});

0 commit comments

Comments
 (0)