Skip to content

Commit 8f95c7d

Browse files
committed
feat: vest 100M STX over 2 years
1 parent 8774a0f commit 8f95c7d

File tree

3 files changed

+263
-65
lines changed

3 files changed

+263
-65
lines changed

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

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4556,6 +4556,15 @@ export const contracts = {
45564556
},
45574557
sip031: {
45584558
functions: {
4559+
calcTotalVested: {
4560+
name: "calc-total-vested",
4561+
access: "private",
4562+
args: [{ name: "burn-height", type: "uint128" }],
4563+
outputs: { type: "uint128" },
4564+
} as TypedAbiFunction<
4565+
[burnHeight: TypedAbiArg<number | bigint, "burnHeight">],
4566+
bigint
4567+
>,
45594568
validateCaller: {
45604569
name: "validate-caller",
45614570
access: "private",
@@ -4592,12 +4601,6 @@ export const contracts = {
45924601
args: [],
45934602
outputs: { type: "uint128" },
45944603
} as TypedAbiFunction<[], bigint>,
4595-
getLastVestingClaimBlock: {
4596-
name: "get-last-vesting-claim-block",
4597-
access: "read_only",
4598-
args: [],
4599-
outputs: { type: { optional: "uint128" } },
4600-
} as TypedAbiFunction<[], bigint | null>,
46014604
getRecipient: {
46024605
name: "get-recipient",
46034606
access: "read_only",
@@ -4613,6 +4616,11 @@ export const contracts = {
46134616
},
46144617
maps: {},
46154618
variables: {
4619+
ERR_NOTHING_TO_CLAIM: {
4620+
name: "ERR_NOTHING_TO_CLAIM",
4621+
type: "uint128",
4622+
access: "constant",
4623+
} as TypedAbiVariable<bigint>,
46164624
ERR_NOT_ALLOWED: {
46174625
name: "ERR_NOT_ALLOWED",
46184626
type: "uint128",
@@ -4648,13 +4656,6 @@ export const contracts = {
46484656
type: "uint128",
46494657
access: "variable",
46504658
} as TypedAbiVariable<bigint>,
4651-
lastVestingClaimBlock: {
4652-
name: "last-vesting-claim-block",
4653-
type: {
4654-
optional: "uint128",
4655-
},
4656-
access: "variable",
4657-
} as TypedAbiVariable<bigint | null>,
46584659
recipient: {
46594660
name: "recipient",
46604661
type: "principal",
@@ -4667,14 +4668,14 @@ export const contracts = {
46674668
} as TypedAbiVariable<bigint>,
46684669
},
46694670
constants: {
4671+
ERR_NOTHING_TO_CLAIM: 102n,
46704672
ERR_NOT_ALLOWED: 101n,
46714673
INITIAL_MINT_AMOUNT: 200_000_000_000_000n,
46724674
INITIAL_MINT_IMMEDIATE_AMOUNT: 100_000_000_000_000n,
46734675
INITIAL_MINT_VESTING_AMOUNT: 100_000_000_000_000n,
46744676
INITIAL_MINT_VESTING_ITERATIONS: 24n,
46754677
INITIAL_MINT_VESTING_ITERATION_BLOCKS: 4_383n,
46764678
deployBlockHeight: 3n,
4677-
lastVestingClaimBlock: null,
46784679
recipient: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM",
46794680
vestedClaimedAmount: 0n,
46804681
},

contrib/core-contract-tests/tests/sip-031/sip-031.test.ts

Lines changed: 194 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,26 @@ const contract = contracts.sip031;
88
const constants = contract.constants;
99
const indirectContract = contracts.sip031Indirect;
1010

11+
/**
12+
* "Mint" STX to the contract
13+
*/
1114
function mint(amount: number | bigint) {
1215
txOk(indirectContract.transferStx(amount, contract.identifier), accounts.wallet_4.address);
1316
}
1417

18+
// Helper function to mint the initial 200M STX to the contract
19+
function mintInitial() {
20+
// First make sure wallet_4 has enough STX to mint the initial amount
21+
txOk(indirectContract.transferStx(constants.INITIAL_MINT_AMOUNT / 2n, accounts.wallet_4.address), accounts.wallet_5.address);
22+
txOk(indirectContract.transferStx(constants.INITIAL_MINT_AMOUNT / 2n, accounts.wallet_4.address), accounts.wallet_6.address);
23+
// Mint the entire INITIAL_MINT_AMOUNT to the vesting contract
24+
mint(constants.INITIAL_MINT_AMOUNT);
25+
}
26+
27+
function months(n: number) {
28+
return n * Number(constants.INITIAL_MINT_VESTING_ITERATION_BLOCKS);
29+
}
30+
1531
test('initial recipient should be the deployer', () => {
1632
const value = rov(contract.getRecipient());
1733
expect(value).toBe(accounts.deployer.address);
@@ -49,27 +65,29 @@ test('errors if claiming as a non-recipient', () => {
4965
});
5066

5167
test('initial recipient can claim', () => {
52-
mint(100000000);
53-
const receipt = txOk(contract.claim(), accounts.deployer.address)
54-
expect(receipt.value).toBe(100000000n);
68+
mintInitial();
69+
const receipt = txOk(contract.claim(), accounts.deployer.address);
70+
expect(receipt.value).toBe(constants.INITIAL_MINT_IMMEDIATE_AMOUNT);
71+
5572
const [event] = filterEvents(receipt.events, CoreNodeEventType.StxTransferEvent);
56-
expect(event.data.amount).toBe(`${100000000n}`);
73+
expect(event.data.amount).toBe(`${constants.INITIAL_MINT_IMMEDIATE_AMOUNT}`);
5774
expect(event.data.recipient).toBe(accounts.deployer.address);
5875
expect(event.data.sender).toBe(contract.identifier);
5976
});
6077

78+
// Mint full initial amount first
6179
test('updated recipient can claim', () => {
62-
mint(100000000);
80+
mintInitial();
6381
const balance = rov(indirectContract.getBalance(contract.identifier));
64-
expect(balance).toBe(100000000n);
82+
expect(balance).toBe(constants.INITIAL_MINT_AMOUNT);
6583

66-
txOk(contract.updateRecipient(accounts.wallet_1.address), accounts.deployer.address)
67-
const receipt = txOk(contract.claim(), accounts.wallet_1.address)
68-
expect(receipt.value).toBe(100000000n);
84+
txOk(contract.updateRecipient(accounts.wallet_1.address), accounts.deployer.address);
85+
const receipt = txOk(contract.claim(), accounts.wallet_1.address);
86+
expect(receipt.value).toBe(constants.INITIAL_MINT_IMMEDIATE_AMOUNT);
6987

7088
expect(receipt.events.length).toBe(1);
7189
const [event] = filterEvents(receipt.events, CoreNodeEventType.StxTransferEvent);
72-
expect(event.data.amount).toBe(`${100000000n}`);
90+
expect(event.data.amount).toBe(`${constants.INITIAL_MINT_IMMEDIATE_AMOUNT}`);
7391
expect(event.data.recipient).toBe(accounts.wallet_1.address);
7492
expect(event.data.sender).toBe(contract.identifier);
7593
});
@@ -79,7 +97,6 @@ test('calculating vested amounts at a block height', () => {
7997

8098
const initialMintAmount = 200_000_000n * 1000000n; // 200,000,000 STX
8199
const immediateAmount = 100_000_000n * 1000000n; // 100,000,000 STX
82-
const vestingAmount = initialMintAmount - immediateAmount;
83100

84101
function expectedAmount(burnHeight: bigint) {
85102
const diff = burnHeight - deployBlockHeight;
@@ -95,30 +112,172 @@ test('calculating vested amounts at a block height', () => {
95112
const burnHeight = deployBlockHeight + month * 4383n;
96113
expect(rovOk(contract.calcVestedAmount(burnHeight))).toBe(expectedAmount(burnHeight));
97114
}
98-
expectAmount(1n);
99-
expectAmount(2n);
100-
expectAmount(3n);
101-
expectAmount(4n);
102-
expectAmount(5n);
103-
expectAmount(6n);
104-
expectAmount(7n);
105-
expectAmount(8n);
106-
expectAmount(9n);
107-
expectAmount(10n);
108-
expectAmount(11n);
109-
expectAmount(12n);
110-
expectAmount(13n);
111-
expectAmount(14n);
112-
expectAmount(15n);
113-
expectAmount(16n);
114-
expectAmount(17n);
115-
expectAmount(18n);
116-
expectAmount(19n);
117-
expectAmount(20n);
118-
expectAmount(21n);
119-
expectAmount(22n);
120-
expectAmount(23n);
121-
expectAmount(24n);
122115

116+
for (let i = 1n; i < 24n; i++) {
117+
expectAmount(i);
118+
}
119+
// At 24+ months, the entire vesting bucket should be unlocked
120+
expect(rovOk(contract.calcVestedAmount(deployBlockHeight + 24n * 4383n))).toBe(initialMintAmount);
123121
expect(rovOk(contract.calcVestedAmount(deployBlockHeight + 25n * 4383n))).toBe(initialMintAmount);
122+
});
123+
124+
// -----------------------------------------------------------------------------
125+
// Claim scenario 1:
126+
// - contract gets 100 STX after initial mint
127+
// - claim after 1 month
128+
// - recipient should get 100M + vested + 100 STX
129+
// -----------------------------------------------------------------------------
130+
test('claim scenario 1', () => {
131+
mintInitial();
132+
mint(100n * 1000000n);
133+
simnet.mineEmptyBlocks(months(1));
134+
const receipt = txOk(contract.claim(), accounts.deployer.address);
135+
const expected = constants.INITIAL_MINT_IMMEDIATE_AMOUNT + constants.INITIAL_MINT_VESTING_AMOUNT / 24n + 100n * 1000000n;
136+
expect(receipt.value).toBe(expected);
137+
138+
const [event] = filterEvents(receipt.events, CoreNodeEventType.StxTransferEvent);
139+
expect(event.data.amount).toBe(expected.toString());
140+
expect(event.data.recipient).toBe(accounts.deployer.address);
141+
expect(event.data.sender).toBe(contract.identifier);
142+
143+
// wait 4 months, also the contract gets 500 STX
144+
mint(500n * 1000000n);
145+
simnet.mineEmptyBlocks(months(4));
146+
const receipt2 = txOk(contract.claim(), accounts.deployer.address);
147+
const expected2 = constants.INITIAL_MINT_VESTING_AMOUNT / 24n * 4n + 500n * 1000000n;
148+
expect(receipt2.value).toBe(expected2);
149+
150+
const [event2] = filterEvents(receipt2.events, CoreNodeEventType.StxTransferEvent);
151+
expect(event2.data.amount).toBe(expected2.toString());
152+
expect(event2.data.recipient).toBe(accounts.deployer.address);
153+
154+
// wait until end of vesting (20 more months), with an extra 1500 STX
155+
// calc remainder of unvested, to deal with integer division
156+
const vestedAlready = constants.INITIAL_MINT_VESTING_AMOUNT / 24n * 5n;
157+
const unvested = constants.INITIAL_MINT_VESTING_AMOUNT - vestedAlready;
158+
const expected3 = unvested + 1500n * 1000000n;
159+
mint(1500n * 1000000n);
160+
simnet.mineEmptyBlocks(months(20));
161+
const receipt3 = txOk(contract.claim(), accounts.deployer.address);
162+
expect(receipt3.value).toBe(expected3);
163+
164+
const [event3] = filterEvents(receipt3.events, CoreNodeEventType.StxTransferEvent);
165+
expect(event3.data.amount).toBe(expected3.toString());
166+
expect(event3.data.recipient).toBe(accounts.deployer.address);
167+
168+
// wait 1 more month, with an extra 1000 STX
169+
// there is no more vested amount, so the extra 1000 STX should be claimed
170+
const expected4 = 1000n * 1000000n;
171+
mint(1000n * 1000000n);
172+
simnet.mineEmptyBlocks(months(1));
173+
const receipt4 = txOk(contract.claim(), accounts.deployer.address);
174+
expect(receipt4.value).toBe(expected4);
175+
176+
const [event4] = filterEvents(receipt4.events, CoreNodeEventType.StxTransferEvent);
177+
expect(event4.data.amount).toBe(expected4.toString());
178+
expect(event4.data.recipient).toBe(accounts.deployer.address);
179+
expect(rov(indirectContract.getBalance(contract.identifier))).toBe(0n);
180+
})
181+
182+
// -----------------------------------------------------------------------------
183+
// Edge-case: Claim when the contract holds *zero* balance should revert
184+
// -----------------------------------------------------------------------------
185+
test('claim with zero balance should error with ERR_NOTHING_TO_CLAIM', () => {
186+
// No minting has happened, contract balance == 0
187+
const receipt = txErr(contract.claim(), accounts.deployer.address);
188+
expect(receipt.value).toBe(constants.ERR_NOTHING_TO_CLAIM);
189+
});
190+
191+
// -----------------------------------------------------------------------------
192+
// Edge-case: Calling `claim` twice in the same block – second should fail
193+
// -----------------------------------------------------------------------------
194+
test('double claim in the same block reverts on second call', () => {
195+
mintInitial();
196+
197+
// First claim succeeds and drains the immediate bucket
198+
const first = txOk(contract.claim(), accounts.deployer.address);
199+
expect(first.value).toBe(constants.INITIAL_MINT_IMMEDIATE_AMOUNT);
200+
201+
// Second claim in the *same* block should have nothing left
202+
const second = txErr(contract.claim(), accounts.deployer.address);
203+
expect(second.value).toBe(constants.ERR_NOTHING_TO_CLAIM);
204+
});
205+
206+
// -----------------------------------------------------------------------------
207+
// Edge-case: Deposit exactly the amount that is still un-vested ("reserved")
208+
// -> nothing should be claimable.
209+
// -----------------------------------------------------------------------------
210+
test('deposit equal to reserved (unvested) amount is NOT claimable', () => {
211+
// `reserved` at deployment time equals the total unvested part (100 M STX)
212+
const reserved = constants.INITIAL_MINT_VESTING_AMOUNT;
213+
214+
// Deposit *only* the reserved amount, without the initial 200 M mint
215+
mint(reserved);
216+
217+
// No portion of this deposit is vested, so claim must revert
218+
const receipt = txErr(contract.claim(), accounts.deployer.address);
219+
expect(receipt.value).toBe(constants.ERR_NOTHING_TO_CLAIM);
220+
});
221+
222+
// -----------------------------------------------------------------------------
223+
// Edge-case: Integer-division rounding – last vesting iteration flushes the
224+
// remainder so that total withdrawn == 200 M STX.
225+
// -----------------------------------------------------------------------------
226+
test('final vesting iteration flushes rounding remainder', () => {
227+
mintInitial();
228+
229+
// Advance 23 of 24 months
230+
simnet.mineEmptyBlocks(months(23));
231+
232+
// First claim: immediate bucket + 23/24 of vesting bucket
233+
const perIteration = constants.INITIAL_MINT_VESTING_AMOUNT / constants.INITIAL_MINT_VESTING_ITERATIONS;
234+
const expectedFirst =
235+
constants.INITIAL_MINT_IMMEDIATE_AMOUNT + perIteration * 23n;
236+
const first = txOk(contract.claim(), accounts.deployer.address);
237+
expect(first.value).toBe(expectedFirst);
238+
239+
// Advance the final month
240+
simnet.mineEmptyBlocks(months(1));
241+
242+
// Second claim: should transfer *exactly* the remainder
243+
const expectedSecond = constants.INITIAL_MINT_AMOUNT - expectedFirst;
244+
expect(expectedSecond + expectedFirst).toBe(constants.INITIAL_MINT_AMOUNT);
245+
const second = txOk(contract.claim(), accounts.deployer.address);
246+
expect(second.value).toBe(expectedSecond);
247+
248+
// Contract should now hold zero STX (no extras were ever deposited)
249+
expect(rov(indirectContract.getBalance(contract.identifier))).toBe(0n);
250+
});
251+
252+
// -----------------------------------------------------------------------------
253+
// Edge-case #5: Recipient change between deposits – new recipient should receive
254+
// the next vested tranche *plus* freshly deposited STX.
255+
// -----------------------------------------------------------------------------
256+
test('new recipient claims vested tranche plus extra deposit', () => {
257+
mintInitial();
258+
259+
// Deployer immediately claims the instantaneous 100 M
260+
txOk(contract.claim(), accounts.deployer.address);
261+
262+
// Mine one vesting iteration (1 month)
263+
simnet.mineEmptyBlocks(months(1));
264+
265+
// Update recipient to wallet_1
266+
txOk(contract.updateRecipient(accounts.wallet_1.address), accounts.deployer.address);
267+
268+
// External party deposits 500 STX
269+
const extraDeposit = 500n * 1000000n;
270+
mint(extraDeposit);
271+
272+
// Wallet_1 claims: should receive 1/24 of vesting bucket + 500 STX
273+
const perIteration = constants.INITIAL_MINT_VESTING_AMOUNT / constants.INITIAL_MINT_VESTING_ITERATIONS;
274+
const expected = perIteration + extraDeposit;
275+
const receipt = txOk(contract.claim(), accounts.wallet_1.address);
276+
expect(receipt.value).toBe(expected);
277+
278+
// Validate transfer event
279+
const [evt] = filterEvents(receipt.events, CoreNodeEventType.StxTransferEvent);
280+
expect(evt.data.amount).toBe(expected.toString());
281+
expect(evt.data.recipient).toBe(accounts.wallet_1.address);
282+
expect(evt.data.sender).toBe(contract.identifier);
124283
});

0 commit comments

Comments
 (0)