create-kozalak-l1 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +87 -0
  2. package/dist/deploy.js +63 -0
  3. package/dist/forge.js +27 -0
  4. package/dist/index.js +176 -0
  5. package/dist/prompts.js +23 -0
  6. package/dist/scaffold.js +33 -0
  7. package/dist/templates.js +108 -0
  8. package/package.json +29 -0
  9. package/templates/erc20-gas/.env.example +19 -0
  10. package/templates/erc20-gas/.gitmodules +6 -0
  11. package/templates/erc20-gas/README.md +48 -0
  12. package/templates/erc20-gas/foundry.toml +35 -0
  13. package/templates/erc20-gas/gitignore +5 -0
  14. package/templates/erc20-gas/remappings.txt +2 -0
  15. package/templates/erc20-gas/script/DeployERC20Gas.s.sol +131 -0
  16. package/templates/erc20-gas/src/KozaGasToken.sol +105 -0
  17. package/templates/erc20-gas/test/DeployERC20Gas.t.sol +60 -0
  18. package/templates/erc20-gas/test/ERC20Gas.invariants.t.sol +144 -0
  19. package/templates/erc20-gas/test/ERC20Gas.t.sol +354 -0
  20. package/templates/erc721-collection/.env.example +37 -0
  21. package/templates/erc721-collection/.gitmodules +6 -0
  22. package/templates/erc721-collection/README.md +48 -0
  23. package/templates/erc721-collection/foundry.toml +35 -0
  24. package/templates/erc721-collection/gitignore +5 -0
  25. package/templates/erc721-collection/remappings.txt +2 -0
  26. package/templates/erc721-collection/script/DeployERC721Collection.s.sol +151 -0
  27. package/templates/erc721-collection/src/KozaCollection.sol +281 -0
  28. package/templates/erc721-collection/test/DeployERC721Collection.t.sol +76 -0
  29. package/templates/erc721-collection/test/ERC721Collection.invariants.t.sol +175 -0
  30. package/templates/erc721-collection/test/ERC721Collection.t.sol +501 -0
  31. package/templates/ictt-bridge/.env.example +19 -0
  32. package/templates/ictt-bridge/.gitmodules +9 -0
  33. package/templates/ictt-bridge/README.md +49 -0
  34. package/templates/ictt-bridge/foundry.toml +41 -0
  35. package/templates/ictt-bridge/gitignore +5 -0
  36. package/templates/ictt-bridge/remappings.txt +8 -0
  37. package/templates/ictt-bridge/script/DeployTokenHome.s.sol +139 -0
  38. package/templates/ictt-bridge/src/KozaTokenHome.sol +57 -0
  39. package/templates/ictt-bridge/src/KozaTokenRemote.sol +65 -0
  40. package/templates/ictt-bridge/test/ICTTBridge.t.sol +157 -0
  41. package/templates/soulbound-credential/.env.example +19 -0
  42. package/templates/soulbound-credential/.gitmodules +6 -0
  43. package/templates/soulbound-credential/README.md +48 -0
  44. package/templates/soulbound-credential/foundry.toml +35 -0
  45. package/templates/soulbound-credential/gitignore +5 -0
  46. package/templates/soulbound-credential/remappings.txt +2 -0
  47. package/templates/soulbound-credential/script/DeployCredential.s.sol +126 -0
  48. package/templates/soulbound-credential/src/KozaCredential.sol +201 -0
  49. package/templates/soulbound-credential/test/DeployCredential.t.sol +46 -0
  50. package/templates/soulbound-credential/test/Soulbound.invariants.t.sol +133 -0
  51. package/templates/soulbound-credential/test/Soulbound.t.sol +319 -0
  52. package/templates/treasury-multisig/.env.example +19 -0
  53. package/templates/treasury-multisig/.gitmodules +6 -0
  54. package/templates/treasury-multisig/README.md +48 -0
  55. package/templates/treasury-multisig/foundry.toml +35 -0
  56. package/templates/treasury-multisig/gitignore +5 -0
  57. package/templates/treasury-multisig/remappings.txt +2 -0
  58. package/templates/treasury-multisig/script/DeployTreasury.s.sol +128 -0
  59. package/templates/treasury-multisig/src/KozaTreasury.sol +55 -0
  60. package/templates/treasury-multisig/test/DeployTreasury.t.sol +50 -0
  61. package/templates/treasury-multisig/test/Treasury.t.sol +154 -0
