Skip to content

Commit 1a760b4

Browse files
committed
test: add test infrastructure — mocks, base helpers, smoke tests
- MockERC20 with configurable decimals + open mint for funding - MitandaTestBase: deploys full system in setUp via predict-then-deploy (vm.computeCreateAddress resolves the Manager<->NFT circular dependency, mirroring the CREATE2 pattern the production deploy will use) - Uses Chainlink's official VRFCoordinatorV2_5Mock with deterministic seed delivery via fulfillRandomWordsWithOverride - Helpers: _fundAndApprove, _createDefaultTanda, _joinTanda, _fulfillVRF, _captureAndFulfillVRF, _fillAndStart, _warpToNextCycle - Smoke tests verify full system deploys and a tanda reaches ACTIVE with payout order assigned
1 parent 823fe80 commit 1a760b4

3 files changed

Lines changed: 275 additions & 0 deletions

File tree

test/Setup.t.sol

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
7+
/// @notice Smoke tests — prove the full system wires up correctly and
8+
/// a tanda reaches ACTIVE with payout order assigned via the
9+
/// standard `_fillAndStart` helper.
10+
contract SetupTest is MitandaTestBase {
11+
function test_systemDeploys() public {
12+
assertTrue(address(manager) != address(0));
13+
assertTrue(manager.isAllowedToken(address(usdc)));
14+
(uint256 activeId,) = manager.getActiveCollection();
15+
assertEq(activeId, 1);
16+
}
17+
18+
function test_fillAndStartFlow() public {
19+
(address tandaAddr,) = _createDefaultTanda(alice);
20+
address[] memory users = new address[](3);
21+
users[0] = alice;
22+
users[1] = bob;
23+
users[2] = carol;
24+
_fillAndStart(tandaAddr, users, uint256(keccak256("seed1")));
25+
26+
Tanda t = Tanda(tandaAddr);
27+
assertEq(uint8(t.state()), uint8(Tanda.TandaState.ACTIVE));
28+
assertTrue(t.payoutOrderAssigned());
29+
assertEq(t.getPayoutOrder().length, 3);
30+
}
31+
}

