Skip to content

Commit 1b00198

Browse files
authored
Merge pull request #6316 from obycode/test/sip-031
Additional SIP-031 tests
2 parents 16668fa + 807527c commit 1b00198

File tree

4 files changed

+735
-7
lines changed

4 files changed

+735
-7
lines changed

contrib/core-contract-tests/contracts/sip-031-indirect.clar

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@
77
)
88
)
99

10+
;; WARNING: This is for testing purposes only.
11+
;; This is not a safe way to call `update-recipient` from an external contract,
12+
;; as it does not perform the necessary authorization checks.
13+
(define-public (update-recipient-as-contract (new-recipient principal))
14+
(as-contract (contract-call? 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.sip-031
15+
update-recipient new-recipient
16+
))
17+
)
18+
19+
;; WARNING: This is for testing purposes only.
20+
;; This is not a safe way to call `claim` from an external contract,
21+
;; as it does not perform the necessary authorization checks.
22+
(define-public (claim-as-contract)
23+
(as-contract (contract-call? 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.sip-031 claim))
24+
)
25+
1026
;; Helper function to transfer STX within tests
1127
(define-public (transfer-stx
1228
(amount uint)

contrib/core-contract-tests/tests/clarigen-types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4687,6 +4687,12 @@ export const contracts = {
46874687
},
46884688
sip031Indirect: {
46894689
functions: {
4690+
claimAsContract: {
4691+
name: 'claim-as-contract',
4692+
access: 'public',
4693+
args: [],
4694+
outputs: { type: { response: { ok: 'uint128', error: 'uint128' } } },
4695+
} as TypedAbiFunction<[], Response<bigint, bigint>>,
46904696
transferStx: {
46914697
name: 'transfer-stx',
46924698
access: 'public',
@@ -4711,6 +4717,15 @@ export const contracts = {
47114717
[newRecipient: TypedAbiArg<string, 'newRecipient'>],
47124718
Response<boolean, bigint>
47134719
>,
4720+
updateRecipientAsContract: {
4721+
name: 'update-recipient-as-contract',
4722+
access: 'public',
4723+
args: [{ name: 'new-recipient', type: 'principal' }],
4724+
outputs: { type: { response: { ok: 'bool', error: 'uint128' } } },
4725+
} as TypedAbiFunction<
4726+
[newRecipient: TypedAbiArg<string, 'newRecipient'>],
4727+
Response<boolean, bigint>
4728+
>,
47144729
getBalance: {
47154730
name: 'get-balance',
47164731
access: 'read_only',
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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+
mintInitial();
66+
67+
// Add extra deposit
68+
if (extraDeposit > 0n) {
69+
mint(extraDeposit);
70+
}
71+
72+
// Advance time
73+
if (monthsElapsed > 0) {
74+
simnet.mineEmptyBlocks(months(monthsElapsed));
75+
}
76+
77+
// Calculate expected vested amount
78+
const effectiveMonths = Math.min(monthsElapsed, 24);
79+
const expectedVested =
80+
effectiveMonths < 24
81+
? (constants.INITIAL_MINT_VESTING_AMOUNT /
82+
constants.INITIAL_MINT_VESTING_ITERATIONS) *
83+
BigInt(effectiveMonths)
84+
: constants.INITIAL_MINT_VESTING_AMOUNT;
85+
86+
const expectedTotal =
87+
constants.INITIAL_MINT_IMMEDIATE_AMOUNT +
88+
expectedVested +
89+
extraDeposit;
90+
91+
// Claim and verify
92+
const receipt = txOk(contract.claim(), accounts.deployer.address);
93+
94+
// Properties that must always hold:
95+
// 1. Claimed amount should match calculation
96+
expect(receipt.value).toBe(expectedTotal);
97+
98+
// 2. Remaining balance should be correct
99+
const remainingBalance = rov(
100+
indirectContract.getBalance(contract.identifier),
101+
);
102+
const expectedRemaining =
103+
effectiveMonths < 24
104+
? constants.INITIAL_MINT_VESTING_AMOUNT - expectedVested
105+
: 0n;
106+
expect(remainingBalance).toBe(expectedRemaining);
107+
108+
// 3. Total funds should be conserved
109+
const totalFunds = receipt.value + remainingBalance;
110+
expect(totalFunds).toBe(constants.INITIAL_MINT_AMOUNT + extraDeposit);
111+
},
112+
),
113+
{ numRuns: 50 },
114+
);
115+
});
116+
117+
test('property: recipient changes maintain access control invariants', async () => {
118+
await fc.assert(
119+
fc.asyncProperty(
120+
fc.integer({
121+
min: 0,
122+
max: 10 * Number(constants.INITIAL_MINT_VESTING_ITERATION_BLOCKS),
123+
}), // blocks elapsed (0-10 months)
124+
fc.array(fc.integer({ min: 0, max: 9 }), { minLength: 1, maxLength: 20 }), // sequence of wallet indices
125+
async (blocksElapsed, walletIndices) => {
126+
// Reset state for each property test run
127+
const manifestPath = global.options.clarinet.manifestPath;
128+
await simnet.initSession(process.cwd(), manifestPath);
129+
130+
const wallets = [
131+
accounts.deployer.address,
132+
accounts.wallet_1.address,
133+
accounts.wallet_2.address,
134+
accounts.wallet_3.address,
135+
accounts.wallet_4.address,
136+
accounts.wallet_5.address,
137+
accounts.wallet_6.address,
138+
accounts.wallet_7.address,
139+
accounts.wallet_8.address,
140+
accounts.wallet_9.address,
141+
];
142+
143+
let currentRecipient: string = accounts.deployer.address;
144+
145+
// Perform sequence of recipient changes, advancing blocks between changes
146+
for (const walletIndex of walletIndices) {
147+
simnet.mineEmptyBlocks(blocksElapsed);
148+
const newRecipient = wallets[walletIndex];
149+
if (newRecipient !== currentRecipient) {
150+
txOk(contract.updateRecipient(newRecipient), currentRecipient);
151+
currentRecipient = newRecipient;
152+
}
153+
154+
// Invariant: only current recipient can perform operations
155+
expect(rov(contract.getRecipient())).toBe(currentRecipient);
156+
157+
const otherWallets = wallets.filter((w) => w !== currentRecipient);
158+
for (const otherWallet of otherWallets) {
159+
// Invariant: other wallets cannot update recipient
160+
const receipt = txErr(
161+
contract.updateRecipient(accounts.deployer.address),
162+
otherWallet,
163+
);
164+
expect(receipt.value).toBe(constants.ERR_NOT_ALLOWED);
165+
166+
// Invariant: other wallets cannot claim
167+
const claimReceipt = txErr(contract.claim(), otherWallet);
168+
expect(claimReceipt.value).toBe(constants.ERR_NOT_ALLOWED);
169+
}
170+
}
171+
},
172+
),
173+
{ numRuns: 20 },
174+
);
175+
});
176+
177+
test('property: calc-total-vested is always correct', () => {
178+
fc.assert(
179+
fc.property(
180+
fc.array(
181+
fc.bigInt({
182+
min: 0n,
183+
max: 1000n * constants.INITIAL_MINT_VESTING_ITERATION_BLOCKS,
184+
}),
185+
{
186+
minLength: 2,
187+
maxLength: 50,
188+
},
189+
),
190+
(burnHeights) => {
191+
const deployBlockHeight = rov(contract.getDeployBlockHeight());
192+
for (const burnHeight of burnHeights) {
193+
// This function cannot be called before the contract is deployed
194+
if (burnHeight < deployBlockHeight) {
195+
continue;
196+
}
197+
198+
const diff = burnHeight - deployBlockHeight;
199+
const monthsElapsed =
200+
diff / constants.INITIAL_MINT_VESTING_ITERATION_BLOCKS;
201+
const expectedVested =
202+
constants.INITIAL_MINT_IMMEDIATE_AMOUNT +
203+
(monthsElapsed < 24
204+
? (constants.INITIAL_MINT_VESTING_AMOUNT /
205+
constants.INITIAL_MINT_VESTING_ITERATIONS) *
206+
BigInt(monthsElapsed)
207+
: constants.INITIAL_MINT_VESTING_AMOUNT);
208+
const actual = simnet.callPrivateFn(
209+
contract.identifier,
210+
'calc-total-vested',
211+
[Cl.uint(burnHeight)],
212+
accounts.deployer.address,
213+
);
214+
expect(actual.result).toBeUint(expectedVested);
215+
}
216+
},
217+
),
218+
{ numRuns: 1000 },
219+
);
220+
});

0 commit comments

Comments
 (0)