@@ -0,0 +1,281 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.34;
3
+
4
+ import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
5
+ import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol";
6
+ import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
7
+ import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
8
+ import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
9
+
10
+ /**
11
+ * @title KozaCollection
12
+ * @author kozalak-L1 contributors
13
+ * @notice ERC-721 + ERC-2981 royalty + Ownable2Step + Merkle allowlist NFT koleksiyonu.
14
+ * Avalanche L1 veya C-Chain üzerinde audit-grade boilerplate.
15
+ * @dev OpenZeppelin v5.3+ pattern'leri. Token id'ler 1'den başlar (UX/explorer kolaylığı).
16
+ *
17
+ * Faz akışı:
18
+ * Closed → mint kapalı, sadece deploy sonrası başlangıç
19
+ * Allowlist → sadece Merkle proof'u doğrulanan adresler (tek seferlik claim)
20
+ * Public → herkese açık
21
+ *
22
+ * Güvenlik kararları:
23
+ * - Ownable2Step: yanlış adrese ownership transferi engellenir
24
+ * - immutable maxSupply: deploy sonrası kapasite değiştirilemez
25
+ * - Strict msg.value: fazla ödeme refund edilmez (basit, deterministik). Frontend
26
+ * kullanıcıya net miktarı göstermek zorunda
27
+ * - Merkle leaf = keccak256(abi.encodePacked(address)) — basit, sadece "kim"i
28
+ * kanıtlar; quota MAX_PER_WALLET ile sınırlandırılır
29
+ * - Per-wallet mint cap (her fazı kapsar) Sybil saldırılarını yavaşlatır ama
30
+ * çoklu cüzdan tamamen engellenmez (KYC seviyesinde değil)
31
+ * - withdraw `call`'i target'a istediği gibi davranma yetkisi verir; reentrancy
32
+ * riski yok çünkü mint/setter fonksiyonlarda durum güncellemeleri tüm external
33
+ * çağrılardan ÖNCE yapılır (CEI pattern)
34
+ * - ERC-2981 royalty marketplace'in saygı göstermesine bağlıdır (OpenSea Operator
35
+ * Filter, Joepegs vb. — protokol seviyesinde garantisi yoktur)
36
+ */
37
+ contract KozaCollection is ERC721, ERC2981, Ownable2Step {
38
+ using Strings for uint256;
39
+
40
+ /*//////////////////////////////////////////////////////////////
41
+ TYPES
42
+ //////////////////////////////////////////////////////////////*/
43
+
44
+ enum Phase {
45
+ Closed,
46
+ Allowlist,
47
+ Public
48
+ }
49
+
50
+ /*//////////////////////////////////////////////////////////////
51
+ CONSTANTS
52
+ //////////////////////////////////////////////////////////////*/
53
+
54
+ /// @notice Tek bir cüzdanın tüm fazlar boyunca toplam mint edebileceği üst sınır.
55
+ uint256 public constant MAX_PER_WALLET = 10;
56
+
57
+ /*//////////////////////////////////////////////////////////////
58
+ STORAGE
59
+ //////////////////////////////////////////////////////////////*/
60
+
61
+ /// @notice Toplam basılabilir token sayısı (deploy'da sabit, immutable).
62
+ uint256 public immutable maxSupply;
63
+
64
+ /// @notice Şimdiye kadar basılan toplam token sayısı (id artışı için kullanılır,
65
+ /// burn olsa bile geri sayılmaz).
66
+ uint256 public totalMinted;
67
+
68
+ /// @notice Mint başına ödenecek native gas miktarı (wei).
69
+ uint256 public mintPrice;
70
+
71
+ /// @notice Allowlist Merkle ağacının kök hash'i.
72
+ bytes32 public merkleRoot;
73
+
74
+ /// @notice Aktif mint fazı.
75
+ Phase public phase;
76
+
77
+ /// @dev `tokenURI` için `_baseURI()` override sonucu.
78
+ string private _baseTokenURI;
79
+
80
+ /// @notice Cüzdan başına şimdiye kadarki mint sayısı (allowlist + public toplam).
81
+ mapping(address account => uint256 minted) public mintedPerWallet;
82
+
83
+ /// @notice Allowlist'i bir kez kullanan adresler (yeniden claim engeli).
84
+ mapping(address account => bool claimed) public allowlistClaimed;
85
+
86
+ /*//////////////////////////////////////////////////////////////
87
+ EVENTS
88
+ //////////////////////////////////////////////////////////////*/
89
+
90
+ event PhaseChanged(Phase indexed newPhase);
91
+ event MerkleRootSet(bytes32 indexed newRoot);
92
+ event BaseURISet(string newBaseURI);
93
+ event MintPriceSet(uint256 newPrice);
94
+ event Withdrawn(address indexed to, uint256 amount);
95
+
96
+ /*//////////////////////////////////////////////////////////////
97
+ ERRORS
98
+ //////////////////////////////////////////////////////////////*/
99
+
100
+ error MaxSupplyZero();
101
+ error MaxSupplyReached(uint256 supplyCap, uint256 attempted);
102
+ error InvalidProof();
103
+ error AlreadyClaimed(address account);
104
+ error IncorrectPayment(uint256 sent, uint256 required);
105
+ error WithdrawFailed();
106
+ error WrongPhase(Phase current, Phase required);
107
+ error ZeroQuantity();
108
+ error ExceedsPerWalletLimit(uint256 current, uint256 attempted, uint256 max);
109
+
110
+ /*//////////////////////////////////////////////////////////////
111
+ CONSTRUCTOR
112
+ //////////////////////////////////////////////////////////////*/
113
+
114
+ /**
115
+ * @param name_ Koleksiyon adı (örn. "Koza Genesis")
116
+ * @param symbol_ Koleksiyon sembolü (örn. "KOZA")
117
+ * @param baseURI_ Metadata IPFS/HTTPS prefix (sonu `/` ile bitmeli, tokenURI = baseURI + id)
118
+ * @param maxSupply_ Toplam basılabilir token sayısı (immutable)
119
+ * @param mintPrice_ Token başına native gas ücreti (wei, 0 olabilir)
120
+ * @param royaltyReceiver_ Default ERC-2981 royalty alıcısı (multisig önerilir)
121
+ * @param royaltyBps_ Royalty oranı BPS cinsinden (1000 = %10, max 10000 = %100)
122
+ * @param initialOwner_ Owner adresi (production: multisig, asla EOA değil)
123
+ */
124
+ constructor(
125
+ string memory name_,
126
+ string memory symbol_,
127
+ string memory baseURI_,
128
+ uint256 maxSupply_,
129
+ uint256 mintPrice_,
130
+ address royaltyReceiver_,
131
+ uint96 royaltyBps_,
132
+ address initialOwner_
133
+ )
134
+ ERC721(name_, symbol_)
135
+ Ownable(initialOwner_)
136
+ {
137
+ if (maxSupply_ == 0) revert MaxSupplyZero();
138
+
139
+ maxSupply = maxSupply_;
140
+ mintPrice = mintPrice_;
141
+ _baseTokenURI = baseURI_;
142
+
143
+ // OZ ERC2981 royaltyBps_ <= _feeDenominator() (10000) kontrolünü kendi içinde yapar
144
+ // ve receiver_ != address(0) kontrolü uygular. Ek revert tanımına gerek yok.
145
+ _setDefaultRoyalty(royaltyReceiver_, royaltyBps_);
146
+ }
147
+
148
+ /*//////////////////////////////////////////////////////////////
149
+ MINTING
150
+ //////////////////////////////////////////////////////////////*/
151
+
152
+ /**
153
+ * @notice Allowlist fazında, Merkle proof ile mint et.
154
+ * @dev Tek seferlik claim. Aynı adres ikinci kez allowlistMint çağıramaz; ama public
155
+ * faza geçtiğinde publicMint yapabilir (MAX_PER_WALLET izin verdiği kadar).
156
+ * @param quantity Bu çağrıda mint edilecek miktar
157
+ * @param proof Çağıranın Merkle ağacındaki yaprak kanıtı
158
+ */
159
+ function allowlistMint(uint256 quantity, bytes32[] calldata proof) external payable {
160
+ if (phase != Phase.Allowlist) revert WrongPhase(phase, Phase.Allowlist);
161
+ if (allowlistClaimed[msg.sender]) revert AlreadyClaimed(msg.sender);
162
+
163
+ bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
164
+ if (!MerkleProof.verifyCalldata(proof, merkleRoot, leaf)) revert InvalidProof();
165
+
166
+ // CEI: önce state, sonra ödeme + mint
167
+ allowlistClaimed[msg.sender] = true;
168
+ _mintBatch(msg.sender, quantity);
169
+ }
170
+
171
+ /**
172
+ * @notice Public faz mint. Herkese açık.
173
+ * @param quantity Bu çağrıda mint edilecek miktar
174
+ */
175
+ function publicMint(uint256 quantity) external payable {
176
+ if (phase != Phase.Public) revert WrongPhase(phase, Phase.Public);
177
+ _mintBatch(msg.sender, quantity);
178
+ }
179
+
180
+ /*//////////////////////////////////////////////////////////////
181
+ OWNER ACTIONS
182
+ //////////////////////////////////////////////////////////////*/
183
+
184
+ /// @notice Allowlist Merkle ağacının yeni kökünü ata.
185
+ function setMerkleRoot(bytes32 newRoot) external onlyOwner {
186
+ merkleRoot = newRoot;
187
+ emit MerkleRootSet(newRoot);
188
+ }
189
+
190
+ /// @notice Aktif mint fazını değiştir.
191
+ function setPhase(Phase newPhase) external onlyOwner {
192
+ phase = newPhase;
193
+ emit PhaseChanged(newPhase);
194
+ }
195
+
196
+ /// @notice tokenURI prefix'ini güncelle (örn. reveal sonrası).
197
+ function setBaseURI(string calldata newBaseURI) external onlyOwner {
198
+ _baseTokenURI = newBaseURI;
199
+ emit BaseURISet(newBaseURI);
200
+ }
201
+
202
+ /// @notice Token başına mint ücretini güncelle.
203
+ function setMintPrice(uint256 newPrice) external onlyOwner {
204
+ mintPrice = newPrice;
205
+ emit MintPriceSet(newPrice);
206
+ }
207
+
208
+ /// @notice Default ERC-2981 royalty bilgisini güncelle.
209
+ /// @dev Tüm token'lar için varsayılan; per-token override yok (bu template'te).
210
+ function setDefaultRoyalty(address receiver, uint96 bps) external onlyOwner {
211
+ _setDefaultRoyalty(receiver, bps);
212
+ }
213
+
214
+ /**
215
+ * @notice Kontratta biriken native gas'ı hedef adrese çek.
216
+ * @dev `to` çağrı sırasında reentrancy yapamaz çünkü withdraw'da güncellenecek
217
+ * durum yok (balance native zaten transfer edilirken sıfırlanır). Yine de
218
+ * Withdrawn event'i call'dan önce emit edilir; başarısızsa revert tüm
219
+ * durumu geri alır.
220
+ */
221
+ function withdraw(address payable to) external onlyOwner {
222
+ uint256 balance = address(this).balance;
223
+ emit Withdrawn(to, balance);
224
+ (bool success,) = to.call{value: balance}("");
225
+ if (!success) revert WithdrawFailed();
226
+ }
227
+
228
+ /*//////////////////////////////////////////////////////////////
229
+ VIEWS
230
+ //////////////////////////////////////////////////////////////*/
231
+
232
+ /// @inheritdoc ERC721
233
+ function _baseURI() internal view override returns (string memory) {
234
+ return _baseTokenURI;
235
+ }
236
+
237
+ /// @notice Birden fazla interface (ERC721, ERC2981) desteklendiği için override.
238
+ function supportsInterface(bytes4 interfaceId) public view override(ERC721, ERC2981) returns (bool) {
239
+ return super.supportsInterface(interfaceId);
240
+ }
241
+
242
+ /*//////////////////////////////////////////////////////////////
243
+ INTERNAL
244
+ //////////////////////////////////////////////////////////////*/
245
+
246
+ /**
247
+ * @dev Mint helper'ı. Quantity, supply, per-wallet ve ödeme kontrollerini yapar,
248
+ * `_safeMint` ile id'leri sırayla basar.
249
+ *
250
+ * Strict `msg.value == required` kullanıyoruz — fazla ödeme refund edilmez.
251
+ * Bu basitlik audit yüzeyini küçültür ve frontend'in net hesap göstermesini
252
+ * zorunlu kılar. İhtiyaç durumunda fork'ta `>=` + refund eklenebilir.
253
+ */
254
+ function _mintBatch(address to, uint256 quantity) private {
255
+ if (quantity == 0) revert ZeroQuantity();
256
+
257
+ uint256 currentMinted = mintedPerWallet[to];
258
+ if (currentMinted + quantity > MAX_PER_WALLET) {
259
+ revert ExceedsPerWalletLimit(currentMinted, quantity, MAX_PER_WALLET);
260
+ }
261
+
262
+ uint256 totalAfter = totalMinted + quantity;
263
+ if (totalAfter > maxSupply) revert MaxSupplyReached(maxSupply, totalAfter);
264
+
265
+ uint256 required = mintPrice * quantity;
266
+ if (msg.value != required) revert IncorrectPayment(msg.value, required);
267
+
268
+ // CEI: state güncellemeleri _safeMint'ten ÖNCE yapılır (reentrancy guard)
269
+ mintedPerWallet[to] = currentMinted + quantity;
270
+ uint256 startId = totalMinted;
271
+ totalMinted = totalAfter;
272
+
273
+ for (uint256 i = 0; i < quantity;) {
274
+ // Token id 1'den başlasın (UX için)
275
+ _safeMint(to, startId + i + 1);
276
+ unchecked {
277
+ ++i;
278
+ }
279
+ }
280
+ }
281
+ }
@@ -0,0 +1,76 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.34;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+ import {DeployERC721Collection} from "../script/DeployERC721Collection.s.sol";
6
+ import {KozaCollection} from "../src/KozaCollection.sol";
7
+
8
+ /**
9
+ * @title DeployERC721CollectionTest
10
+ * @notice Smoke tests for the ERC-721 deploy script. Calls `deploy(...)` directly
11
+ * with explicit parameters; env-driven `run()` exercised on Fuji integration.
12
+ */
13
+ contract DeployERC721CollectionTest is Test {
14
+ function test_Deploy_WithDefaultParameters() public {
15
+ DeployERC721Collection deployer = new DeployERC721Collection();
16
+ address broadcaster = makeAddr("broadcaster");
17
+ address royalty = makeAddr("royalty");
18
+
19
+ (KozaCollection nft, address returnedDeployer) = deployer.deploy(
20
+ "Koza Genesis", "KOZA", "ipfs://QmExampleCID/", 5000, 0.05 ether, royalty, 500, broadcaster, broadcaster
21
+ );
22
+
23
+ assertEq(nft.name(), "Koza Genesis");
24
+ assertEq(nft.symbol(), "KOZA");
25
+ assertEq(nft.maxSupply(), 5000);
26
+ assertEq(nft.mintPrice(), 0.05 ether);
27
+ assertEq(nft.owner(), broadcaster);
28
+ assertEq(uint8(nft.phase()), uint8(KozaCollection.Phase.Closed));
29
+ assertEq(returnedDeployer, broadcaster);
30
+
31
+ (address rcv, uint256 amt) = nft.royaltyInfo(1, 10_000);
32
+ assertEq(rcv, royalty);
33
+ assertEq(amt, 500);
34
+ }
35
+
36
+ function test_Deploy_WithCustomParameters() public {
37
+ DeployERC721Collection deployer = new DeployERC721Collection();
38
+ address customOwner = makeAddr("customOwner");
39
+ address customRoyalty = makeAddr("customRoyalty");
40
+ address customBroadcaster = makeAddr("customBroadcaster");
41
+
42
+ (KozaCollection nft,) = deployer.deploy(
43
+ "Custom Art",
44
+ "ART",
45
+ "https://meta.example/",
46
+ 100,
47
+ 0.001 ether,
48
+ customRoyalty,
49
+ 1000,
50
+ customOwner,
51
+ customBroadcaster
52
+ );
53
+
54
+ assertEq(nft.name(), "Custom Art");
55
+ assertEq(nft.maxSupply(), 100);
56
+ assertEq(nft.owner(), customOwner);
57
+
58
+ (address rcv, uint256 amt) = nft.royaltyInfo(1, 10_000);
59
+ assertEq(rcv, customRoyalty);
60
+ assertEq(amt, 1000); // %10
61
+ }
62
+
63
+ function test_Deploy_FreeMintNoRoyalty() public {
64
+ DeployERC721Collection deployer = new DeployERC721Collection();
65
+ address broadcaster = makeAddr("broadcaster");
66
+ address royalty = makeAddr("royalty");
67
+
68
+ (KozaCollection nft,) =
69
+ deployer.deploy("Free Drop", "FREE", "ipfs://QmFreeCID/", 1000, 0, royalty, 0, broadcaster, broadcaster);
70
+
71
+ assertEq(nft.mintPrice(), 0);
72
+
73
+ (, uint256 amt) = nft.royaltyInfo(1, 10_000);
74
+ assertEq(amt, 0);
75
+ }
76
+ }
@@ -0,0 +1,175 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.34;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+ import {KozaCollection} from "../src/KozaCollection.sol";
6
+
7
+ /**
8
+ * @title ERC721CollectionHandler
9
+ * @notice Foundry invariant testing handler. Bounded random calls into KozaCollection.
10
+ * @dev Mints and transfers between actors. Phase ve fiyat değişiklikleri owner tarafından
11
+ * yapılır. Invariant runner her tx sonrası invariant fonksiyonlarını çağırır.
12
+ */
13
+ contract ERC721CollectionHandler is Test {
14
+ KozaCollection public nft;
15
+ address public owner;
16
+ address[] public actors;
17
+
18
+ uint256 public callCount;
19
+
20
+ /// @notice Toplam kontrata ödenen ETH (mint ile). Withdraw'lar ayrı tutulur.
21
+ uint256 public totalPaid;
22
+
23
+ /// @notice Owner tarafından çekilen toplam ETH (cumulative).
24
+ uint256 public totalWithdrawn;
25
+
26
+ constructor(KozaCollection _nft, address _owner, address[] memory _actors) {
27
+ nft = _nft;
28
+ owner = _owner;
29
+ actors = _actors;
30
+
31
+ // Faz başlangıçta Public — handler basit kalsın diye
32
+ vm.prank(owner);
33
+ nft.setPhase(KozaCollection.Phase.Public);
34
+ }
35
+
36
+ function _pickActor(uint256 seed) internal view returns (address) {
37
+ return actors[seed % actors.length];
38
+ }
39
+
40
+ /// @notice Random public mint by an actor.
41
+ function publicMint(uint256 actorSeed, uint256 quantity) external {
42
+ callCount++;
43
+ address actor = _pickActor(actorSeed);
44
+
45
+ uint256 maxSupply = nft.maxSupply();
46
+ uint256 minted = nft.totalMinted();
47
+ if (minted >= maxSupply) return;
48
+
49
+ uint256 walletMinted = nft.mintedPerWallet(actor);
50
+ uint256 maxPerWallet = nft.MAX_PER_WALLET();
51
+ if (walletMinted >= maxPerWallet) return;
52
+
53
+ uint256 maxQty = maxSupply - minted;
54
+ uint256 walletRoom = maxPerWallet - walletMinted;
55
+ uint256 cap = maxQty < walletRoom ? maxQty : walletRoom;
56
+
57
+ quantity = bound(quantity, 1, cap);
58
+ uint256 cost = nft.mintPrice() * quantity;
59
+
60
+ vm.deal(actor, cost);
61
+
62
+ vm.prank(actor);
63
+ try nft.publicMint{value: cost}(quantity) {
64
+ totalPaid += cost;
65
+ } catch {}
66
+ }
67
+
68
+ /// @notice Random NFT transfer between actors.
69
+ function transfer(uint256 fromSeed, uint256 toSeed, uint256 idSeed) external {
70
+ callCount++;
71
+ address from = _pickActor(fromSeed);
72
+ address to = _pickActor(toSeed);
73
+
74
+ uint256 balance = nft.balanceOf(from);
75
+ if (balance == 0) return;
76
+
77
+ // Token id'leri 1..totalMinted aralığında. From'un sahip olduğu bir id'yi bul.
78
+ uint256 totalMinted = nft.totalMinted();
79
+ if (totalMinted == 0) return;
80
+
81
+ uint256 startId = (idSeed % totalMinted) + 1;
82
+ uint256 foundId;
83
+ for (uint256 i = 0; i < totalMinted; i++) {
84
+ uint256 candidate = ((startId - 1 + i) % totalMinted) + 1;
85
+ if (nft.ownerOf(candidate) == from) {
86
+ foundId = candidate;
87
+ break;
88
+ }
89
+ }
90
+ if (foundId == 0) return;
91
+
92
+ vm.prank(from);
93
+ try nft.transferFrom(from, to, foundId) {} catch {}
94
+ }
95
+
96
+ /// @notice Owner withdraw kontratı boşaltır.
97
+ function withdraw() external {
98
+ callCount++;
99
+ uint256 balance = address(nft).balance;
100
+ if (balance == 0) return;
101
+
102
+ vm.prank(owner);
103
+ try nft.withdraw(payable(owner)) {
104
+ totalWithdrawn += balance;
105
+ } catch {}
106
+ }
107
+ }
108
+
109
+ /**
110
+ * @title ERC721CollectionInvariantTest
111
+ * @notice Stateful fuzzing invariants for KozaCollection.
112
+ *
113
+ * Tested invariants:
114
+ * 1. totalMinted <= maxSupply
115
+ * 2. sum(balanceOf(actors)) == totalMinted (no token leaks)
116
+ * 3. contract.balance == totalPaid - totalWithdrawn
117
+ * 4. owner immutable during fuzz
118
+ */
119
+ contract ERC721CollectionInvariantTest is Test {
120
+ KozaCollection internal nft;
121
+ ERC721CollectionHandler internal handler;
122
+ address internal owner;
123
+ address[] internal actors;
124
+
125
+ string internal constant NAME = "Koza Genesis";
126
+ string internal constant SYMBOL = "KOZA";
127
+ string internal constant BASE_URI = "ipfs://QmExampleCID/";
128
+ uint256 internal constant MAX_SUPPLY = 50;
129
+ uint256 internal constant MINT_PRICE = 0.01 ether;
130
+ uint96 internal constant ROYALTY_BPS = 500;
131
+
132
+ function setUp() public {
133
+ owner = makeAddr("owner");
134
+
135
+ actors.push(makeAddr("alice"));
136
+ actors.push(makeAddr("bob"));
137
+ actors.push(makeAddr("charlie"));
138
+ actors.push(makeAddr("dave"));
139
+ actors.push(makeAddr("eve"));
140
+
141
+ address royalty = makeAddr("royalty");
142
+ nft = new KozaCollection(NAME, SYMBOL, BASE_URI, MAX_SUPPLY, MINT_PRICE, royalty, ROYALTY_BPS, owner);
143
+ handler = new ERC721CollectionHandler(nft, owner, actors);
144
+
145
+ targetContract(address(handler));
146
+
147
+ bytes4[] memory selectors = new bytes4[](3);
148
+ selectors[0] = handler.publicMint.selector;
149
+ selectors[1] = handler.transfer.selector;
150
+ selectors[2] = handler.withdraw.selector;
151
+ targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
152
+ }
153
+
154
+ function invariant_TotalMintedDoesNotExceedMaxSupply() public view {
155
+ assertLe(nft.totalMinted(), nft.maxSupply(), "totalMinted > maxSupply");
156
+ }
157
+
158
+ function invariant_SumOfBalancesEqualsTotalMinted() public view {
159
+ uint256 sum;
160
+ for (uint256 i = 0; i < actors.length; i++) {
161
+ sum += nft.balanceOf(actors[i]);
162
+ }
163
+ assertEq(sum, nft.totalMinted(), "sum(balances) != totalMinted (token leak)");
164
+ }
165
+
166
+ function invariant_ContractBalanceMatchesPaidMinusWithdrawn() public view {
167
+ assertEq(
168
+ address(nft).balance, handler.totalPaid() - handler.totalWithdrawn(), "contract balance != paid - withdrawn"
169
+ );
170
+ }
171
+
172
+ function invariant_OwnerDoesNotChange() public view {
173
+ assertEq(nft.owner(), owner, "owner mutated unexpectedly");
174
+ }
175
+ }