-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTicketFactory.sol
More file actions
629 lines (540 loc) · 20.8 KB
/
TicketFactory.sol
File metadata and controls
629 lines (540 loc) · 20.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "./TicketNFT.sol";
/**
* @title TicketFactory
* @notice Main contract for creating and managing events, ticket types, and primary ticket sales.
* @dev MVP focuses on native-currency purchases on the deployment network.
* - Future support for ERC-20 and cross-chain routing can be added without breaking public interfaces.
* - Uses Checks-Effects-Interactions and ReentrancyGuard for value transfers.
* - Mints ERC-721 tickets via TicketNFT; marks tickets used via TicketNFT.
*/
contract TicketFactory is Ownable, ReentrancyGuard {
using Strings for uint256;
// ========= Data Structures =========
/**
* @notice Event definition
*/
struct EventData {
uint256 eventId;
address organizer;
string name;
string description;
uint256 startTime;
uint256 endTime;
string venue;
string imageIpfsHash;
uint256 totalSupply; // Cap across all ticket types
uint256 sold; // Total sold across all ticket types
bool active;
uint96 royaltyBps; // Optional default royalty (EIP-2981) applied per minted ticket
}
/**
* @notice Ticket type/tier within an event (e.g., VIP, General)
*/
struct TicketType {
uint256 eventId;
string name;
uint256 price; // In native currency for MVP
uint256 supply; // Max supply for this ticket type
uint256 sold; // Number sold for this ticket type
string imageIpfsHash; // IPFS hash for tier-specific NFT image
}
/**
* @notice Input struct for creating ticket types
* @dev Used when creating multiple ticket types at once
*/
struct TicketTypeInput {
string name;
uint256 price;
uint256 supply;
string imageIpfsHash; // IPFS hash for tier-specific NFT image
}
// ========= Storage =========
/// @notice Mapping eventId => EventData
mapping(uint256 => EventData) public events;
/// @notice Mapping eventId => array of ticket types
mapping(uint256 => TicketType[]) private _ticketTypes;
/// @notice Mapping organizer => list of eventIds
mapping(address => uint256[]) private _organizerEvents;
/// @notice Auto-incrementing event id
uint256 public eventCounter;
/// @notice Proceeds held in factory for each event (native currency)
mapping(uint256 => uint256) public eventProceedsNative;
/// @notice Ticket NFT contract used for minting and validation
TicketNFT public ticketNFT;
// ========= Custom Errors =========
error InvalidInput();
error InvalidEvent();
error NotOrganizer();
error Unauthorized();
error EventInactive();
error EventNotLive();
error SoldOut();
error IncorrectPayment();
error NothingToWithdraw();
error WithdrawalFailed();
error InvalidTicketType();
// ========= Events =========
event EventCreated(
uint256 indexed eventId,
address indexed organizer,
string name,
uint256 startTime,
uint256 endTime,
uint256 totalSupply,
uint96 royaltyBps
);
event TicketTypeAdded(
uint256 indexed eventId,
uint256 indexed ticketTypeId,
string name,
uint256 price,
uint256 supply
);
event TicketPurchased(
uint256 indexed eventId,
uint256 indexed ticketTypeId,
uint256 indexed tokenId,
address buyer,
uint256 price
);
event EventStatusToggled(uint256 indexed eventId, bool active);
event FundsWithdrawn(uint256 indexed eventId, address indexed organizer, uint256 amount, address to);
event NFTContractUpdated(address indexed nft);
// ========= Constructor =========
/**
* @param nft Address of the TicketNFT contract
*/
constructor(address nft) Ownable(msg.sender) {
if (nft == address(0)) revert InvalidInput();
ticketNFT = TicketNFT(nft);
emit NFTContractUpdated(nft);
}
// ========= Modifiers =========
modifier onlyOrganizer(uint256 eventId) {
if (events[eventId].organizer != msg.sender) revert NotOrganizer();
_;
}
modifier validEvent(uint256 eventId) {
if (eventId == 0 || eventId > eventCounter) revert InvalidEvent();
_;
}
// ========= Admin =========
/**
* @notice Update the NFT contract address
* @dev Requires owner; use carefully and update the NFT's factory separately via TicketNFT.setFactory
*/
function setTicketNFT(address nft) external onlyOwner {
if (nft == address(0)) revert InvalidInput();
ticketNFT = TicketNFT(nft);
emit NFTContractUpdated(nft);
}
// ========= Event Management =========
/**
* @notice Create a new event with optional initial ticket types
* @param name_ Name of the event
* @param description_ Description of the event
* @param startTime_ Start time (unix)
* @param endTime_ End time (unix)
* @param venue_ Venue/location
* @param imageIpfsHash_ IPFS hash for the event image
* @param totalSupply_ Global cap across all ticket types (must be > 0)
* @param royaltyBps_ Default royalty (basis points) for NFTs minted for this event
* @param initialTicketTypes_ Array of ticket types to add during event creation (can be empty)
* @return eventId The id of the created event
*/
function createEvent(
string memory name_,
string memory description_,
uint256 startTime_,
uint256 endTime_,
string memory venue_,
string memory imageIpfsHash_,
uint256 totalSupply_,
uint96 royaltyBps_,
TicketTypeInput[] memory initialTicketTypes_
) external returns (uint256 eventId) {
if (
bytes(name_).length == 0 ||
bytes(description_).length == 0 ||
bytes(venue_).length == 0 ||
bytes(imageIpfsHash_).length == 0 ||
totalSupply_ == 0 ||
endTime_ <= startTime_ ||
startTime_ == 0
) {
revert InvalidInput();
}
eventId = ++eventCounter;
events[eventId] = EventData({
eventId: eventId,
organizer: msg.sender,
name: name_,
description: description_,
startTime: startTime_,
endTime: endTime_,
venue: venue_,
imageIpfsHash: imageIpfsHash_,
totalSupply: totalSupply_,
sold: 0,
active: true,
royaltyBps: royaltyBps_
});
_organizerEvents[msg.sender].push(eventId);
emit EventCreated(eventId, msg.sender, name_, startTime_, endTime_, totalSupply_, royaltyBps_);
// Add initial ticket types if provided
for (uint256 i = 0; i < initialTicketTypes_.length; i++) {
TicketTypeInput memory input = initialTicketTypes_[i];
if (bytes(input.name).length == 0 || input.price == 0 || input.supply == 0 || bytes(input.imageIpfsHash).length == 0) {
revert InvalidInput();
}
TicketType memory tt = TicketType({
eventId: eventId,
name: input.name,
price: input.price,
supply: input.supply,
sold: 0,
imageIpfsHash: input.imageIpfsHash
});
_ticketTypes[eventId].push(tt);
emit TicketTypeAdded(eventId, i, input.name, input.price, input.supply);
}
}
/**
* @notice Add a ticket type to an existing event
* @param eventId Event id
* @param name_ Ticket type name
* @param price_ Price in native currency
* @param supply_ Supply cap for this ticket type
* @param imageIpfsHash_ IPFS hash for tier-specific NFT image
* @return ticketTypeId Index of the newly created ticket type
*/
function addTicketType(
uint256 eventId,
string memory name_,
uint256 price_,
uint256 supply_,
string memory imageIpfsHash_
) external validEvent(eventId) onlyOrganizer(eventId) returns (uint256 ticketTypeId) {
if (bytes(name_).length == 0 || price_ == 0 || supply_ == 0 || bytes(imageIpfsHash_).length == 0) revert InvalidInput();
TicketType memory tt = TicketType({
eventId: eventId,
name: name_,
price: price_,
supply: supply_,
sold: 0,
imageIpfsHash: imageIpfsHash_
});
_ticketTypes[eventId].push(tt);
ticketTypeId = _ticketTypes[eventId].length - 1;
emit TicketTypeAdded(eventId, ticketTypeId, name_, price_, supply_);
}
/**
* @notice Toggle event status (active/inactive)
* @param eventId Event id
*/
function toggleEventStatus(uint256 eventId)
external
validEvent(eventId)
onlyOrganizer(eventId)
{
EventData storage ev = events[eventId];
ev.active = !ev.active;
emit EventStatusToggled(eventId, ev.active);
}
// ========= Purchasing =========
/**
* @notice Purchase a ticket for an event and ticket type (no metadata URI provided)
* @dev For metadata, frontend can call setTokenURI on TicketNFT after upload
*/
function purchaseTicket(uint256 eventId, uint256 ticketTypeId)
external
payable
validEvent(eventId)
nonReentrant
returns (uint256 tokenId)
{
return _purchase(eventId, ticketTypeId, "");
}
/**
* @notice Purchase multiple tickets for an event and ticket type
* @param eventId The event id
* @param ticketTypeId The ticket type id
* @param quantity The number of tickets to purchase
* @return tokenIds An array of the newly minted token ids
*/
function purchaseTickets(uint256 eventId, uint256 ticketTypeId, uint256 quantity)
external
payable
validEvent(eventId)
nonReentrant
returns (uint256[] memory tokenIds)
{
return _purchaseMultiple(eventId, ticketTypeId, quantity);
}
/**
* @notice Purchase a ticket and set the token URI at mint time
* @param tokenURI_ IPFS metadata URI (e.g., ipfs://CID)
*/
function purchaseTicketWithURI(
uint256 eventId,
uint256 ticketTypeId,
string memory tokenURI_
)
external
payable
validEvent(eventId)
nonReentrant
returns (uint256 tokenId)
{
if (bytes(tokenURI_).length == 0) revert InvalidInput();
return _purchase(eventId, ticketTypeId, tokenURI_);
}
function _purchase(uint256 eventId, uint256 ticketTypeId, string memory tokenURI_)
internal
returns (uint256 tokenId)
{
EventData storage ev = events[eventId];
if (!ev.active) revert EventInactive();
// Allow pre-sales before the event starts; only restrict purchases after the event ends
if (block.timestamp > ev.endTime) revert EventNotLive();
if (ticketTypeId >= _ticketTypes[eventId].length) revert InvalidTicketType();
TicketType storage tt = _ticketTypes[eventId][ticketTypeId];
if (tt.sold >= tt.supply) revert SoldOut();
if (ev.sold >= ev.totalSupply) revert SoldOut();
if (msg.value != tt.price) revert IncorrectPayment();
// Effects
unchecked {
tt.sold += 1;
ev.sold += 1;
}
eventProceedsNative[eventId] += msg.value;
// Build TicketMetadata per spec
TicketNFT.TicketMetadata memory meta = TicketNFT.TicketMetadata({
eventId: eventId,
ticketTypeId: ticketTypeId,
originalOwner: msg.sender,
purchasePrice: msg.value,
purchaseChain: _chainString(),
used: false,
qrCodeHash: "" // Frontend can compute/store separately; optional update later
});
// Interactions: mint NFT to buyer
address royaltyReceiver = ev.organizer;
uint96 royaltyBps = ev.royaltyBps;
tokenId = ticketNFT.mint(
msg.sender,
meta,
tokenURI_,
royaltyReceiver,
royaltyBps
);
emit TicketPurchased(eventId, ticketTypeId, tokenId, msg.sender, msg.value);
}
function _purchaseMultiple(uint256 eventId, uint256 ticketTypeId, uint256 quantity)
internal
returns (uint256[] memory tokenIds)
{
EventData storage ev = events[eventId];
if (!ev.active) revert EventInactive();
if (block.timestamp > ev.endTime) revert EventNotLive();
if (ticketTypeId >= _ticketTypes[eventId].length) revert InvalidTicketType();
TicketType storage tt = _ticketTypes[eventId][ticketTypeId];
if (tt.sold + quantity > tt.supply) revert SoldOut();
if (ev.sold + quantity > ev.totalSupply) revert SoldOut();
if (msg.value != tt.price * quantity) revert IncorrectPayment();
// Effects
unchecked {
tt.sold += quantity;
ev.sold += quantity;
}
eventProceedsNative[eventId] += msg.value;
tokenIds = new uint256[](quantity);
address royaltyReceiver = ev.organizer;
uint96 royaltyBps = ev.royaltyBps;
for (uint256 i = 0; i < quantity; i++) {
TicketNFT.TicketMetadata memory meta = TicketNFT.TicketMetadata({
eventId: eventId,
ticketTypeId: ticketTypeId,
originalOwner: msg.sender,
purchasePrice: tt.price, // Price per ticket
purchaseChain: _chainString(),
used: false,
qrCodeHash: ""
});
uint256 tokenId = ticketNFT.mint(msg.sender, meta, "", royaltyReceiver, royaltyBps);
tokenIds[i] = tokenId;
emit TicketPurchased(eventId, ticketTypeId, tokenId, msg.sender, tt.price);
}
}
// ========= Withdrawals =========
/**
* @notice Withdraw native proceeds for an event to a specified address
* @param eventId Event id
* @param to Recipient address
*/
function withdrawFunds(uint256 eventId, address payable to)
external
validEvent(eventId)
onlyOrganizer(eventId)
nonReentrant
{
if (to == address(0)) revert InvalidInput();
uint256 amount = eventProceedsNative[eventId];
if (amount == 0) revert NothingToWithdraw();
eventProceedsNative[eventId] = 0;
(bool ok, ) = to.call{value: amount}("");
if (!ok) {
// Restore state if transfer failed
eventProceedsNative[eventId] = amount;
revert WithdrawalFailed();
}
emit FundsWithdrawn(eventId, msg.sender, amount, to);
}
// ========= Views =========
/**
* @notice Get event details
*/
function getEvent(uint256 eventId) external view validEvent(eventId) returns (EventData memory) {
return events[eventId];
}
/**
* @notice Get multiple events in a single call
* @dev Optimized for frontend: fetch all events needed for ticket display at once
* @param eventIds Array of event IDs to retrieve
* @return eventsData Array of event data (same order as input)
* @return validFlags Array indicating which events exist (true) or don't exist (false)
*/
function getEventsBatch(uint256[] calldata eventIds)
external
view
returns (EventData[] memory eventsData, bool[] memory validFlags)
{
eventsData = new EventData[](eventIds.length);
validFlags = new bool[](eventIds.length);
for (uint256 i = 0; i < eventIds.length; i++) {
uint256 eventId = eventIds[i];
// Check if event exists (eventId > 0 and <= eventCounter)
if (eventId > 0 && eventId <= eventCounter) {
eventsData[i] = events[eventId];
validFlags[i] = true;
} else {
validFlags[i] = false;
}
}
}
/**
* @notice Get all ticket types for an event
*/
function getTicketTypes(uint256 eventId) external view validEvent(eventId) returns (TicketType[] memory) {
return _ticketTypes[eventId];
}
/**
* @notice Get ticket types for multiple events in a single call
* @dev Optimized for frontend: fetch all ticket types needed at once
* @param eventIds Array of event IDs
* @return ticketTypesArray Array of ticket type arrays (same order as input)
*/
function getTicketTypesBatch(uint256[] calldata eventIds)
external
view
returns (TicketType[][] memory ticketTypesArray)
{
ticketTypesArray = new TicketType[][](eventIds.length);
for (uint256 i = 0; i < eventIds.length; i++) {
uint256 eventId = eventIds[i];
// Only return ticket types for valid events
if (eventId > 0 && eventId <= eventCounter) {
ticketTypesArray[i] = _ticketTypes[eventId];
} else {
// Return empty array for invalid events
ticketTypesArray[i] = new TicketType[](0);
}
}
}
/**
* @notice Get events created by an organizer
*/
function getOrganizerEvents(address organizer) external view returns (uint256[] memory) {
return _organizerEvents[organizer];
}
/**
* @notice Check if an address is the organizer of an event
* @param eventId The event id to check
* @param addr The address to check
* @return bool True if the address is the event organizer
*/
function isEventOrganizer(uint256 eventId, address addr)
external
view
validEvent(eventId)
returns (bool)
{
return events[eventId].organizer == addr;
}
/**
* @notice Mark a ticket as validated (used) by the event organizer
* @param eventId The event id the ticket belongs to
* @param tokenId The token id of the ticket to validate
*/
function validateTicket(uint256 eventId, uint256 tokenId)
external
validEvent(eventId)
onlyOrganizer(eventId)
{
// The main check is that only the organizer of *this* event can initiate validation.
// We also check that the ticket belongs to the event to prevent cross-event validation.
(uint256 evId,,,,,,) = ticketNFT.ticketDetails(tokenId);
if (evId != eventId) revert InvalidEvent();
ticketNFT.validateTicket(tokenId);
}
/**
* @notice Set or update the tokenURI (metadata) for a ticket
* @dev Can be called by ticket owner or event organizer to add/update metadata post-purchase
* @param tokenId The token id to update
* @param tokenURI_ New IPFS metadata URI (e.g., ipfs://CID)
*/
function setTicketURI(uint256 tokenId, string memory tokenURI_) external {
if (bytes(tokenURI_).length == 0) revert InvalidInput();
// Get ticket owner and event info
address owner = ticketNFT.ownerOf(tokenId);
(uint256 eventId,,,,,,) = ticketNFT.ticketDetails(tokenId);
// Only ticket owner or event organizer can set metadata
if (msg.sender != owner && msg.sender != events[eventId].organizer) {
revert Unauthorized();
}
ticketNFT.setTokenURI(tokenId, tokenURI_);
}
/**
* @notice Batch set tokenURIs for multiple tickets (gas-efficient for organizers)
* @dev Useful for organizers to add metadata to multiple tickets at once
* @param tokenIds Array of token IDs
* @param tokenURIs Array of IPFS URIs (must match tokenIds length)
*/
function setTicketURIBatch(uint256[] calldata tokenIds, string[] calldata tokenURIs) external {
if (tokenIds.length != tokenURIs.length) revert InvalidInput();
for (uint256 i = 0; i < tokenIds.length; i++) {
if (bytes(tokenURIs[i]).length == 0) revert InvalidInput();
address owner = ticketNFT.ownerOf(tokenIds[i]);
(uint256 eventId,,,,,,) = ticketNFT.ticketDetails(tokenIds[i]);
// Only ticket owner or event organizer can set metadata
if (msg.sender != owner && msg.sender != events[eventId].organizer) {
revert Unauthorized();
}
ticketNFT.setTokenURI(tokenIds[i], tokenURIs[i]);
}
}
// ========= Internal Utilities =========
function _chainString() internal view returns (string memory) {
// Example: "chain-<chainId>"
// To align to spec "purchaseChain" string, we encode chainId as decimal
return string(abi.encodePacked("chain:", block.chainid.toString()));
}
// ========= Receive / Fallback =========
receive() external payable {}
fallback() external payable {}
}