test/helpers/MitandaTestBase.sol

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity 0.8.20;
3+
4+
import {Test, Vm} from "forge-std/Test.sol";
5+
6+
import {VRFCoordinatorV2_5Mock} from "@chainlink/contracts/src/v0.8/vrf/mocks/VRFCoordinatorV2_5Mock.sol";
7+
8+
import {MockERC20} from "../mocks/MockERC20.sol";
9+
10+
import {TandaManager} from "../../src/TandaManager.sol";
11+
import {Tanda} from "../../src/Tanda.sol";
12+
import {ITanda} from "../../src/interfaces/ITanda.sol";
13+
import {MitandaPassNFT} from "../../src/MitandaPassNFT.sol";
14+
import {MitandaReceiptNFT} from "../../src/MitandaReceiptNFT.sol";
15+
import {MitandaCompletionNFT} from "../../src/MitandaCompletionNFT.sol";
16+
17+
/// @title MitandaTestBase
18+
/// @notice Shared setUp + helpers for every Mi Tanda test. Deploys the
19+
/// full system (mock VRF + USDC, Tanda implementation, three
20+
/// NFT singletons, Manager) and wires it together so individual
21+
/// tests can focus on behavior.
22+
/// @dev Circular dependency between Manager and the NFTs is resolved
23+
/// by predicting Manager's CREATE1 address via
24+
/// `vm.computeCreateAddress(deployer, nonce + 3)` before
25+
/// deploying the NFTs. Production deploy script should mirror
26+
/// this — recommended pattern is CREATE2 with a deterministic
27+
/// salt for cross-chain identical Manager addresses.
28+
abstract contract MitandaTestBase is Test {
29+
// ─── Deployed system ───────────────────────────────────────────────
30+
TandaManager internal manager;
31+
Tanda internal tandaImpl;
32+
MitandaPassNFT internal passNFT;
33+
MitandaReceiptNFT internal receiptNFT;
34+
MitandaCompletionNFT internal completionNFT;
35+
MockERC20 internal usdc;
36+
VRFCoordinatorV2_5Mock internal vrfCoordinator;
37+
38+
// ─── Test config ───────────────────────────────────────────────────
39+
uint256 internal subId;
40+
bytes32 internal constant GAS_LANE = keccak256("test-gas-lane");
41+
uint32 internal constant CALLBACK_GAS_LIMIT = 500_000;
42+
address internal constant TREASURY = address(0xBEEF);
43+
address internal owner = address(this);
44+
45+
// ─── Default tanda parameters ──────────────────────────────────────
46+
uint256 internal constant DEFAULT_CONTRIBUTION = 100 * 10 ** 6; // 100 USDC
47+
uint256 internal constant DEFAULT_PAYOUT_INTERVAL = 1 days;
48+
uint16 internal constant DEFAULT_PARTICIPANT_COUNT = 3;
49+
uint256 internal constant DEFAULT_GRACE_PERIOD = 1 days;
50+
51+
// ─── Default collection ────────────────────────────────────────────
52+
address internal constant SPONSOR_ROYALTY_RECEIVER = address(0xCAFE);
53+
uint96 internal constant SPONSOR_ROYALTY_BPS = 500; // 5%
54+
55+
// ─── Test users ────────────────────────────────────────────────────
56+
address internal alice = makeAddr("alice");
57+
address internal bob = makeAddr("bob");
58+
address internal carol = makeAddr("carol");
59+
address internal dave = makeAddr("dave");
60+
address internal eve = makeAddr("eve");
61+
62+
function setUp() public virtual {
63+
// 1. VRF coordinator mock — args are placeholder fees for tests.
64+
vrfCoordinator = new VRFCoordinatorV2_5Mock(
65+
0.1 ether, // base fee
66+
1 gwei, // gas price
67+
4e15 // wei per unit LINK (~0.004 ETH per LINK)
68+
);
69+
70+
// 2. Create + fund a VRF subscription. Sub owner = address(this).
71+
subId = vrfCoordinator.createSubscription();
72+
vrfCoordinator.fundSubscription(subId, 1000 ether); // LINK balance
73+
vm.deal(address(this), 100 ether);
74+
vrfCoordinator.fundSubscriptionWithNative{value: 50 ether}(subId);
75+
76+
// 3. Mock USDC (6 decimals — matches mainnet USDC).
77+
usdc = new MockERC20("USD Coin", "USDC", 6);
78+
79+
// 4. Tanda implementation (constructor disables initializers).
80+
tandaImpl = new Tanda();
81+
82+
// 5. Predict Manager's CREATE1 address. The next 4 deploys are
83+
// passNFT, receiptNFT, completionNFT, Manager (in order),
84+
// so Manager will be at nonce + 3 relative to here.
85+
uint256 nonceNow = vm.getNonce(address(this));
86+
address predictedManager = vm.computeCreateAddress(address(this), nonceNow + 3);
87+
88+
// 6. NFTs reference the predicted Manager address.
89+
passNFT = new MitandaPassNFT(predictedManager, owner);
90+
receiptNFT = new MitandaReceiptNFT(predictedManager, owner, "ipfs://default-fallback");
91+
completionNFT = new MitandaCompletionNFT(predictedManager, owner);
92+
93+
// 7. Deploy Manager. Its actual address MUST match the prediction
94+
// or every NFT call from any tanda will fail forever.
95+
manager = new TandaManager(
96+
address(tandaImpl),
97+
address(vrfCoordinator),
98+
subId,
99+
GAS_LANE,
100+
CALLBACK_GAS_LIMIT,
101+
TREASURY,
102+
address(passNFT),
103+
address(receiptNFT),
104+
address(completionNFT)
105+
);
106+
require(address(manager) == predictedManager, "MitandaTestBase: Manager address prediction failed");
107+
108+
// 8. Register Manager as a VRF consumer on the subscription.
109+
vrfCoordinator.addConsumer(subId, address(manager));
110+
111+
// 9. Allowlist the mock USDC for tanda creation.
112+
manager.allowlistToken(address(usdc));
113+
114+
// 10. Register and activate a default sponsored collection so
115+
// receipts mint with non-fallback art by default.
116+
uint256 collId = manager.registerCollection(
117+
"Mi Tanda Test Collection", "ipfs://test-collection", SPONSOR_ROYALTY_RECEIVER, SPONSOR_ROYALTY_BPS
118+
);
119+
manager.setActiveCollection(collId);
120+
}
121+
122+
// ─────────────────────────────────────────────────────────────────────
123+
// Helpers
124+
// ─────────────────────────────────────────────────────────────────────
125+
126+
/// @notice Mint `amount` USDC to `user` and prank-approve `spender`.
127+
function _fundAndApprove(address user, uint256 amount, address spender) internal {
128+
usdc.mint(user, amount);
129+
vm.prank(user);
130+
usdc.approve(spender, amount);
131+
}
132+
133+
/// @notice Create a default PUBLIC tanda from `creator`.
134+
/// @return tandaAddr Address of the new Tanda clone.
135+
/// @return tandaId ID assigned by the Manager.
136+
function _createDefaultTanda(address creator) internal returns (address tandaAddr, uint256 tandaId) {
137+
vm.prank(creator);
138+
tandaId = manager.createTanda(
139+
address(usdc),
140+
DEFAULT_CONTRIBUTION,
141+
DEFAULT_PAYOUT_INTERVAL,
142+
DEFAULT_PARTICIPANT_COUNT,
143+
DEFAULT_GRACE_PERIOD,
144+
0, // scheduledStart = 0 → auto-start when full
145+
ITanda.TandaPrivacy.PUBLIC
146+
);
147+
tandaAddr = manager.tandaIdToAddress(tandaId);
148+
}
149+
150+
/// @notice Fund, approve, and prank `user` to join a PUBLIC tanda.
151+
/// Handles contribution + insurance premium automatically.
152+
function _joinTanda(address tandaAddr, address user) internal {
153+
Tanda t = Tanda(tandaAddr);
154+
uint256 c = t.contributionAmount();
155+
uint256 premium = (c * t.INSURANCE_BPS()) / t.BPS_DENOMINATOR();
156+
uint256 charge = c + premium;
157+
158+
_fundAndApprove(user, charge, tandaAddr);
159+
160+
vm.prank(user);
161+
t.join();
162+
}
163+
164+
/// @notice Deliver a VRF callback to Manager with a deterministic seed.
165+
/// @param requestId The Manager's VRF request ID (capture from the
166+
/// `RandomnessRequested(tandaId, requestId)` log).
167+
/// @param seed The single random word to forward.
168+
function _fulfillVRF(uint256 requestId, uint256 seed) internal {
169+
uint256[] memory words = new uint256[](1);
170+
words[0] = seed;
171+
vrfCoordinator.fulfillRandomWordsWithOverride(requestId, address(manager), words);
172+
}
173+
174+
/// @notice Convenience: capture the most recent `RandomnessRequested`
175+
/// emitted by Manager and immediately fulfill it. Requires
176+
/// the test to have called `vm.recordLogs()` before the
177+
/// action that triggers the VRF request.
178+
function _captureAndFulfillVRF(uint256 seed) internal returns (uint256 requestId) {
179+
Vm.Log[] memory logs = vm.getRecordedLogs();
180+
bytes32 sig = keccak256("RandomnessRequested(uint256,uint256)");
181+
for (uint256 i = logs.length; i > 0; i--) {
182+
Vm.Log memory log = logs[i - 1];
183+
if (log.emitter == address(manager) && log.topics[0] == sig) {
184+
// event RandomnessRequested(uint256 indexed tandaId, uint256 indexed requestId)
185+
requestId = uint256(log.topics[2]);
186+
_fulfillVRF(requestId, seed);
187+
return requestId;
188+
}
189+
}
190+
revert("MitandaTestBase: no RandomnessRequested log");
191+
}
192+
193+
/// @dev Fills a tanda to capacity (joining all `users`), which triggers
194+
/// _startTanda → VRF request on the final join, then fulfills the
195+
/// VRF request with the given seed. Handles vm.recordLogs()
196+
/// internally so individual tests can't forget it. The `users`
197+
/// array length must equal the tanda's participantCount.
198+
/// Returns the requestId that was fulfilled.
199+
function _fillAndStart(address tandaAddr, address[] memory users, uint256 seed)
200+
internal
201+
returns (uint256 requestId)
202+
{
203+
uint256 count = users.length;
204+
// Join all but the last without log recording.
205+
for (uint256 i = 0; i < count - 1; i++) {
206+
_joinTanda(tandaAddr, users[i]);
207+
}
208+
// Record logs around the final join (which triggers the VRF request).
209+
vm.recordLogs();
210+
_joinTanda(tandaAddr, users[count - 1]);
211+
requestId = _captureAndFulfillVRF(seed);
212+
}
213+
214+
/// @notice Warp `block.timestamp` to the moment the current cycle's
215+
/// payout becomes eligible. Tanda must already be ACTIVE.
216+
function _warpToNextCycle(address tandaAddr) internal {
217+
Tanda t = Tanda(tandaAddr);
218+
uint256 payoutTime = t.startTimestamp() + t.currentCycle() * t.payoutInterval();
219+
vm.warp(payoutTime);
220+
}
221+
}

test/mocks/MockERC20.sol

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity 0.8.20;
3+
4+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5+
6+
/// @notice Minimal ERC-20 with configurable decimals and open mint for
7+
/// test funding. NOT for production use.
8+
contract MockERC20 is ERC20 {
9+
uint8 private _decimals;
10+
11+
constructor(string memory name, string memory symbol, uint8 decimals_) ERC20(name, symbol) {
12+
_decimals = decimals_;
13+
}
14+
15+
function decimals() public view override returns (uint8) {
16+
return _decimals;
17+
}
18+
19+
/// @notice Open mint — anyone can fund any address. Tests only.
20+
function mint(address to, uint256 amount) external {
21+
_mint(to, amount);
22+
}
23+
}

0 commit comments

Comments
 (0)