|
| 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 | +} |
0 commit comments