From 4559f7d722bbbd27b12d713f97dc01c4ba912b59 Mon Sep 17 00:00:00 2001 From: magent Date: Mon, 8 Jun 2026 11:59:47 -0400 Subject: [PATCH 1/4] Here's what we have for ticket stub claiming at the moment. --- contracts/TicketStubClaimer.sol | 316 ++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 contracts/TicketStubClaimer.sol diff --git a/contracts/TicketStubClaimer.sol b/contracts/TicketStubClaimer.sol new file mode 100644 index 00000000..feade33c --- /dev/null +++ b/contracts/TicketStubClaimer.sol @@ -0,0 +1,316 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; + +/** + * @title TicketStubClaimer + * @dev NFT contract for claiming physical ticket stubs with secrets + * + * This contract bridges physical ticket stubs distributed at shows to digital NFTs. + * Each ticket stub contains a secret that can be used to claim the corresponding NFT. + * This replaces the previous "rabbit secrets" paradigm for set stone minting. + * + * QR codes on ticket stubs point to: + * https://cryptograss.live/blox-office/ticketstubs/claim/{tokenId}?secret={secret} + */ +contract TicketStubClaimer is ERC721, Ownable, ReentrancyGuard { + + /// @dev Struct to store ticket stub data with embedded show information + struct TicketStub { + uint8[] bandIds; // Array of band IDs for this show (e.g., [0, 7]) + uint32 blockheight; // Show blockheight (e.g., 22748946) + bytes32 secretHash; // Keccak256 hash of the secret from physical ticket + address claimedBy; // Address that claimed it (address(0) if unclaimed) + uint32 claimedAt; // Block number when claimed + bool exists; // Whether this token exists + } + + /// @dev Mapping from token ID to ticket stub data + mapping(uint32 => TicketStub) public ticketStubs; + + /// @dev Mapping from band ID to array of token IDs (for reverse lookup) + mapping(uint8 => uint32[]) public tokensByBand; + + /// @dev Mapping from blockheight to array of token IDs (for reverse lookup) + mapping(uint32 => uint32[]) public tokensByBlockheight; + + /// @dev Next available token ID (starts at 0) + uint32 public nextTokenId = 0; + + /// @dev Emitted when new ticket stubs are created for a show + event TicketStubCreated(uint32 indexed tokenId, uint8[] bandIds, uint32 indexed blockheight, bytes32 secretHash); + + /// @dev Emitted when a ticket stub is successfully claimed + event TicketStubClaimed(uint32 indexed tokenId, address indexed claimedBy); + + /// @dev Emitted when ticket stubs are created for a show + event ShowTicketStubsCreated(uint8[] bandIds, uint32 indexed blockheight, uint32 count); + + constructor() ERC721("Cryptograss Ticket Stub", "CGTS") {} + + /** + * @dev Creates ticket stubs for a show with pre-computed secret hashes + * @param bandIds Array of band IDs participating in this show (uint8 for gas efficiency) + * @param blockheight The blockheight of the show + * @param secretHashes Array of keccak256 hashes of the secrets + * + * Only the contract owner can create ticket stubs. The secrets should be + * generated off-chain and their hashes stored here for claiming. + */ + function createTicketStubsForShow( + uint8[] calldata bandIds, + uint32 blockheight, + bytes32[] calldata secretHashes + ) external onlyOwner { + require(secretHashes.length > 0, "Must create at least one ticket stub"); + require(bandIds.length > 0, "Must have at least one band"); + require(blockheight > 0, "Blockheight must be positive"); + + // Create ticket stubs + for (uint i = 0; i < secretHashes.length; i++) { + require(secretHashes[i] != bytes32(0), "Secret hash cannot be zero"); + + uint32 tokenId = nextTokenId++; + + ticketStubs[tokenId] = TicketStub({ + bandIds: bandIds, + blockheight: blockheight, + secretHash: secretHashes[i], + claimedBy: address(0), + claimedAt: 0, + exists: true + }); + + // Add to reverse lookup mappings + for (uint j = 0; j < bandIds.length; j++) { + tokensByBand[bandIds[j]].push(tokenId); + } + tokensByBlockheight[blockheight].push(tokenId); + + emit TicketStubCreated(tokenId, bandIds, blockheight, secretHashes[i]); + } + + emit ShowTicketStubsCreated(bandIds, blockheight, uint32(secretHashes.length)); + } + + /** + * @dev Claims a ticket stub using the secret from the physical ticket + * @param tokenId The ID of the ticket stub to claim + * @param secret The secret printed on the physical ticket stub + * + * The secret is hashed and compared against the stored hash. If valid, + * the NFT is minted to the caller's address. + */ + function claimTicketStub(uint32 tokenId, string calldata secret) external nonReentrant { + require(ticketStubs[tokenId].exists, "Ticket stub does not exist"); + require(ticketStubs[tokenId].claimedBy == address(0), "Ticket stub already claimed"); + require(bytes(secret).length > 0, "Secret cannot be empty"); + + bytes32 hashedSecret = keccak256(abi.encodePacked(secret)); + require(ticketStubs[tokenId].secretHash == hashedSecret, "Invalid secret"); + + // Mark as claimed + ticketStubs[tokenId].claimedBy = msg.sender; + ticketStubs[tokenId].claimedAt = uint32(block.number); + + // Mint the NFT to the claimer + _mint(msg.sender, tokenId); + + emit TicketStubClaimed(tokenId, msg.sender); + } + + /** + * @dev Checks if a ticket stub can be claimed with the given secret + * @param tokenId The ID of the ticket stub to check + * @param secret The secret to validate + * @return true if the ticket stub can be claimed with this secret + */ + function canClaim(uint32 tokenId, string calldata secret) external view returns (bool) { + if (!ticketStubs[tokenId].exists) return false; + if (ticketStubs[tokenId].claimedBy != address(0)) return false; + if (bytes(secret).length == 0) return false; + + bytes32 hashedSecret = keccak256(abi.encodePacked(secret)); + return ticketStubs[tokenId].secretHash == hashedSecret; + } + + /** + * @dev Returns the full ticket stub data for a given token ID + * @param tokenId The token ID to query + * @return The TicketStub struct containing all data + */ + function getTicketStub(uint32 tokenId) external view returns (TicketStub memory) { + require(ticketStubs[tokenId].exists, "Ticket stub does not exist"); + return ticketStubs[tokenId]; + } + + /** + * @dev Returns all ticket stub token IDs for a given band + * @param bandId The band identifier to query + * @return Array of token IDs for the specified band + */ + function getTicketStubsForBand(uint8 bandId) external view returns (uint32[] memory) { + return tokensByBand[bandId]; + } + + /** + * @dev Returns all ticket stub token IDs for a given blockheight + * @param blockheight The blockheight to query + * @return Array of token IDs for the specified blockheight + */ + function getTicketStubsForBlockheight(uint32 blockheight) external view returns (uint32[] memory) { + return tokensByBlockheight[blockheight]; + } + + /** + * @dev Returns all ticket stub token IDs for a specific show (band combination + blockheight) + * @param bandIds Array of band IDs for the show + * @param blockheight The blockheight of the show + * @return Array of token IDs for the specified show + */ + function getTicketStubsForShow(uint8[] calldata bandIds, uint32 blockheight) external view returns (uint32[] memory) { + uint32[] memory allTokensAtBlock = tokensByBlockheight[blockheight]; + uint32[] memory results = new uint32[](allTokensAtBlock.length); + uint32 count = 0; + + for (uint i = 0; i < allTokensAtBlock.length; i++) { + uint32 tokenId = allTokensAtBlock[i]; + if (ticketStubs[tokenId].exists && _arraysEqual(ticketStubs[tokenId].bandIds, bandIds)) { + results[count] = tokenId; + count++; + } + } + + // Resize array to actual count + uint32[] memory finalResults = new uint32[](count); + for (uint i = 0; i < count; i++) { + finalResults[i] = results[i]; + } + + return finalResults; + } + + /** + * @dev Helper function to compare two uint8 arrays + */ + function _arraysEqual(uint8[] memory a, uint8[] calldata b) internal pure returns (bool) { + if (a.length != b.length) return false; + for (uint i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + /** + * @dev Returns the total number of ticket stubs created + * @return The number of ticket stubs that have been created + */ + function totalSupply() external view returns (uint32) { + return nextTokenId; + } + + /** + * @dev Returns the number of claimed ticket stubs for a show + * @param bandIds Array of band IDs for the show + * @param blockheight The blockheight of the show + * @return The number of claimed ticket stubs for the show + */ + function getClaimedCountForShow(uint8[] calldata bandIds, uint32 blockheight) external view returns (uint32) { + uint32[] memory showTokens = this.getTicketStubsForShow(bandIds, blockheight); + uint32 count = 0; + + for (uint i = 0; i < showTokens.length; i++) { + if (ticketStubs[showTokens[i]].claimedBy != address(0)) { + count++; + } + } + return count; + } + + /** + * @dev Returns the total count of ticket stubs for a show (claimed + unclaimed) + * @param bandIds Array of band IDs for the show + * @param blockheight The blockheight of the show + * @return The total number of ticket stubs created for the show + */ + function getTotalCountForShow(uint8[] calldata bandIds, uint32 blockheight) external view returns (uint32) { + return uint32(this.getTicketStubsForShow(bandIds, blockheight).length); + } + + /** + * @dev Check if a specific show has ticket stubs available + * @param bandIds Array of band IDs for the show + * @param blockheight The blockheight of the show + * @return true if ticket stubs exist for this show + */ + function hasTicketStubsForShow(uint8[] calldata bandIds, uint32 blockheight) external view returns (bool) { + return this.getTicketStubsForShow(bandIds, blockheight).length > 0; + } + + /** + * @dev Generate a rabbit hash for compatibility with existing infrastructure + * @param tokenId The token ID to generate hash for + * @return The rabbit hash (first 8 bytes of secretHash as bytes32) + */ + function getRabbitHash(uint32 tokenId) external view returns (bytes32) { + require(ticketStubs[tokenId].exists, "Ticket stub does not exist"); + + // Take first 8 bytes of the secret hash for display purposes + bytes32 secretHash = ticketStubs[tokenId].secretHash; + return bytes32(uint256(secretHash) & 0xFFFFFFFFFFFFFFFF); + } + + /** + * @dev Override tokenURI to point to ticket stub metadata + * @param tokenId The token ID to get URI for + * @return The metadata URI for the token + */ + function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { + require(tokenId < nextTokenId, "URI query for nonexistent token"); + require(_exists(tokenId), "URI query for nonexistent token"); + + string memory baseURI = _baseURI(); + return bytes(baseURI).length > 0 + ? string(abi.encodePacked(baseURI, _toString(tokenId))) + : ""; + } + + /** + * @dev Set the base URI for token metadata + * @param baseURI The new base URI + */ + function setBaseURI(string calldata baseURI) external onlyOwner { + _baseTokenURI = baseURI; + } + + string private _baseTokenURI; + + function _baseURI() internal view virtual override returns (string memory) { + return _baseTokenURI; + } + + /** + * @dev Internal function to convert uint to string + */ + function _toString(uint256 value) internal pure returns (string memory) { + if (value == 0) { + return "0"; + } + uint256 temp = value; + uint256 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + bytes memory buffer = new bytes(digits); + while (value != 0) { + digits -= 1; + buffer[digits] = bytes1(uint8(48 + uint256(value % 10))); + value /= 10; + } + return string(buffer); + } +} \ No newline at end of file From 61db07698ef9fb32b3cfc78006e585fc6ed7be03 Mon Sep 17 00:00:00 2001 From: Justin Holmes Date: Fri, 26 Jun 2026 14:28:09 +0000 Subject: [PATCH 2/4] TicketStubClaimer: add claimTicketStubFor for relayer/gasless claims Sibling method to claimTicketStub that takes a recipient address parameter and mints to that address rather than msg.sender. Enables a gas-paying relayer to submit claims on behalf of holders who don't have funds on the deployment chain. The secret remains the bearer instrument; whoever holds it can authorize a mint to any address. Same validation order as claimTicketStub, plus a zero-address check on recipient. Mock + 10 tests added covering recipient routing, validation failures, and double-claim prevention across both entry points. All 32 tests pass. --- __tests__/ticket_stub_claimer_test.js | 122 +++++++++++++++++++++++++- contracts/TicketStubClaimer.sol | 33 ++++++- 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/__tests__/ticket_stub_claimer_test.js b/__tests__/ticket_stub_claimer_test.js index 9f0e3d67..0e157775 100644 --- a/__tests__/ticket_stub_claimer_test.js +++ b/__tests__/ticket_stub_claimer_test.js @@ -86,7 +86,44 @@ class MockTicketStubClaimer { stub.claimedBy = caller; stub.claimedAt = Date.now(); // Mock block number this.usedSecrets.add(hashedSecret); - + + return true; + } + + claimTicketStubFor(tokenId, secret, recipient, caller) { + if (!recipient || recipient === 'zero_address') { + throw new Error('Recipient cannot be zero address'); + } + + const stub = this.ticketStubs.get(tokenId); + + if (!stub || !stub.exists) { + throw new Error('Ticket stub does not exist'); + } + + if (stub.claimedBy) { + throw new Error('Ticket stub already claimed'); + } + + if (!secret || secret.length === 0) { + throw new Error('Secret cannot be empty'); + } + + const hashedSecret = this.hashSecret(secret); + + if (stub.secretHash !== hashedSecret) { + throw new Error('Invalid secret'); + } + + if (this.usedSecrets.has(hashedSecret)) { + throw new Error('Secret already used'); + } + + // Mark as claimed by the recipient, not the caller + stub.claimedBy = recipient; + stub.claimedAt = Date.now(); + this.usedSecrets.add(hashedSecret); + return true; } @@ -280,15 +317,94 @@ describe('TicketStubClaimer Contract', () => { test('tracks claimed count correctly', () => { expect(contract.getClaimedCountForShow(showId)).toBe(0); - + contract.claimTicketStub(1, secrets[0], 'claimer1'); expect(contract.getClaimedCountForShow(showId)).toBe(1); - + contract.claimTicketStub(2, secrets[1], 'claimer2'); expect(contract.getClaimedCountForShow(showId)).toBe(2); }); }); + describe('Claiming on behalf of a recipient (claimTicketStubFor)', () => { + beforeEach(() => { + contract.createTicketStubsForShow(showId, secretHashes); + }); + + test('claims to recipient address, not the caller', () => { + const tokenId = 1; + const recipient = 'intended_recipient'; + const relayer = 'gas_paying_relayer'; + + const result = contract.claimTicketStubFor(tokenId, secrets[0], recipient, relayer); + expect(result).toBe(true); + + const stub = contract.getTicketStub(tokenId); + expect(stub.claimedBy).toBe(recipient); + expect(stub.claimedBy).not.toBe(relayer); + expect(stub.claimedAt).toBeGreaterThan(0); + }); + + test('marks the ticket stub as unclaimable after a successful claimFor', () => { + contract.claimTicketStubFor(1, secrets[0], 'recipient', 'relayer'); + expect(contract.canClaim(1, secrets[0])).toBe(false); + }); + + test('fails when recipient is the zero address', () => { + expect(() => { + contract.claimTicketStubFor(1, secrets[0], 'zero_address', 'relayer'); + }).toThrow('Recipient cannot be zero address'); + }); + + test('fails when recipient is null/empty', () => { + expect(() => { + contract.claimTicketStubFor(1, secrets[0], null, 'relayer'); + }).toThrow('Recipient cannot be zero address'); + }); + + test('fails when claiming for non-existent ticket stub', () => { + expect(() => { + contract.claimTicketStubFor(999, 'anysecret', 'recipient', 'relayer'); + }).toThrow('Ticket stub does not exist'); + }); + + test('fails when claiming for already-claimed ticket stub', () => { + contract.claimTicketStubFor(1, secrets[0], 'first_recipient', 'relayer'); + + expect(() => { + contract.claimTicketStubFor(1, secrets[0], 'second_recipient', 'relayer'); + }).toThrow('Ticket stub already claimed'); + }); + + test('fails when claiming for with invalid secret', () => { + expect(() => { + contract.claimTicketStubFor(1, 'wrong_secret', 'recipient', 'relayer'); + }).toThrow('Invalid secret'); + }); + + test('fails when claiming for with empty secret', () => { + expect(() => { + contract.claimTicketStubFor(1, '', 'recipient', 'relayer'); + }).toThrow('Secret cannot be empty'); + }); + + test('prevents double-claim across both entry points (claimTicketStub then claimTicketStubFor)', () => { + contract.claimTicketStub(1, secrets[0], 'self_claimer'); + + expect(() => { + contract.claimTicketStubFor(1, secrets[0], 'recipient', 'relayer'); + }).toThrow('Ticket stub already claimed'); + }); + + test('prevents double-claim across both entry points (claimTicketStubFor then claimTicketStub)', () => { + contract.claimTicketStubFor(1, secrets[0], 'recipient', 'relayer'); + + expect(() => { + contract.claimTicketStub(1, secrets[0], 'self_claimer'); + }).toThrow('Ticket stub already claimed'); + }); + }); + describe('Querying ticket stubs', () => { beforeEach(() => { contract.createTicketStubsForShow(showId, secretHashes); diff --git a/contracts/TicketStubClaimer.sol b/contracts/TicketStubClaimer.sol index feade33c..48a18de7 100644 --- a/contracts/TicketStubClaimer.sol +++ b/contracts/TicketStubClaimer.sol @@ -121,7 +121,38 @@ contract TicketStubClaimer is ERC721, Ownable, ReentrancyGuard { emit TicketStubClaimed(tokenId, msg.sender); } - + + /** + * @dev Claims a ticket stub on behalf of a specified recipient using the secret + * @param tokenId The ID of the ticket stub to claim + * @param secret The secret printed on the physical ticket stub + * @param recipient The address to mint the NFT to + * + * Identical to `claimTicketStub` except the NFT is minted to `recipient` rather + * than the caller. Enables relayer/gasless flows where a gas-paying third party + * submits the claim transaction so the holder doesn't need ETH on the deployment + * chain. The secret is the bearer instrument: whoever holds it can authorize a + * mint to any address. + */ + function claimTicketStubFor(uint32 tokenId, string calldata secret, address recipient) external nonReentrant { + require(recipient != address(0), "Recipient cannot be zero address"); + require(ticketStubs[tokenId].exists, "Ticket stub does not exist"); + require(ticketStubs[tokenId].claimedBy == address(0), "Ticket stub already claimed"); + require(bytes(secret).length > 0, "Secret cannot be empty"); + + bytes32 hashedSecret = keccak256(abi.encodePacked(secret)); + require(ticketStubs[tokenId].secretHash == hashedSecret, "Invalid secret"); + + // Mark as claimed by the recipient (not the caller) + ticketStubs[tokenId].claimedBy = recipient; + ticketStubs[tokenId].claimedAt = uint32(block.number); + + // Mint the NFT to the recipient + _mint(recipient, tokenId); + + emit TicketStubClaimed(tokenId, recipient); + } + /** * @dev Checks if a ticket stub can be claimed with the given secret * @param tokenId The ID of the ticket stub to check From 459f04a211dc4c29f09bc890320c90fcb7e890c3 Mon Sep 17 00:00:00 2001 From: Justin Holmes Date: Fri, 26 Jun 2026 15:22:07 +0000 Subject: [PATCH 3/4] TicketStubClaimer: port to OpenZeppelin 5.x blox-office (where this will be deployed from) has OZ 5.0.2 installed and all sibling contracts (SetStone, LiveSet, etc.) use OZ 5.x patterns. Three changes were required for the contract to compile in that environment: - ReentrancyGuard import moved from `security/` to `utils/` - Ownable constructor now requires an explicit initial owner address; TicketStubClaimer's constructor now takes `address initialOwner` - `_exists(tokenId)` was removed in OZ 5.x; replaced with the equivalent `_ownerOf(tokenId) != address(0)` check in tokenURI No behavioral change. All 32 JS tests still pass. --- contracts/TicketStubClaimer.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/TicketStubClaimer.sol b/contracts/TicketStubClaimer.sol index 48a18de7..470ecdaa 100644 --- a/contracts/TicketStubClaimer.sol +++ b/contracts/TicketStubClaimer.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.0; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; -import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; /** * @title TicketStubClaimer @@ -49,7 +49,7 @@ contract TicketStubClaimer is ERC721, Ownable, ReentrancyGuard { /// @dev Emitted when ticket stubs are created for a show event ShowTicketStubsCreated(uint8[] bandIds, uint32 indexed blockheight, uint32 count); - constructor() ERC721("Cryptograss Ticket Stub", "CGTS") {} + constructor(address initialOwner) ERC721("Cryptograss Ticket Stub", "CGTS") Ownable(initialOwner) {} /** * @dev Creates ticket stubs for a show with pre-computed secret hashes @@ -301,7 +301,7 @@ contract TicketStubClaimer is ERC721, Ownable, ReentrancyGuard { */ function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { require(tokenId < nextTokenId, "URI query for nonexistent token"); - require(_exists(tokenId), "URI query for nonexistent token"); + require(_ownerOf(tokenId) != address(0), "URI query for nonexistent token"); string memory baseURI = _baseURI(); return bytes(baseURI).length > 0 From 08ca5dc711d6220568ccc60a588d90bcd3a3060b Mon Sep 17 00:00:00 2001 From: Justin Holmes Date: Wed, 1 Jul 2026 16:13:29 +0000 Subject: [PATCH 4/4] Remove duplicate TicketStubClaimer.sol; canonical copy lives in blox-office MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Solidity source now lives in cryptograss/blox-office alongside its Foundry deploy script (scripts/DeployTicketStubClaimer.s.sol). The JS mock + test suite at __tests__/ticket_stub_claimer_test.js stays in arthel — it's a JS unit test of the claim logic, not a Solidity test, and doesn't need Foundry. The ABI at src/abi/ticketStubClaimerABI.js will need regenerating from the deployed contract once we have a deploy (task #12). --- contracts/TicketStubClaimer.sol | 347 -------------------------------- 1 file changed, 347 deletions(-) delete mode 100644 contracts/TicketStubClaimer.sol diff --git a/contracts/TicketStubClaimer.sol b/contracts/TicketStubClaimer.sol deleted file mode 100644 index 470ecdaa..00000000 --- a/contracts/TicketStubClaimer.sol +++ /dev/null @@ -1,347 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; -import "@openzeppelin/contracts/access/Ownable.sol"; -import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; - -/** - * @title TicketStubClaimer - * @dev NFT contract for claiming physical ticket stubs with secrets - * - * This contract bridges physical ticket stubs distributed at shows to digital NFTs. - * Each ticket stub contains a secret that can be used to claim the corresponding NFT. - * This replaces the previous "rabbit secrets" paradigm for set stone minting. - * - * QR codes on ticket stubs point to: - * https://cryptograss.live/blox-office/ticketstubs/claim/{tokenId}?secret={secret} - */ -contract TicketStubClaimer is ERC721, Ownable, ReentrancyGuard { - - /// @dev Struct to store ticket stub data with embedded show information - struct TicketStub { - uint8[] bandIds; // Array of band IDs for this show (e.g., [0, 7]) - uint32 blockheight; // Show blockheight (e.g., 22748946) - bytes32 secretHash; // Keccak256 hash of the secret from physical ticket - address claimedBy; // Address that claimed it (address(0) if unclaimed) - uint32 claimedAt; // Block number when claimed - bool exists; // Whether this token exists - } - - /// @dev Mapping from token ID to ticket stub data - mapping(uint32 => TicketStub) public ticketStubs; - - /// @dev Mapping from band ID to array of token IDs (for reverse lookup) - mapping(uint8 => uint32[]) public tokensByBand; - - /// @dev Mapping from blockheight to array of token IDs (for reverse lookup) - mapping(uint32 => uint32[]) public tokensByBlockheight; - - /// @dev Next available token ID (starts at 0) - uint32 public nextTokenId = 0; - - /// @dev Emitted when new ticket stubs are created for a show - event TicketStubCreated(uint32 indexed tokenId, uint8[] bandIds, uint32 indexed blockheight, bytes32 secretHash); - - /// @dev Emitted when a ticket stub is successfully claimed - event TicketStubClaimed(uint32 indexed tokenId, address indexed claimedBy); - - /// @dev Emitted when ticket stubs are created for a show - event ShowTicketStubsCreated(uint8[] bandIds, uint32 indexed blockheight, uint32 count); - - constructor(address initialOwner) ERC721("Cryptograss Ticket Stub", "CGTS") Ownable(initialOwner) {} - - /** - * @dev Creates ticket stubs for a show with pre-computed secret hashes - * @param bandIds Array of band IDs participating in this show (uint8 for gas efficiency) - * @param blockheight The blockheight of the show - * @param secretHashes Array of keccak256 hashes of the secrets - * - * Only the contract owner can create ticket stubs. The secrets should be - * generated off-chain and their hashes stored here for claiming. - */ - function createTicketStubsForShow( - uint8[] calldata bandIds, - uint32 blockheight, - bytes32[] calldata secretHashes - ) external onlyOwner { - require(secretHashes.length > 0, "Must create at least one ticket stub"); - require(bandIds.length > 0, "Must have at least one band"); - require(blockheight > 0, "Blockheight must be positive"); - - // Create ticket stubs - for (uint i = 0; i < secretHashes.length; i++) { - require(secretHashes[i] != bytes32(0), "Secret hash cannot be zero"); - - uint32 tokenId = nextTokenId++; - - ticketStubs[tokenId] = TicketStub({ - bandIds: bandIds, - blockheight: blockheight, - secretHash: secretHashes[i], - claimedBy: address(0), - claimedAt: 0, - exists: true - }); - - // Add to reverse lookup mappings - for (uint j = 0; j < bandIds.length; j++) { - tokensByBand[bandIds[j]].push(tokenId); - } - tokensByBlockheight[blockheight].push(tokenId); - - emit TicketStubCreated(tokenId, bandIds, blockheight, secretHashes[i]); - } - - emit ShowTicketStubsCreated(bandIds, blockheight, uint32(secretHashes.length)); - } - - /** - * @dev Claims a ticket stub using the secret from the physical ticket - * @param tokenId The ID of the ticket stub to claim - * @param secret The secret printed on the physical ticket stub - * - * The secret is hashed and compared against the stored hash. If valid, - * the NFT is minted to the caller's address. - */ - function claimTicketStub(uint32 tokenId, string calldata secret) external nonReentrant { - require(ticketStubs[tokenId].exists, "Ticket stub does not exist"); - require(ticketStubs[tokenId].claimedBy == address(0), "Ticket stub already claimed"); - require(bytes(secret).length > 0, "Secret cannot be empty"); - - bytes32 hashedSecret = keccak256(abi.encodePacked(secret)); - require(ticketStubs[tokenId].secretHash == hashedSecret, "Invalid secret"); - - // Mark as claimed - ticketStubs[tokenId].claimedBy = msg.sender; - ticketStubs[tokenId].claimedAt = uint32(block.number); - - // Mint the NFT to the claimer - _mint(msg.sender, tokenId); - - emit TicketStubClaimed(tokenId, msg.sender); - } - - /** - * @dev Claims a ticket stub on behalf of a specified recipient using the secret - * @param tokenId The ID of the ticket stub to claim - * @param secret The secret printed on the physical ticket stub - * @param recipient The address to mint the NFT to - * - * Identical to `claimTicketStub` except the NFT is minted to `recipient` rather - * than the caller. Enables relayer/gasless flows where a gas-paying third party - * submits the claim transaction so the holder doesn't need ETH on the deployment - * chain. The secret is the bearer instrument: whoever holds it can authorize a - * mint to any address. - */ - function claimTicketStubFor(uint32 tokenId, string calldata secret, address recipient) external nonReentrant { - require(recipient != address(0), "Recipient cannot be zero address"); - require(ticketStubs[tokenId].exists, "Ticket stub does not exist"); - require(ticketStubs[tokenId].claimedBy == address(0), "Ticket stub already claimed"); - require(bytes(secret).length > 0, "Secret cannot be empty"); - - bytes32 hashedSecret = keccak256(abi.encodePacked(secret)); - require(ticketStubs[tokenId].secretHash == hashedSecret, "Invalid secret"); - - // Mark as claimed by the recipient (not the caller) - ticketStubs[tokenId].claimedBy = recipient; - ticketStubs[tokenId].claimedAt = uint32(block.number); - - // Mint the NFT to the recipient - _mint(recipient, tokenId); - - emit TicketStubClaimed(tokenId, recipient); - } - - /** - * @dev Checks if a ticket stub can be claimed with the given secret - * @param tokenId The ID of the ticket stub to check - * @param secret The secret to validate - * @return true if the ticket stub can be claimed with this secret - */ - function canClaim(uint32 tokenId, string calldata secret) external view returns (bool) { - if (!ticketStubs[tokenId].exists) return false; - if (ticketStubs[tokenId].claimedBy != address(0)) return false; - if (bytes(secret).length == 0) return false; - - bytes32 hashedSecret = keccak256(abi.encodePacked(secret)); - return ticketStubs[tokenId].secretHash == hashedSecret; - } - - /** - * @dev Returns the full ticket stub data for a given token ID - * @param tokenId The token ID to query - * @return The TicketStub struct containing all data - */ - function getTicketStub(uint32 tokenId) external view returns (TicketStub memory) { - require(ticketStubs[tokenId].exists, "Ticket stub does not exist"); - return ticketStubs[tokenId]; - } - - /** - * @dev Returns all ticket stub token IDs for a given band - * @param bandId The band identifier to query - * @return Array of token IDs for the specified band - */ - function getTicketStubsForBand(uint8 bandId) external view returns (uint32[] memory) { - return tokensByBand[bandId]; - } - - /** - * @dev Returns all ticket stub token IDs for a given blockheight - * @param blockheight The blockheight to query - * @return Array of token IDs for the specified blockheight - */ - function getTicketStubsForBlockheight(uint32 blockheight) external view returns (uint32[] memory) { - return tokensByBlockheight[blockheight]; - } - - /** - * @dev Returns all ticket stub token IDs for a specific show (band combination + blockheight) - * @param bandIds Array of band IDs for the show - * @param blockheight The blockheight of the show - * @return Array of token IDs for the specified show - */ - function getTicketStubsForShow(uint8[] calldata bandIds, uint32 blockheight) external view returns (uint32[] memory) { - uint32[] memory allTokensAtBlock = tokensByBlockheight[blockheight]; - uint32[] memory results = new uint32[](allTokensAtBlock.length); - uint32 count = 0; - - for (uint i = 0; i < allTokensAtBlock.length; i++) { - uint32 tokenId = allTokensAtBlock[i]; - if (ticketStubs[tokenId].exists && _arraysEqual(ticketStubs[tokenId].bandIds, bandIds)) { - results[count] = tokenId; - count++; - } - } - - // Resize array to actual count - uint32[] memory finalResults = new uint32[](count); - for (uint i = 0; i < count; i++) { - finalResults[i] = results[i]; - } - - return finalResults; - } - - /** - * @dev Helper function to compare two uint8 arrays - */ - function _arraysEqual(uint8[] memory a, uint8[] calldata b) internal pure returns (bool) { - if (a.length != b.length) return false; - for (uint i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; - } - - /** - * @dev Returns the total number of ticket stubs created - * @return The number of ticket stubs that have been created - */ - function totalSupply() external view returns (uint32) { - return nextTokenId; - } - - /** - * @dev Returns the number of claimed ticket stubs for a show - * @param bandIds Array of band IDs for the show - * @param blockheight The blockheight of the show - * @return The number of claimed ticket stubs for the show - */ - function getClaimedCountForShow(uint8[] calldata bandIds, uint32 blockheight) external view returns (uint32) { - uint32[] memory showTokens = this.getTicketStubsForShow(bandIds, blockheight); - uint32 count = 0; - - for (uint i = 0; i < showTokens.length; i++) { - if (ticketStubs[showTokens[i]].claimedBy != address(0)) { - count++; - } - } - return count; - } - - /** - * @dev Returns the total count of ticket stubs for a show (claimed + unclaimed) - * @param bandIds Array of band IDs for the show - * @param blockheight The blockheight of the show - * @return The total number of ticket stubs created for the show - */ - function getTotalCountForShow(uint8[] calldata bandIds, uint32 blockheight) external view returns (uint32) { - return uint32(this.getTicketStubsForShow(bandIds, blockheight).length); - } - - /** - * @dev Check if a specific show has ticket stubs available - * @param bandIds Array of band IDs for the show - * @param blockheight The blockheight of the show - * @return true if ticket stubs exist for this show - */ - function hasTicketStubsForShow(uint8[] calldata bandIds, uint32 blockheight) external view returns (bool) { - return this.getTicketStubsForShow(bandIds, blockheight).length > 0; - } - - /** - * @dev Generate a rabbit hash for compatibility with existing infrastructure - * @param tokenId The token ID to generate hash for - * @return The rabbit hash (first 8 bytes of secretHash as bytes32) - */ - function getRabbitHash(uint32 tokenId) external view returns (bytes32) { - require(ticketStubs[tokenId].exists, "Ticket stub does not exist"); - - // Take first 8 bytes of the secret hash for display purposes - bytes32 secretHash = ticketStubs[tokenId].secretHash; - return bytes32(uint256(secretHash) & 0xFFFFFFFFFFFFFFFF); - } - - /** - * @dev Override tokenURI to point to ticket stub metadata - * @param tokenId The token ID to get URI for - * @return The metadata URI for the token - */ - function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { - require(tokenId < nextTokenId, "URI query for nonexistent token"); - require(_ownerOf(tokenId) != address(0), "URI query for nonexistent token"); - - string memory baseURI = _baseURI(); - return bytes(baseURI).length > 0 - ? string(abi.encodePacked(baseURI, _toString(tokenId))) - : ""; - } - - /** - * @dev Set the base URI for token metadata - * @param baseURI The new base URI - */ - function setBaseURI(string calldata baseURI) external onlyOwner { - _baseTokenURI = baseURI; - } - - string private _baseTokenURI; - - function _baseURI() internal view virtual override returns (string memory) { - return _baseTokenURI; - } - - /** - * @dev Internal function to convert uint to string - */ - function _toString(uint256 value) internal pure returns (string memory) { - if (value == 0) { - return "0"; - } - uint256 temp = value; - uint256 digits; - while (temp != 0) { - digits++; - temp /= 10; - } - bytes memory buffer = new bytes(digits); - while (value != 0) { - digits -= 1; - buffer[digits] = bytes1(uint8(48 + uint256(value % 10))); - value /= 10; - } - return string(buffer); - } -} \ No newline at end of file