@totems/evm 1.0.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.
@@ -0,0 +1,467 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "../shared/ReentrancyGuard.sol";
5
+ import "../library/ITotemTypes.sol";
6
+ import "../shared/Shared.sol";
7
+ import "../totems/Errors.sol";
8
+ import {IMod} from "../interfaces/TotemMod.sol";
9
+
10
+ /**
11
+ * @title ModMarket
12
+ * @author Totems
13
+ * @notice On-chain marketplace for publishing and discovering Totems mods
14
+ * @dev Mods are smart contracts that extend totem functionality through hooks.
15
+ * Developers deploy a mod contract, then publish it here with metadata.
16
+ * The marketplace handles:
17
+ * - Publishing with validation (name, summary, hooks, etc.)
18
+ * - Fee collection with referrer support and burn mechanism
19
+ * - Mod discovery via pagination and batch queries
20
+ * - Required actions that must be called after totem creation
21
+ */
22
+ contract ModMarket is ReentrancyGuard {
23
+
24
+ // ==================== STATE VARIABLES ====================
25
+
26
+ /// @notice Referrer fee amounts by address (must be >= minBaseFee)
27
+ mapping(address => uint256) internal fees;
28
+
29
+ /// @notice Published mod data by contract address
30
+ mapping(address => ITotemTypes.Mod) internal mods;
31
+
32
+ /// @notice Required setup actions for each mod (called after totem creation)
33
+ mapping(address => ITotemTypes.ModRequiredAction[]) internal modRequiredActions;
34
+
35
+ /// @notice Ordered list of all published mod addresses (for pagination)
36
+ address[] public modList;
37
+
38
+ /// @notice Minimum base fee for mod publishing (set once in constructor, never changed)
39
+ uint256 public immutable minBaseFee;
40
+
41
+ /// @notice Fee amount that is always burned (set once in constructor, never changed)
42
+ uint256 public immutable burnedFee;
43
+
44
+ // ==================== CONSTRUCTOR ====================
45
+
46
+ /// @notice Deploys the market with the minimum base fee and burned fee
47
+ /// @param _minBaseFee Minimum base fee for mod publishing
48
+ /// @param _burnedFee Fee amount that is always burned
49
+ constructor(uint256 _minBaseFee, uint256 _burnedFee) {
50
+ require(_minBaseFee > 0, "Invalid min base fee");
51
+ require(_burnedFee <= _minBaseFee, "Burned fee cannot exceed min base fee");
52
+ minBaseFee = _minBaseFee;
53
+ burnedFee = _burnedFee;
54
+ }
55
+
56
+ // ==================== EVENTS ====================
57
+
58
+ /// @notice Emitted when a new mod is published to the marketplace
59
+ /// @param mod The address of the published mod contract
60
+ event ModPublished(address indexed mod);
61
+
62
+ /// @notice Emitted when a mod's details, price, or required actions are updated
63
+ /// @param mod The address of the updated mod contract
64
+ event ModUpdated(address indexed mod);
65
+
66
+ // ==================== ERRORS ====================
67
+
68
+ /// @notice Caller is not authorized (not the mod seller)
69
+ error Unauthorized();
70
+
71
+ /// @notice Mod contract has already been published
72
+ error ModAlreadyPublished(address mod);
73
+
74
+ /// @notice Mod contract not found in marketplace
75
+ error ModNotFound(address mod);
76
+
77
+ /// @notice Hook identifier is not valid
78
+ error InvalidHook(ITotemTypes.Hook hook);
79
+
80
+ /// @notice Mod name cannot be empty
81
+ error EmptyModName();
82
+
83
+ /// @notice Mod summary cannot be empty
84
+ error EmptyModSummary();
85
+
86
+ /// @notice Mod name exceeds 100 character limit
87
+ error ModNameTooLong(uint256 length);
88
+
89
+ /// @notice Mod name is shorter than 3 characters
90
+ error ModNameTooShort(uint256 length);
91
+
92
+ /// @notice Mod summary exceeds 150 character limit
93
+ error ModSummaryTooLong(uint256 length);
94
+
95
+ /// @notice Mod summary is shorter than 10 characters
96
+ error ModSummaryTooShort(uint256 length);
97
+
98
+ /// @notice Mod image URL cannot be empty
99
+ error EmptyModImage();
100
+
101
+ /// @notice At least one hook must be specified
102
+ error NoHooksSpecified();
103
+
104
+ /// @notice Mod address is zero or not a contract
105
+ error InvalidContractAddress();
106
+
107
+ /// @notice Same hook specified multiple times
108
+ error DuplicateHook(ITotemTypes.Hook hook);
109
+
110
+ /// @notice Payment is less than required fee
111
+ error InsufficientFee(uint256 required, uint256 provided);
112
+
113
+ // ==================== EXTERNAL FUNCTIONS ====================
114
+
115
+ /**
116
+ * @notice Set your referrer fee for mod publishing
117
+ * @dev Referrers earn fees when users publish mods using their address.
118
+ * Fee must be at least minBaseFee. The referrer receives (fee - burnedFee).
119
+ * @param fee The fee amount in wei (must be >= minBaseFee)
120
+ */
121
+ function setReferrerFee(uint256 fee) external {
122
+ if(fee < minBaseFee){
123
+ revert Errors.ReferrerFeeTooLow(minBaseFee);
124
+ }
125
+ fees[msg.sender] = fee;
126
+ }
127
+
128
+ /**
129
+ * @notice Get the fee for creating a totem or publishing a mod
130
+ * @param referrer The referrer address (or zero address for no referrer)
131
+ * @return The total fee required (at least minBaseFee)
132
+ */
133
+ function getFee(address referrer) external view returns (uint256) {
134
+ if (referrer == address(0)) {
135
+ return minBaseFee;
136
+ }
137
+ uint256 referrerFee = fees[referrer];
138
+ return referrerFee > minBaseFee ? referrerFee : minBaseFee;
139
+ }
140
+
141
+ /**
142
+ * @notice Publish a mod to the marketplace
143
+ * @dev Only the mod's seller (returned by mod.getSeller()) can publish.
144
+ * Validates: contract exists, not already published, name 3-100 chars,
145
+ * summary 10-150 chars, image URL present, valid hooks with no duplicates.
146
+ * Excess payment is refunded to sender.
147
+ * @param mod Deployed mod contract address
148
+ * @param hooks Array of supported hook identifiers (Created, Mint, Burn, Transfer, TransferOwnership)
149
+ * @param price Price in wei to use this mod (paid when totem creator adds the mod)
150
+ * @param details Mod display details (name, summary, image, website, etc.)
151
+ * @param requiredActions Setup actions users must call after totem creation
152
+ * @param referrer Optional referrer address (receives fee minus burned amount)
153
+ */
154
+ function publish(
155
+ address mod,
156
+ ITotemTypes.Hook[] calldata hooks,
157
+ uint256 price,
158
+ ITotemTypes.ModDetails calldata details,
159
+ ITotemTypes.ModRequiredAction[] calldata requiredActions,
160
+ address payable referrer
161
+ ) external payable nonReentrant {
162
+ if (mod == address(0)) revert InvalidContractAddress();
163
+ if (mod.code.length == 0) revert InvalidContractAddress();
164
+ if (mods[mod].mod != address(0)) {
165
+ revert ModAlreadyPublished(mod);
166
+ }
167
+
168
+ IMod modContract = IMod(mod);
169
+ address payable seller = modContract.getSeller();
170
+ if (seller != msg.sender) {
171
+ revert Unauthorized();
172
+ }
173
+
174
+ _validateModDetails(details);
175
+ if (hooks.length == 0) revert NoHooksSpecified();
176
+
177
+ for (uint256 i = 0; i < hooks.length; i++) {
178
+ if (!_isValidHook(hooks[i])) {
179
+ revert InvalidHook(hooks[i]);
180
+ }
181
+ for (uint256 j = i + 1; j < hooks.length; j++) {
182
+ if (hooks[i] == hooks[j]) {
183
+ revert DuplicateHook(hooks[i]);
184
+ }
185
+ }
186
+ }
187
+
188
+ // Process fees and validate payment
189
+ uint256 totalFee = _processPublishFees(referrer);
190
+
191
+ // Store mod
192
+ ITotemTypes.Mod storage modData = mods[mod];
193
+ modData.mod = mod;
194
+ modData.seller = seller;
195
+ modData.price = price;
196
+ modData.details = details;
197
+ modData.hooks = hooks;
198
+ modData.publishedAt = uint64(block.timestamp);
199
+ modData.updatedAt = uint64(block.timestamp);
200
+
201
+ // Store required actions separately
202
+ for (uint256 i = 0; i < requiredActions.length; i++) {
203
+ modRequiredActions[mod].push(requiredActions[i]);
204
+ }
205
+
206
+ modList.push(mod);
207
+
208
+ // Refund excess payment
209
+ if (msg.value > totalFee) {
210
+ Shared.safeTransfer(msg.sender, msg.value - totalFee);
211
+ }
212
+
213
+ emit ModPublished(mod);
214
+ }
215
+
216
+ /**
217
+ * @notice Process publish fees - validates payment and distributes funds
218
+ * @dev Fee distribution:
219
+ * - No referrer: entire fee is burned (sent to address(0))
220
+ * - With referrer: burnedFee is burned, referrer gets (totalFee - burnedFee)
221
+ * The totalFee is max(referrer's fee, minBaseFee).
222
+ * @param referrer Optional referrer address (zero address = no referrer)
223
+ * @return totalFee The total fee charged
224
+ */
225
+ function _processPublishFees(address payable referrer) internal returns (uint256 totalFee) {
226
+ uint256 referrerFee = referrer != address(0) ? fees[referrer] : 0;
227
+ totalFee = referrerFee > minBaseFee ? referrerFee : minBaseFee;
228
+
229
+ if (msg.value < totalFee) {
230
+ revert InsufficientFee(totalFee, msg.value);
231
+ }
232
+
233
+ if (referrer == address(0)) {
234
+ // No referrer - burn the entire fee
235
+ Shared.safeTransfer(address(0), totalFee);
236
+ } else {
237
+ // burnedFee is always burned
238
+ if (burnedFee > 0) {
239
+ Shared.safeTransfer(address(0), burnedFee);
240
+ }
241
+ // Referrer gets the difference between totalFee and burnedFee
242
+ uint256 referrerAmount = totalFee - burnedFee;
243
+ if (referrerAmount > 0) {
244
+ Shared.safeTransfer(referrer, referrerAmount);
245
+ }
246
+ }
247
+ }
248
+
249
+ /**
250
+ * @notice Update mod price and details
251
+ * @param mod Mod contract address
252
+ * @param newPrice New price in wei
253
+ * @param details New mod details
254
+ */
255
+ function update(
256
+ address mod,
257
+ uint256 newPrice,
258
+ ITotemTypes.ModDetails calldata details
259
+ ) external nonReentrant {
260
+ ITotemTypes.Mod storage modData = mods[mod];
261
+
262
+ if (modData.mod == address(0)) revert ModNotFound(mod);
263
+ if (msg.sender != modData.seller) revert Unauthorized();
264
+
265
+ _validateModDetails(details);
266
+
267
+ modData.price = newPrice;
268
+ modData.details = details;
269
+ modData.updatedAt = uint64(block.timestamp);
270
+
271
+ emit ModUpdated(mod);
272
+ }
273
+
274
+ /**
275
+ * @notice Update required actions for a mod
276
+ * @param mod Mod contract address
277
+ * @param requiredActions New required setup actions
278
+ */
279
+ function updateRequiredActions(
280
+ address mod,
281
+ ITotemTypes.ModRequiredAction[] calldata requiredActions
282
+ ) external {
283
+ ITotemTypes.Mod storage modData = mods[mod];
284
+
285
+ if (modData.mod == address(0)) revert ModNotFound(mod);
286
+ if (msg.sender != modData.seller) revert Unauthorized();
287
+
288
+ // Clear existing required actions
289
+ delete modRequiredActions[mod];
290
+
291
+ // Store new required actions
292
+ for (uint256 i = 0; i < requiredActions.length; i++) {
293
+ modRequiredActions[mod].push(requiredActions[i]);
294
+ }
295
+
296
+ modData.updatedAt = uint64(block.timestamp);
297
+
298
+ emit ModUpdated(mod);
299
+ }
300
+
301
+ /**
302
+ * @notice Get multiple mods by their addresses
303
+ * @param contracts Array of mod contract addresses
304
+ * @return Array of Mod structs
305
+ */
306
+ function getMods(address[] calldata contracts) external view returns (ITotemTypes.Mod[] memory) {
307
+ // Count valid mods first
308
+ uint256 validCount = 0;
309
+ for (uint256 i = 0; i < contracts.length; i++) {
310
+ if (mods[contracts[i]].mod != address(0)) {
311
+ validCount++;
312
+ }
313
+ }
314
+
315
+ // Create result array with correct size
316
+ ITotemTypes.Mod[] memory result = new ITotemTypes.Mod[](validCount);
317
+ uint256 resultIndex = 0;
318
+
319
+ for (uint256 i = 0; i < contracts.length; i++) {
320
+ if (mods[contracts[i]].mod != address(0)) {
321
+ result[resultIndex] = mods[contracts[i]];
322
+ resultIndex++;
323
+ }
324
+ }
325
+
326
+ return result;
327
+ }
328
+
329
+ /**
330
+ * @notice List mods with pagination
331
+ * @param perPage Number of results per page
332
+ * @param cursor Index to start from (for pagination)
333
+ * @return mods_ Array of Mod structs
334
+ * @return nextCursor Next cursor value
335
+ * @return hasMore Whether more results exist
336
+ */
337
+ function listMods(
338
+ uint32 perPage,
339
+ uint256 cursor
340
+ ) external view returns (
341
+ ITotemTypes.Mod[] memory mods_,
342
+ uint256 nextCursor,
343
+ bool hasMore
344
+ ) {
345
+ uint256 startIndex = cursor;
346
+ uint256 endIndex = startIndex + perPage;
347
+
348
+ if (endIndex > modList.length) {
349
+ endIndex = modList.length;
350
+ }
351
+
352
+ uint256 resultCount = endIndex - startIndex;
353
+ mods_ = new ITotemTypes.Mod[](resultCount);
354
+
355
+ for (uint256 i = 0; i < resultCount; i++) {
356
+ mods_[i] = mods[modList[startIndex + i]];
357
+ }
358
+
359
+ nextCursor = endIndex;
360
+ hasMore = endIndex < modList.length;
361
+
362
+ return (mods_, nextCursor, hasMore);
363
+ }
364
+
365
+ /**
366
+ * @notice Get a single mod by address
367
+ * @param mod Mod contract address
368
+ * @return Mod struct
369
+ */
370
+ function getMod(address mod) external view returns (ITotemTypes.Mod memory) {
371
+ if (mods[mod].mod == address(0)) {
372
+ revert ModNotFound(mod);
373
+ }
374
+ return mods[mod];
375
+ }
376
+
377
+ /**
378
+ * @notice Get required actions for a mod (setup actions called after totem creation)
379
+ * @param mod Mod contract address
380
+ * @return Array of required actions
381
+ */
382
+ function getModRequiredActions(address mod) external view returns (ITotemTypes.ModRequiredAction[] memory) {
383
+ if (mods[mod].mod == address(0)) {
384
+ revert ModNotFound(mod);
385
+ }
386
+ return modRequiredActions[mod];
387
+ }
388
+
389
+ /**
390
+ * @notice Get the usage price for a single mod
391
+ * @param mod Mod contract address
392
+ * @return The price in wei to use this mod
393
+ */
394
+ function getModFee(address mod) external view returns (uint256) {
395
+ if (mods[mod].mod == address(0)) {
396
+ revert ModNotFound(mod);
397
+ }
398
+ return mods[mod].price;
399
+ }
400
+
401
+ /**
402
+ * @notice Get the total usage price for multiple mods
403
+ * @dev Useful for calculating total cost when creating a totem with multiple mods
404
+ * @param contracts Array of mod contract addresses
405
+ * @return Total price in wei for all mods combined
406
+ */
407
+ function getModsFee(address[] calldata contracts) external view returns (uint256) {
408
+ uint256 fee = 0;
409
+ for (uint256 i = 0; i < contracts.length; i++) {
410
+ if (mods[contracts[i]].mod == address(0)) {
411
+ revert ModNotFound(contracts[i]);
412
+ }
413
+ fee += mods[contracts[i]].price;
414
+ }
415
+ return fee;
416
+ }
417
+
418
+ /**
419
+ * @notice Get the hooks supported by a mod
420
+ * @dev Hooks determine when the mod is called (Created, Mint, Burn, Transfer, TransferOwnership)
421
+ * @param mod Mod contract address
422
+ * @return Array of Hook enum values
423
+ */
424
+ function getSupportedHooks(address mod) external view returns (ITotemTypes.Hook[] memory) {
425
+ if (mods[mod].mod == address(0)) {
426
+ revert ModNotFound(mod);
427
+ }
428
+ return mods[mod].hooks;
429
+ }
430
+
431
+ /**
432
+ * @notice Check if a mod requires unlimited minting capability
433
+ * @dev Mods with needsUnlimited=true can mint tokens without supply cap restrictions.
434
+ * Returns false for unpublished mods.
435
+ * @param mod Mod contract address
436
+ * @return True if the mod needs unlimited minting capability
437
+ */
438
+ function isUnlimitedMinter(address mod) external view returns (bool) {
439
+ if(mods[mod].publishedAt == 0) return false;
440
+ return mods[mod].details.needsUnlimited;
441
+ }
442
+
443
+ /**
444
+ * @notice Validate mod details (name, summary, image)
445
+ * @dev Reverts if any validation fails
446
+ * @param details The mod details to validate
447
+ */
448
+ function _validateModDetails(ITotemTypes.ModDetails calldata details) internal pure {
449
+ if (bytes(details.name).length == 0) revert EmptyModName();
450
+ if (bytes(details.name).length < 3) revert ModNameTooShort(bytes(details.name).length);
451
+ if (bytes(details.name).length > 100) revert ModNameTooLong(bytes(details.name).length);
452
+ if (bytes(details.summary).length == 0) revert EmptyModSummary();
453
+ if (bytes(details.summary).length < 10) revert ModSummaryTooShort(bytes(details.summary).length);
454
+ if (bytes(details.summary).length > 150) revert ModSummaryTooLong(bytes(details.summary).length);
455
+ if (bytes(details.image).length == 0) revert EmptyModImage();
456
+ }
457
+
458
+ /**
459
+ * @notice Check if a hook is valid
460
+ * @param hook The hook identifier to validate
461
+ * @return bool True if hook is valid
462
+ */
463
+ function _isValidHook(ITotemTypes.Hook hook) internal pure returns (bool) {
464
+ return uint8(hook) <= uint8(ITotemTypes.Hook.TransferOwnership);
465
+ }
466
+
467
+ }
@@ -0,0 +1,17 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ pragma solidity ^0.8.28;
3
+
4
+ /**
5
+ * @title ReentrancyGuard
6
+ * @notice Simple reentrancy guard to prevent reentrant calls
7
+ */
8
+ abstract contract ReentrancyGuard {
9
+ bool private _locked;
10
+
11
+ modifier nonReentrant() {
12
+ require(!_locked, "ReentrancyGuard: reentrant call");
13
+ _locked = true;
14
+ _;
15
+ _locked = false;
16
+ }
17
+ }
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "../library/ITotemTypes.sol";
5
+
6
+ /**
7
+ * @title Shared
8
+ * @notice Shared utilities for fee management and token handling
9
+ * @dev Contains common functions used across Totems contracts
10
+ */
11
+ library Shared {
12
+
13
+ // ==================== ERRORS ====================
14
+
15
+ /// @notice ETH transfer failed
16
+ error TransferFailed();
17
+
18
+ // ==================== FUNCTIONS ====================
19
+
20
+ /**
21
+ * @notice Safely transfer ETH to an address
22
+ * @dev Uses low-level call instead of transfer() to support smart contract recipients.
23
+ * transfer() only forwards 2300 gas which fails for contracts with receive() logic.
24
+ * @param to Recipient address
25
+ * @param amount Amount of ETH to send in wei
26
+ */
27
+ function safeTransfer(address to, uint256 amount) internal {
28
+ (bool success, ) = payable(to).call{value: amount}("");
29
+ if (!success) revert TransferFailed();
30
+ }
31
+
32
+ /**
33
+ * @notice Dispense tokens to multiple recipients
34
+ * @param disbursements Array of fee disbursements
35
+ * @dev Transfers native currency to recipients, skips zero-amount disbursements
36
+ */
37
+ function dispenseTokens(ITotemTypes.FeeDisbursement[] memory disbursements) internal {
38
+ for (uint256 i = 0; i < disbursements.length; i++) {
39
+ ITotemTypes.FeeDisbursement memory disbursement = disbursements[i];
40
+ if (disbursement.amount > 0) {
41
+ safeTransfer(disbursement.recipient, disbursement.amount);
42
+ }
43
+ }
44
+ }
45
+ }