Skip to content

Commit 9722adc

Browse files
GeObtsclaude
andcommitted
test: receipt NFT frozen-at-mint guarantee
Six tests proving the marquee Receipt NFT feature: each receipt snapshots its sponsored collection's baseURI, royalty receiver, and royalty bps at mint time and never changes afterward. - frozen across collection rotation: receipt minted under collection #1 keeps collection #1's URI + royalty even after setActiveCollection(2) - unaffected by forceUpdateCollectionBaseURI: existing receipts keep their snapshot; only future mints pick up the force-updated URI - go-dark fallback: receipts minted when activeCollectionId == 0 use the default fallback URI and return (address(0), 0) royalty - fallback frozen per-token: setDefaultFallbackBaseURI doesn't reach already-minted go-dark receipts - receiptData public getter populated correctly across all fields - receipts are transferable (deliberate contrast with soulbound Pass/Completion) Confirms the frozen-at-mint design: rotating sponsors or fixing metadata never rugs an existing collector's receipt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c75a86c commit 9722adc

1 file changed

Lines changed: 262 additions & 0 deletions

File tree

test/ReceiptFrozen.t.sol

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
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

Comments
 (0)