Skip to content

Commit d5e9120

Browse files
GeObtsclaude
andcommitted
test: full happy-path lifecycle for 3-participant tanda
End-to-end test walking CREATE → OPEN → ACTIVE → COMPLETED with exact-value assertions at every step: - join charges contribution + 10% insurance premium (110 USDC/cycle) - per-cycle 95/2/3 fee split (285 recipient / 6 platform / 9 organizer) - insurance accrual (10/cycle) and refund-at-completion (30 total each) - Pass NFT minted on join, Receipt NFT on each payout, Completion NFT batch at end - dual-role accounting verified: creator who is also a payout recipient - all withdrawals drain contract to zero; accounting snapshot balances Plus a state-transitions test asserting the three state guards (triggerPayout in OPEN, join in ACTIVE, triggerPayout in COMPLETED) each revert with the exact WrongTandaState selector. Test orchestration split into phase helpers to stay under the stack-too-deep limit; running expectations held in storage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1a760b4 commit d5e9120

1 file changed

Lines changed: 357 additions & 0 deletions

File tree

test/TandaLifecycle.t.sol

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity 0.8.20;
3+
4+
import {MitandaTestBase} from "./helpers/MitandaTestBase.sol";
5+
import {Tanda} from "../src/Tanda.sol";
6+
import {WrongTandaState} from "../src/MitandaErrors.sol";
7+
8+
/// @title TandaLifecycleTest
9+
/// @notice End-to-end happy-path test for a 3-participant tanda.
10+
/// Walks CREATE → OPEN → ACTIVE → COMPLETED with assertions
11+
/// on state, balances, fees, insurance, and NFT mints at every
12+
/// step. Phases are split into helpers to keep stack pressure
13+
/// low (the contract is heavily-named-locals-rich and would
14+
/// otherwise hit Solidity's stack-too-deep limit).
15+
///
16+
/// Hand-verified math (100 USDC contribution × 10% insurance):
17+
/// - per-cycle pot = 100 × 3 = 300 USDC
18+
/// - platform fee (2%) = 6 USDC
19+
/// - organizer fee (3%) = 9 USDC
20+
/// - recipient share (95%) = 285 USDC
21+
/// - per participant charge = 110 USDC per cycle
22+
/// - per participant insurance total = 3 × 10 = 30 USDC
23+
/// - no defaulters → slashedPool = 0
24+
contract TandaLifecycleTest is MitandaTestBase {
25+
// ─── Convenience constants (6-decimal USDC) ────────────────────────
26+
uint256 internal constant USDC = 10 ** 6;
27+
uint256 internal constant CONTRIBUTION = 100 * USDC;
28+
uint256 internal constant PREMIUM_PER_CYCLE = 10 * USDC;
29+
uint256 internal constant CHARGE_PER_CYCLE = 110 * USDC;
30+
uint256 internal constant CYCLE_POT = 300 * USDC;
31+
uint256 internal constant CYCLE_PLATFORM = 6 * USDC;
32+
uint256 internal constant CYCLE_ORGANIZER = 9 * USDC;
33+
uint256 internal constant CYCLE_RECIPIENT = 285 * USDC;
34+
uint256 internal constant INSURANCE_PER_PARTICIPANT = 30 * USDC;
35+
36+
// ─── Running expected credits (storage, not stack) ─────────────────
37+
uint256 internal expAlice;
38+
uint256 internal expBob;
39+
uint256 internal expCarol;
40+
uint256 internal expTreasury;
41+
42+
// ─── tandaId remembered across helpers ─────────────────────────────
43+
uint256 internal currentTandaId;
44+
45+
// ─── Captured recipients per cycle ─────────────────────────────────
46+
address internal r1;
47+
address internal r2;
48+
address internal r3;
49+
50+
// ────────────────────────────────────────────────────────────────
51+
// Low-level helpers
52+
// ────────────────────────────────────────────────────────────────
53+
54+
function _makePayment(address tandaAddr, address user, uint256 cycles) internal {
55+
uint256 charge = cycles * CHARGE_PER_CYCLE;
56+
_fundAndApprove(user, charge, tandaAddr);
57+
vm.prank(user);
58+
Tanda(tandaAddr).makePayment(cycles);
59+
}
60+
61+
function _bumpRecipient(address recipient, uint256 amount) internal {
62+
if (recipient == alice) expAlice += amount;
63+
else if (recipient == bob) expBob += amount;
64+
else if (recipient == carol) expCarol += amount;
65+
else revert("unknown recipient");
66+
}
67+
68+
function _assertCredits(Tanda t) internal {
69+
assertEq(t.pendingWithdrawals(alice), expAlice, "alice credit");
70+
assertEq(t.pendingWithdrawals(bob), expBob, "bob credit");
71+
assertEq(t.pendingWithdrawals(carol), expCarol, "carol credit");
72+
assertEq(t.pendingWithdrawals(TREASURY), expTreasury, "treasury credit");
73+
}
74+
75+
// ────────────────────────────────────────────────────────────────
76+
// Phase helpers
77+
// ────────────────────────────────────────────────────────────────
78+
79+
function _assertJustCreated(Tanda t) internal {
80+
assertEq(uint8(t.state()), uint8(Tanda.TandaState.OPEN), "state after create");
81+
assertEq(t.participantCount(), 3, "participantCount");
82+
assertEq(t.contributionAmount(), CONTRIBUTION, "contributionAmount");
83+
assertEq(t.payoutInterval(), DEFAULT_PAYOUT_INTERVAL, "payoutInterval");
84+
assertEq(t.gracePeriod(), DEFAULT_GRACE_PERIOD, "gracePeriod");
85+
assertEq(t.creator(), alice, "creator");
86+
assertEq(t.sponsoredCollectionId(), 1, "sponsoredCollectionId");
87+
assertEq(t.activeParticipantCount(), 0, "activeParticipantCount after create");
88+
}
89+
90+
function _fillAndCaptureRecipients(address tandaAddr) internal {
91+
address[] memory users = new address[](3);
92+
users[0] = alice;
93+
users[1] = bob;
94+
users[2] = carol;
95+
_fillAndStart(tandaAddr, users, uint256(keccak256("seed_lifecycle")));
96+
97+
uint256[] memory order = Tanda(tandaAddr).getPayoutOrder();
98+
address[3] memory pArr = [alice, bob, carol];
99+
r1 = pArr[order[0]];
100+
r2 = pArr[order[1]];
101+
r3 = pArr[order[2]];
102+
assertTrue(r1 != r2 && r2 != r3 && r1 != r3, "payout recipients must be distinct");
103+
emit log_named_address("cycle 1 recipient", r1);
104+
emit log_named_address("cycle 2 recipient", r2);
105+
emit log_named_address("cycle 3 recipient", r3);
106+
}
107+
108+
function _assertJustFilled(address tandaAddr) internal {
109+
Tanda t = Tanda(tandaAddr);
110+
uint256 tandaId = currentTandaId;
111+
112+
assertEq(uint8(t.state()), uint8(Tanda.TandaState.ACTIVE), "state after fill");
113+
assertTrue(t.payoutOrderAssigned(), "payoutOrderAssigned");
114+
assertEq(t.activeParticipantCount(), 3, "activeParticipantCount after fill");
115+
assertEq(t.currentCycle(), 1, "currentCycle after fill");
116+
117+
// Pass NFTs
118+
assertTrue(passNFT.hasPass(alice, tandaId), "alice pass");
119+
assertTrue(passNFT.hasPass(bob, tandaId), "bob pass");
120+
assertTrue(passNFT.hasPass(carol, tandaId), "carol pass");
121+
assertEq(passNFT.balanceOf(alice), 1, "alice pass balanceOf");
122+
assertEq(passNFT.balanceOf(bob), 1, "bob pass balanceOf");
123+
assertEq(passNFT.balanceOf(carol), 1, "carol pass balanceOf");
124+
125+
// paidUntilCycle + insurance
126+
assertEq(t.getParticipant(0).paidUntilCycle, 1, "alice paidUntilCycle after join");
127+
assertEq(t.getParticipant(1).paidUntilCycle, 1, "bob paidUntilCycle after join");
128+
assertEq(t.getParticipant(2).paidUntilCycle, 1, "carol paidUntilCycle after join");
129+
assertEq(t.insuranceBalance(alice), PREMIUM_PER_CYCLE, "alice insurance after join");
130+
assertEq(t.insuranceBalance(bob), PREMIUM_PER_CYCLE, "bob insurance after join");
131+
assertEq(t.insuranceBalance(carol), PREMIUM_PER_CYCLE, "carol insurance after join");
132+
133+
// Contract balance after 3 joins
134+
assertEq(usdc.balanceOf(tandaAddr), 3 * CHARGE_PER_CYCLE, "balance after fill");
135+
_assertSnapshotAfterFill(t);
136+
}
137+
138+
function _assertSnapshotAfterFill(Tanda t) internal {
139+
(
140+
uint256 snapBalance,
141+
uint256 snapOutstanding,
142+
uint256 snapPendingCredits,
143+
uint256 snapSlashPool,
144+
uint256 snapInsuranceReserve
145+
) = t.getAccountingSnapshot();
146+
assertEq(snapBalance, 3 * CHARGE_PER_CYCLE, "snap balance after fill");
147+
assertEq(snapPendingCredits, 0, "snap pendingCredits after fill");
148+
assertEq(snapSlashPool, 0, "snap slashPool after fill");
149+
assertEq(snapInsuranceReserve, 3 * PREMIUM_PER_CYCLE, "snap insurance after fill");
150+
assertEq(snapOutstanding, 3 * PREMIUM_PER_CYCLE, "snap outstanding after fill");
151+
assertEq(snapBalance - snapOutstanding, CYCLE_POT, "residual after fill");
152+
}
153+
154+
/// @dev Per-cycle: optional makePayments, warp, trigger, assert.
155+
/// `cycleIndex` is the cycle number being settled (1, 2, 3).
156+
/// `needsPayments` is false only for cycle 1 (covered by join).
157+
function _runCycle(address tandaAddr, uint256 cycleIndex, address recipient, bool needsPayments) internal {
158+
Tanda t = Tanda(tandaAddr);
159+
160+
if (needsPayments) {
161+
_makePayment(tandaAddr, alice, 1);
162+
_makePayment(tandaAddr, bob, 1);
163+
_makePayment(tandaAddr, carol, 1);
164+
165+
assertEq(t.getParticipant(0).paidUntilCycle, cycleIndex, "alice paidUntilCycle pre-cycle");
166+
assertEq(t.getParticipant(1).paidUntilCycle, cycleIndex, "bob paidUntilCycle pre-cycle");
167+
assertEq(t.getParticipant(2).paidUntilCycle, cycleIndex, "carol paidUntilCycle pre-cycle");
168+
assertEq(t.insuranceBalance(alice), cycleIndex * PREMIUM_PER_CYCLE, "alice insurance pre-cycle");
169+
assertEq(t.insuranceBalance(bob), cycleIndex * PREMIUM_PER_CYCLE, "bob insurance pre-cycle");
170+
assertEq(t.insuranceBalance(carol), cycleIndex * PREMIUM_PER_CYCLE, "carol insurance pre-cycle");
171+
// Total contract balance = (cycleIndex × 3 × 110).
172+
assertEq(usdc.balanceOf(tandaAddr), cycleIndex * 3 * CHARGE_PER_CYCLE, "balance pre-trigger");
173+
}
174+
175+
_warpToNextCycle(tandaAddr);
176+
t.triggerPayout();
177+
178+
// Credit accumulation
179+
_bumpRecipient(recipient, CYCLE_RECIPIENT);
180+
expAlice += CYCLE_ORGANIZER; // alice is creator → organizer fee every cycle
181+
expTreasury += CYCLE_PLATFORM;
182+
183+
// For non-final cycles: assert state + credits in-place.
184+
// For the final cycle: defer credit assertion to caller, because
185+
// `triggerPayout` here also runs `_completeTanda` which credits
186+
// insurance refunds — the caller adds those to expectations and
187+
// then asserts.
188+
if (cycleIndex < 3) {
189+
assertEq(t.currentCycle(), cycleIndex + 1, "currentCycle after cycle");
190+
assertEq(uint8(t.state()), uint8(Tanda.TandaState.ACTIVE), "state after cycle");
191+
_assertCredits(t);
192+
}
193+
}
194+
195+
function _assertCompletion(address tandaAddr) internal {
196+
Tanda t = Tanda(tandaAddr);
197+
uint256 tandaId = currentTandaId;
198+
199+
assertEq(uint8(t.state()), uint8(Tanda.TandaState.COMPLETED), "state after cycle 3");
200+
assertEq(t.currentCycle(), 4, "currentCycle after cycle 3");
201+
assertEq(t.slashedPool(), 0, "slashedPool at completion");
202+
assertEq(t.totalInsuranceReserve(), 0, "totalInsuranceReserve after refunds");
203+
assertEq(t.insuranceBalance(alice), 0, "alice insurance after refund");
204+
assertEq(t.insuranceBalance(bob), 0, "bob insurance after refund");
205+
assertEq(t.insuranceBalance(carol), 0, "carol insurance after refund");
206+
207+
// Completion NFTs
208+
assertTrue(completionNFT.hasCompletion(alice, tandaId), "alice completion");
209+
assertTrue(completionNFT.hasCompletion(bob, tandaId), "bob completion");
210+
assertTrue(completionNFT.hasCompletion(carol, tandaId), "carol completion");
211+
assertEq(completionNFT.reputationScore(alice), 1, "alice reputation");
212+
assertEq(completionNFT.reputationScore(bob), 1, "bob reputation");
213+
assertEq(completionNFT.reputationScore(carol), 1, "carol reputation");
214+
215+
// Receipt NFTs: 3 total across r1, r2, r3 (some may overlap if recipient repeats — they don't here)
216+
uint256 receiptCount = receiptNFT.balanceOf(alice) + receiptNFT.balanceOf(bob) + receiptNFT.balanceOf(carol);
217+
assertEq(receiptCount, 3, "total receipts minted");
218+
219+
// Contract balance equals sum of unclaimed credits
220+
assertEq(usdc.balanceOf(tandaAddr), expAlice + expBob + expCarol + expTreasury, "balance pre-withdraw");
221+
}
222+
223+
function _withdrawAllAndAssert(address tandaAddr) internal {
224+
Tanda t = Tanda(tandaAddr);
225+
226+
uint256 aliceBefore = usdc.balanceOf(alice);
227+
uint256 bobBefore = usdc.balanceOf(bob);
228+
uint256 carolBefore = usdc.balanceOf(carol);
229+
uint256 treasuryBefore = usdc.balanceOf(TREASURY);
230+
231+
vm.prank(alice);
232+
t.withdraw();
233+
vm.prank(bob);
234+
t.withdraw();
235+
vm.prank(carol);
236+
t.withdraw();
237+
vm.prank(TREASURY);
238+
t.withdraw();
239+
240+
assertEq(usdc.balanceOf(alice) - aliceBefore, expAlice, "alice withdraw amount");
241+
assertEq(usdc.balanceOf(bob) - bobBefore, expBob, "bob withdraw amount");
242+
assertEq(usdc.balanceOf(carol) - carolBefore, expCarol, "carol withdraw amount");
243+
assertEq(usdc.balanceOf(TREASURY) - treasuryBefore, expTreasury, "treasury withdraw amount");
244+
245+
assertEq(t.pendingWithdrawals(alice), 0, "alice pending after withdraw");
246+
assertEq(t.pendingWithdrawals(bob), 0, "bob pending after withdraw");
247+
assertEq(t.pendingWithdrawals(carol), 0, "carol pending after withdraw");
248+
assertEq(t.pendingWithdrawals(TREASURY), 0, "treasury pending after withdraw");
249+
250+
assertEq(usdc.balanceOf(tandaAddr), 0, "contract drained");
251+
assertEq(t.totalPendingCredits(), 0, "totalPendingCredits final");
252+
253+
(uint256 snapBalance, uint256 snapOutstanding,,,) = t.getAccountingSnapshot();
254+
assertEq(snapBalance, 0, "snap balance final");
255+
assertEq(snapOutstanding, 0, "snap outstanding final");
256+
}
257+
258+
// ────────────────────────────────────────────────────────────────
259+
// Main test
260+
// ────────────────────────────────────────────────────────────────
261+
262+
function test_fullLifecycle_threeParticipants() public {
263+
// a. Create
264+
(address tandaAddr, uint256 tandaId) = _createDefaultTanda(alice);
265+
currentTandaId = tandaId;
266+
_assertJustCreated(Tanda(tandaAddr));
267+
268+
// b. Fill + capture order
269+
_fillAndCaptureRecipients(tandaAddr);
270+
_assertJustFilled(tandaAddr);
271+
272+
// d. Cycle 1 (no makePayment — covered by join)
273+
_runCycle(tandaAddr, 1, r1, false);
274+
assertEq(receiptNFT.balanceOf(r1), 1, "r1 receipt after cycle 1");
275+
276+
// f. Cycle 2
277+
_runCycle(tandaAddr, 2, r2, true);
278+
279+
// h. Cycle 3 (completes tanda)
280+
_runCycle(tandaAddr, 3, r3, true);
281+
282+
// Insurance refunds added at completion
283+
expAlice += INSURANCE_PER_PARTICIPANT;
284+
expBob += INSURANCE_PER_PARTICIPANT;
285+
expCarol += INSURANCE_PER_PARTICIPANT;
286+
_assertCredits(Tanda(tandaAddr));
287+
288+
// Verify completion-level invariants
289+
_assertCompletion(tandaAddr);
290+
291+
// i. Withdrawals
292+
_withdrawAllAndAssert(tandaAddr);
293+
}
294+
295+
// ────────────────────────────────────────────────────────────────
296+
// State transitions
297+
// ────────────────────────────────────────────────────────────────
298+
299+
function test_lifecycle_stateTransitions() public {
300+
(address tandaAddr,) = _createDefaultTanda(alice);
301+
Tanda t = Tanda(tandaAddr);
302+
303+
// OPEN: triggerPayout reverts WrongTandaState(ACTIVE, OPEN)
304+
vm.expectRevert(
305+
abi.encodeWithSelector(
306+
WrongTandaState.selector, uint8(Tanda.TandaState.ACTIVE), uint8(Tanda.TandaState.OPEN)
307+
)
308+
);
309+
t.triggerPayout();
310+
311+
// Fill to ACTIVE
312+
address[] memory users = new address[](3);
313+
users[0] = alice;
314+
users[1] = bob;
315+
users[2] = carol;
316+
_fillAndStart(tandaAddr, users, uint256(keccak256("seed_state")));
317+
assertEq(uint8(t.state()), uint8(Tanda.TandaState.ACTIVE), "state after fill");
318+
319+
// ACTIVE: dave's join reverts WrongTandaState(OPEN, ACTIVE)
320+
vm.expectRevert(
321+
abi.encodeWithSelector(
322+
WrongTandaState.selector, uint8(Tanda.TandaState.OPEN), uint8(Tanda.TandaState.ACTIVE)
323+
)
324+
);
325+
vm.prank(dave);
326+
t.join();
327+
328+
// Run to COMPLETED
329+
_runRemainingCyclesToCompletion(tandaAddr);
330+
assertEq(uint8(t.state()), uint8(Tanda.TandaState.COMPLETED), "state after run");
331+
332+
// COMPLETED: triggerPayout reverts WrongTandaState(ACTIVE, COMPLETED)
333+
vm.expectRevert(
334+
abi.encodeWithSelector(
335+
WrongTandaState.selector, uint8(Tanda.TandaState.ACTIVE), uint8(Tanda.TandaState.COMPLETED)
336+
)
337+
);
338+
t.triggerPayout();
339+
}
340+
341+
function _runRemainingCyclesToCompletion(address tandaAddr) internal {
342+
Tanda t = Tanda(tandaAddr);
343+
344+
// Cycle 1: no makePayment needed
345+
_warpToNextCycle(tandaAddr);
346+
t.triggerPayout();
347+
348+
// Cycles 2..N until COMPLETED
349+
while (uint8(t.state()) == uint8(Tanda.TandaState.ACTIVE)) {
350+
_makePayment(tandaAddr, alice, 1);
351+
_makePayment(tandaAddr, bob, 1);
352+
_makePayment(tandaAddr, carol, 1);
353+
_warpToNextCycle(tandaAddr);
354+
t.triggerPayout();
355+
}
356+
}
357+
}

0 commit comments

Comments
 (0)