|
| 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 {MitandaReceiptNFT} from "../src/MitandaReceiptNFT.sol"; |
| 7 | + |
| 8 | +/// @title ReceiptFrozenTest |
| 9 | +/// @notice Proves the frozen-at-mint guarantee for the Receipt NFT: a |
| 10 | +/// receipt's `tokenURI` and `royaltyInfo` are snapshotted at |
| 11 | +/// mint time and NEVER change afterward, even when the Manager |
| 12 | +/// rotates the active sponsored collection, force-updates a |
| 13 | +/// collection's baseURI, or the Receipt NFT's default fallback |
| 14 | +/// baseURI is updated. |
| 15 | +/// |
| 16 | +/// Also confirms: go-dark fallback path, ReceiptData getter |
| 17 | +/// field-by-field, and that receipts are transferable (the |
| 18 | +/// deliberate contrast with Pass and Completion which are |
| 19 | +/// soulbound). |
| 20 | +/// |
| 21 | +/// Setup-provided collection #1: baseURI "ipfs://test-collection", |
| 22 | +/// royalty receiver `SPONSOR_ROYALTY_RECEIVER` (0xCAFE), |
| 23 | +/// royalty bps 500 (5%). Default fallback baseURI |
| 24 | +/// "ipfs://default-fallback". |
| 25 | +contract ReceiptFrozenTest is MitandaTestBase { |
| 26 | + uint256 internal constant USDC = 10 ** 6; |
| 27 | + uint256 internal constant CONTRIBUTION = 100 * USDC; |
| 28 | + uint256 internal constant CHARGE_PER_CYCLE = 110 * USDC; |
| 29 | + |
| 30 | + // ── Constants we register for the second collection in scenario 1 ── |
| 31 | + string internal constant COL2_BASE_URI = "ipfs://collection-two"; |
| 32 | + address internal constant COL2_RECEIVER = address(0xBEEEEE); |
| 33 | + uint96 internal constant COL2_BPS = 750; // 7.5% |
| 34 | + |
| 35 | + // ── Setup-base collection #1 (mirrors what's in MitandaTestBase) ── |
| 36 | + string internal constant COL1_BASE_URI = "ipfs://test-collection"; |
| 37 | + |
| 38 | + // ── Default fallback (from base setUp constructor arg) ───────────── |
| 39 | + string internal constant DEFAULT_FALLBACK_URI = "ipfs://default-fallback"; |
| 40 | + |
| 41 | + // ──────────────────────────────────────────────────────────────── |
| 42 | + // Helpers |
| 43 | + // ──────────────────────────────────────────────────────────────── |
| 44 | + |
| 45 | + /// @dev Create + fill a default 3-person tanda and run cycle 1. |
| 46 | + /// Returns the new tanda's address, its first cycle's recipient, |
| 47 | + /// and the receipt tokenId minted on that payout. |
| 48 | + function _mintFirstReceipt(uint256 seed) internal returns (address tandaAddr, address recipient, uint256 tokenId) { |
| 49 | + (tandaAddr,) = _createDefaultTanda(alice); |
| 50 | + Tanda t = Tanda(tandaAddr); |
| 51 | + |
| 52 | + address[] memory users = new address[](3); |
| 53 | + users[0] = alice; |
| 54 | + users[1] = bob; |
| 55 | + users[2] = carol; |
| 56 | + _fillAndStart(tandaAddr, users, seed); |
| 57 | + |
| 58 | + uint256[] memory order = t.getPayoutOrder(); |
| 59 | + address[3] memory pArr = [alice, bob, carol]; |
| 60 | + recipient = pArr[order[0]]; |
| 61 | + |
| 62 | + _warpToNextCycle(tandaAddr); |
| 63 | + t.triggerPayout(); |
| 64 | + |
| 65 | + // Each test starts with nextTokenId == 0; the first mint produces tokenId 1. |
| 66 | + tokenId = 1; |
| 67 | + } |
| 68 | + |
| 69 | + function _makePayment(address tandaAddr, address user, uint256 cycles) internal { |
| 70 | + uint256 charge = cycles * CHARGE_PER_CYCLE; |
| 71 | + _fundAndApprove(user, charge, tandaAddr); |
| 72 | + vm.prank(user); |
| 73 | + Tanda(tandaAddr).makePayment(cycles); |
| 74 | + } |
| 75 | + |
| 76 | + /// @dev Advance one more cycle on the given tanda (pay + warp + trigger). |
| 77 | + function _runNextCycle(address tandaAddr) internal { |
| 78 | + _makePayment(tandaAddr, alice, 1); |
| 79 | + _makePayment(tandaAddr, bob, 1); |
| 80 | + _makePayment(tandaAddr, carol, 1); |
| 81 | + _warpToNextCycle(tandaAddr); |
| 82 | + Tanda(tandaAddr).triggerPayout(); |
| 83 | + } |
| 84 | + |
| 85 | + function _expectedURI(string memory base, uint256 tokenId) internal pure returns (string memory) { |
| 86 | + return string.concat(base, "/", vm.toString(tokenId), ".json"); |
| 87 | + } |
| 88 | + |
| 89 | + // ──────────────────────────────────────────────────────────────── |
| 90 | + // Scenario 1: tokenURI + royaltyInfo frozen across collection rotation |
| 91 | + // ──────────────────────────────────────────────────────────────── |
| 92 | + |
| 93 | + function test_receiptURI_frozenAcrossCollectionRotation() public { |
| 94 | + // Register collection #2 (do NOT activate). |
| 95 | + uint256 col2Id = manager.registerCollection("Collection Two", COL2_BASE_URI, COL2_RECEIVER, COL2_BPS); |
| 96 | + assertEq(col2Id, 2, "collection 2 id"); |
| 97 | + |
| 98 | + // Mint a receipt while collection #1 is the active slot. |
| 99 | + (,, uint256 tokenId) = _mintFirstReceipt(uint256(keccak256("rot_seed"))); |
| 100 | + |
| 101 | + // Pre-rotation: URI + royalty reflect collection #1. |
| 102 | + string memory expectedURI = _expectedURI(COL1_BASE_URI, tokenId); |
| 103 | + assertEq(receiptNFT.tokenURI(tokenId), expectedURI, "URI pre-rotation"); |
| 104 | + |
| 105 | + uint256 salePrice = 10_000; |
| 106 | + (address receiverBefore, uint256 royaltyBefore) = receiptNFT.royaltyInfo(tokenId, salePrice); |
| 107 | + assertEq(receiverBefore, SPONSOR_ROYALTY_RECEIVER, "royalty receiver pre"); |
| 108 | + // 10_000 * 500 / 10_000 = 500 |
| 109 | + assertEq(royaltyBefore, 500, "royalty amount pre"); |
| 110 | + |
| 111 | + // ROTATE: activate collection #2. |
| 112 | + manager.setActiveCollection(col2Id); |
| 113 | + (uint256 activeId,) = manager.getActiveCollection(); |
| 114 | + assertEq(activeId, col2Id, "active is now collection 2"); |
| 115 | + |
| 116 | + // Re-read the SAME receipt — frozen. |
| 117 | + assertEq(receiptNFT.tokenURI(tokenId), expectedURI, "URI frozen after rotation"); |
| 118 | + |
| 119 | + (address receiverAfter, uint256 royaltyAfter) = receiptNFT.royaltyInfo(tokenId, salePrice); |
| 120 | + assertEq(receiverAfter, SPONSOR_ROYALTY_RECEIVER, "royalty receiver frozen after rotation"); |
| 121 | + assertEq(royaltyAfter, 500, "royalty amount frozen after rotation"); |
| 122 | + } |
| 123 | + |
| 124 | + // ──────────────────────────────────────────────────────────────── |
| 125 | + // Scenario 2: tokenURI unaffected by forceUpdateCollectionBaseURI |
| 126 | + // ──────────────────────────────────────────────────────────────── |
| 127 | + |
| 128 | + function test_receiptURI_unaffectedByForceUpdate() public { |
| 129 | + (address tandaAddr,, uint256 tokenId1) = _mintFirstReceipt(uint256(keccak256("force_seed"))); |
| 130 | + |
| 131 | + // Capture receipt 1's URI under collection #1's original baseURI. |
| 132 | + string memory originalURI = _expectedURI(COL1_BASE_URI, tokenId1); |
| 133 | + assertEq(receiptNFT.tokenURI(tokenId1), originalURI, "receipt 1 URI before force"); |
| 134 | + |
| 135 | + // Force update collection #1's baseURI (emergency only). |
| 136 | + string memory newURI = "ipfs://NEW_EMERGENCY_URI"; |
| 137 | + manager.forceUpdateCollectionBaseURI(1, newURI); |
| 138 | + |
| 139 | + // Sanity: Manager's stored baseURI did change. |
| 140 | + (, string memory newStoredBaseURI,,,,) = manager.collections(1); |
| 141 | + assertEq(newStoredBaseURI, newURI, "manager baseURI did update"); |
| 142 | + |
| 143 | + // Existing receipt is unchanged — frozen at mint time. |
| 144 | + assertEq(receiptNFT.tokenURI(tokenId1), originalURI, "receipt 1 URI frozen after force"); |
| 145 | + |
| 146 | + // Mint a NEW receipt in the same tanda. It should use the new URI |
| 147 | + // because it's a fresh mint that snapshots the now-updated value. |
| 148 | + _runNextCycle(tandaAddr); |
| 149 | + uint256 tokenId2 = 2; |
| 150 | + string memory expectedNewReceiptURI = _expectedURI(newURI, tokenId2); |
| 151 | + assertEq(receiptNFT.tokenURI(tokenId2), expectedNewReceiptURI, "new receipt URI uses force-updated"); |
| 152 | + |
| 153 | + // And receipt 1 is STILL frozen. |
| 154 | + assertEq(receiptNFT.tokenURI(tokenId1), originalURI, "receipt 1 STILL frozen"); |
| 155 | + } |
| 156 | + |
| 157 | + // ──────────────────────────────────────────────────────────────── |
| 158 | + // Scenario 3: go-dark fallback |
| 159 | + // ──────────────────────────────────────────────────────────────── |
| 160 | + |
| 161 | + function test_receiptURI_goDarkFallback() public { |
| 162 | + // Clear active collection — go dark. |
| 163 | + manager.clearActiveCollection(); |
| 164 | + (uint256 activeId,) = manager.getActiveCollection(); |
| 165 | + assertEq(activeId, 0, "active is now 0"); |
| 166 | + |
| 167 | + // Mint a receipt under go-dark. |
| 168 | + (,, uint256 tokenId) = _mintFirstReceipt(uint256(keccak256("godark_seed"))); |
| 169 | + |
| 170 | + // tokenURI uses the default fallback. |
| 171 | + assertEq(receiptNFT.tokenURI(tokenId), _expectedURI(DEFAULT_FALLBACK_URI, tokenId), "URI uses fallback"); |
| 172 | + |
| 173 | + // royaltyInfo returns (address(0), 0) — no royalty on go-dark. |
| 174 | + (address receiver, uint256 royaltyAmount) = receiptNFT.royaltyInfo(tokenId, 10_000); |
| 175 | + assertEq(receiver, address(0), "no royalty receiver on go-dark"); |
| 176 | + assertEq(royaltyAmount, 0, "no royalty amount on go-dark"); |
| 177 | + |
| 178 | + // ReceiptData.collectionId == 0. |
| 179 | + MitandaReceiptNFT.ReceiptData memory data = receiptNFT.getReceiptData(tokenId); |
| 180 | + assertEq(data.collectionId, 0, "frozen collectionId 0"); |
| 181 | + assertEq(data.frozenBaseURI, DEFAULT_FALLBACK_URI, "frozen baseURI is fallback"); |
| 182 | + assertEq(data.frozenRoyaltyReceiver, address(0), "frozen royalty receiver zero"); |
| 183 | + assertEq(uint256(data.frozenRoyaltyBps), 0, "frozen royalty bps zero"); |
| 184 | + } |
| 185 | + |
| 186 | + // ──────────────────────────────────────────────────────────────── |
| 187 | + // Scenario 4: go-dark fallback frozen across fallback update |
| 188 | + // ──────────────────────────────────────────────────────────────── |
| 189 | + |
| 190 | + function test_receiptURI_goDarkFallback_frozenAcrossFallbackUpdate() public { |
| 191 | + manager.clearActiveCollection(); |
| 192 | + |
| 193 | + // Mint receipt 1 under fallback "A" (= DEFAULT_FALLBACK_URI from setUp). |
| 194 | + (address tandaAddr,, uint256 tokenId1) = _mintFirstReceipt(uint256(keccak256("godark2_seed"))); |
| 195 | + string memory receipt1URI = _expectedURI(DEFAULT_FALLBACK_URI, tokenId1); |
| 196 | + assertEq(receiptNFT.tokenURI(tokenId1), receipt1URI, "receipt 1 URI fallback A"); |
| 197 | + |
| 198 | + // Owner-updates the fallback URI. |
| 199 | + string memory fallbackB = "ipfs://FALLBACK_B"; |
| 200 | + receiptNFT.setDefaultFallbackBaseURI(fallbackB); |
| 201 | + assertEq(receiptNFT.defaultFallbackBaseURI(), fallbackB, "fallback B is set"); |
| 202 | + |
| 203 | + // Existing receipt is UNCHANGED. |
| 204 | + assertEq(receiptNFT.tokenURI(tokenId1), receipt1URI, "receipt 1 frozen across fallback update"); |
| 205 | + |
| 206 | + // Mint receipt 2 in same tanda — should use fallback B. |
| 207 | + _runNextCycle(tandaAddr); |
| 208 | + uint256 tokenId2 = 2; |
| 209 | + assertEq(receiptNFT.tokenURI(tokenId2), _expectedURI(fallbackB, tokenId2), "receipt 2 uses fallback B"); |
| 210 | + |
| 211 | + // Receipt 1 STILL fallback A. |
| 212 | + assertEq(receiptNFT.tokenURI(tokenId1), receipt1URI, "receipt 1 STILL fallback A"); |
| 213 | + } |
| 214 | + |
| 215 | + // ──────────────────────────────────────────────────────────────── |
| 216 | + // Scenario 5: full ReceiptData struct getter |
| 217 | + // ──────────────────────────────────────────────────────────────── |
| 218 | + |
| 219 | + function test_receiptData_publicGetter() public { |
| 220 | + (address tandaAddr,, uint256 tokenId) = _mintFirstReceipt(uint256(keccak256("getter_seed"))); |
| 221 | + |
| 222 | + MitandaReceiptNFT.ReceiptData memory data = receiptNFT.getReceiptData(tokenId); |
| 223 | + |
| 224 | + // tandaId is whatever Manager assigned — the only tanda this test creates, so id = 1. |
| 225 | + assertEq(data.tandaId, 1, "tandaId"); |
| 226 | + assertEq(data.cycle, 1, "cycle (first payout)"); |
| 227 | + assertEq(data.collectionId, 1, "collectionId (active in base setUp)"); |
| 228 | + assertEq(data.frozenBaseURI, COL1_BASE_URI, "frozen baseURI"); |
| 229 | + assertEq(data.frozenRoyaltyReceiver, SPONSOR_ROYALTY_RECEIVER, "frozen royalty receiver"); |
| 230 | + assertEq(uint256(data.frozenRoyaltyBps), 500, "frozen royalty bps"); |
| 231 | + assertEq(data.tandaAddress, tandaAddr, "tandaAddress matches"); |
| 232 | + } |
| 233 | + |
| 234 | + // ──────────────────────────────────────────────────────────────── |
| 235 | + // Scenario 6: receipts are TRANSFERABLE (deliberate contrast with soulbound) |
| 236 | + // ──────────────────────────────────────────────────────────────── |
| 237 | + |
| 238 | + function test_receipt_isTransferable() public { |
| 239 | + (, address recipient, uint256 tokenId) = _mintFirstReceipt(uint256(keccak256("transfer_seed"))); |
| 240 | + |
| 241 | + // Pre-transfer ownership |
| 242 | + assertEq(receiptNFT.ownerOf(tokenId), recipient, "recipient owns receipt"); |
| 243 | + |
| 244 | + // Pick a non-recipient destination |
| 245 | + address dest = recipient == dave ? eve : dave; |
| 246 | + assertTrue(dest != recipient, "dest != recipient"); |
| 247 | + |
| 248 | + // Receipt holder transfers it. |
| 249 | + vm.prank(recipient); |
| 250 | + receiptNFT.transferFrom(recipient, dest, tokenId); |
| 251 | + |
| 252 | + assertEq(receiptNFT.ownerOf(tokenId), dest, "ownership moved to dest"); |
| 253 | + assertEq(receiptNFT.balanceOf(recipient), 0, "recipient balance zero"); |
| 254 | + assertEq(receiptNFT.balanceOf(dest), 1, "dest balance one"); |
| 255 | + |
| 256 | + // The transferred receipt's URI + royalty info is still the original snapshot. |
| 257 | + assertEq(receiptNFT.tokenURI(tokenId), _expectedURI(COL1_BASE_URI, tokenId), "URI unchanged by transfer"); |
| 258 | + (address receiver, uint256 royalty) = receiptNFT.royaltyInfo(tokenId, 10_000); |
| 259 | + assertEq(receiver, SPONSOR_ROYALTY_RECEIVER, "royalty receiver unchanged"); |
| 260 | + assertEq(royalty, 500, "royalty amount unchanged"); |
| 261 | + } |
| 262 | +} |
0 commit comments