diff --git a/packages/nfts/contracts/party-ticket/TaikoPartyTicketV2.sol b/packages/nfts/contracts/party-ticket/TaikoPartyTicketV2.sol new file mode 100644 index 00000000000..ac0d97b7473 --- /dev/null +++ b/packages/nfts/contracts/party-ticket/TaikoPartyTicketV2.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// Compatible with OpenZeppelin Contracts ^5.0.0 +pragma solidity ^0.8.24; + +import { TaikoPartyTicket } from "./TaikoPartyTicket.sol"; + +/// @title TaikoPartyTicketV2 +/// @dev Upgrade to support Golden Ticket (winner of winners, singular) ticket +/// @custom:security-contact security@taiko.xyz +contract TaikoPartyTicketV2 is TaikoPartyTicket { + /// @notice Get the version of the contract + /// @return The version of the contract + function version() public pure returns (string memory) { + return "v2"; + } + + /// @notice Get individual token's URI + /// @param tokenId The token ID + /// @return The token URI + /// @dev re-implemented to support golden winner + function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { + if (winnerIds.length == 0) { + return string(abi.encodePacked(baseURI, "/raffle.json")); + } else if (winners[tokenId] && winnerIds[0] == tokenId) { + return string(abi.encodePacked(baseURI, "/golden-winner.json")); + } else if (winners[tokenId]) { + return string(abi.encodePacked(baseURI, "/winner.json")); + } else { + return string(abi.encodePacked(baseURI, "/loser.json")); + } + } + + /// @notice Checks if a tokenId is the golden winner + /// @param tokenId The token ID + /// @return True if the token is the golden winner + function isGoldenWinner(uint256 tokenId) public view returns (bool) { + return winners[tokenId] && winnerIds[0] == tokenId; + } + + /// @notice Checks if an account has a golden winner token + /// @param account The account address + /// @return True if the account has a golden winner + function isGoldenWinner(address account) public view returns (bool) { + for (uint256 i = 0; i < balanceOf(account); i++) { + if (isGoldenWinner(tokenOfOwnerByIndex(account, i))) { + return true; + } + } + return false; + } +} diff --git a/packages/nfts/data/party-token/metadata/golden-winner.json b/packages/nfts/data/party-token/metadata/golden-winner.json new file mode 100644 index 00000000000..53d54f7c9f2 --- /dev/null +++ b/packages/nfts/data/party-token/metadata/golden-winner.json @@ -0,0 +1,5 @@ +{ + "name": "[GW] KBW Party Raffle Ticket", + "description": "A unique raffle ticket for the KBW Party. This ticket won a special prize at the raffle.", + "image": "https://taikonfts.4everland.link/ipfs/bafybeif2piyppimpd4rn6wkq4mxtwdgajil7vt6shkb2gt72a2zyqufg2a/golden-winner.png" +} diff --git a/packages/nfts/data/party-token/metadata/loser.json b/packages/nfts/data/party-token/metadata/loser.json index 0055f9f362b..d0814c76707 100644 --- a/packages/nfts/data/party-token/metadata/loser.json +++ b/packages/nfts/data/party-token/metadata/loser.json @@ -1,5 +1,5 @@ { "name": "[L] KBW Party Raffle Ticket", "description": "A raffle ticket for the KBW Party. This ticket won nothing at the raffle.", - "image": "https://taikonfts.4everland.link/ipfs/bafybeialwrhlnfb46o3mdd2gcrrc3ksf5exuji5lmwwcljynae4kdq4pae/loser.png" + "image": "https://taikonfts.4everland.link/ipfs/bafybeif2piyppimpd4rn6wkq4mxtwdgajil7vt6shkb2gt72a2zyqufg2a/loser.png" } diff --git a/packages/nfts/data/party-token/metadata/raffle.json b/packages/nfts/data/party-token/metadata/raffle.json index 4b2ebff9a9a..429c5cbf593 100644 --- a/packages/nfts/data/party-token/metadata/raffle.json +++ b/packages/nfts/data/party-token/metadata/raffle.json @@ -1,5 +1,5 @@ { "name": "KBW Party Raffle Ticket", "description": "A raffle ticket for the KBW Party. This ticket gives you a chance to win a special prize.", - "image": "https://taikonfts.4everland.link/ipfs/bafybeialwrhlnfb46o3mdd2gcrrc3ksf5exuji5lmwwcljynae4kdq4pae/raffle.png" + "image": "https://taikonfts.4everland.link/ipfs/bafybeif2piyppimpd4rn6wkq4mxtwdgajil7vt6shkb2gt72a2zyqufg2a/raffle.png" } diff --git a/packages/nfts/data/party-token/metadata/winner.json b/packages/nfts/data/party-token/metadata/winner.json index 19d329d7245..9f301d467e7 100644 --- a/packages/nfts/data/party-token/metadata/winner.json +++ b/packages/nfts/data/party-token/metadata/winner.json @@ -1,6 +1,6 @@ { "name": "[W] KBW Party Raffle Ticket", "description": "A raffle ticket for the KBW Party. This ticket won a special prize at the raffle.", - "image": "https://taikonfts.4everland.link/ipfs/bafybeialwrhlnfb46o3mdd2gcrrc3ksf5exuji5lmwwcljynae4kdq4pae/winner.gif", - "animation_url": "https://taikonfts.4everland.link/ipfs/bafybeialwrhlnfb46o3mdd2gcrrc3ksf5exuji5lmwwcljynae4kdq4pae/winner.gif" + "image": "https://taikonfts.4everland.link/ipfs/bafybeif2piyppimpd4rn6wkq4mxtwdgajil7vt6shkb2gt72a2zyqufg2a/winner.gif", + "animation_url": "https://taikonfts.4everland.link/ipfs/bafybeif2piyppimpd4rn6wkq4mxtwdgajil7vt6shkb2gt72a2zyqufg2a/winner.gif" } diff --git a/packages/nfts/data/party-token/static/golden-winner.png b/packages/nfts/data/party-token/static/golden-winner.png new file mode 100644 index 00000000000..1578f5b11ad Binary files /dev/null and b/packages/nfts/data/party-token/static/golden-winner.png differ diff --git a/packages/nfts/data/party-token/static/loser.png b/packages/nfts/data/party-token/static/loser.png index df5b2d2b979..9da31c54da1 100644 Binary files a/packages/nfts/data/party-token/static/loser.png and b/packages/nfts/data/party-token/static/loser.png differ diff --git a/packages/nfts/deployments/party-ticket/hekla.json b/packages/nfts/deployments/party-ticket/hekla.json index 57202211243..cb0a3609871 100644 --- a/packages/nfts/deployments/party-ticket/hekla.json +++ b/packages/nfts/deployments/party-ticket/hekla.json @@ -1,3 +1,3 @@ { - "TaikoPartyTicket": "0x1fE073fb9C749Ba99aab01aEc4E9d08875ea55a9" + "TaikoPartyTicket": "0x1d504615c42130F4fdbEb87775585B250BA78422" } diff --git a/packages/nfts/deployments/party-ticket/mainnet.json b/packages/nfts/deployments/party-ticket/mainnet.json new file mode 100644 index 00000000000..3574e47293c --- /dev/null +++ b/packages/nfts/deployments/party-ticket/mainnet.json @@ -0,0 +1,3 @@ +{ + "TaikoPartyTicket": "0x00E6dc8B0a58d505de61309df3568Ba3f9734a6C" +} diff --git a/packages/nfts/package.json b/packages/nfts/package.json index 241e323bfd3..4c10c32290b 100644 --- a/packages/nfts/package.json +++ b/packages/nfts/package.json @@ -27,7 +27,10 @@ "galxe:deploy:mainnet": "forge clean && pnpm compile && forge script script/galxe/Deploy.s.sol --rpc-url https://rpc.mainnet.taiko.xyz --legacy --with-gas-price 1", "tbzb:deploy:mainnet": "forge clean && pnpm compile && forge script script/trailblazers-badges/sol/Deploy.s.sol --rpc-url https://rpc.mainnet.taiko.xyz --broadcast --legacy --with-gas-price 13 ", "taikoon:deploy:v2": "forge clean && pnpm compile && forge script script/taikoon/sol/UpgradeV2.sol --rpc-url https://rpc.mainnet.taiko.xyz --broadcast", - "kbw:deploy:hekla": "forge clean && pnpm compile && forge script script/party-ticket/sol/Deploy.s.sol --rpc-url https://rpc.hekla.taiko.xyz --broadcast --gas-estimate-multiplier 200" + "kbw:deploy:hekla": "forge clean && pnpm compile && forge script script/party-ticket/sol/Deploy.s.sol --rpc-url https://rpc.hekla.taiko.xyz --broadcast --gas-estimate-multiplier 200", + "kbw:deploy:mainnet": "forge clean && pnpm compile && forge script script/party-ticket/sol/Deploy.s.sol --rpc-url https://rpc.mainnet.taiko.xyz --broadcast --legacy --with-gas-price 30 ", + "kbw:upgradeV2:hekla": "forge clean && pnpm compile && forge script script/party-ticket/sol/UpgradeV2.s.sol --rpc-url https://rpc.hekla.taiko.xyz --broadcast --gas-estimate-multiplier 200", + "kbw:upgradeV2:mainnet": "forge clean && pnpm compile && forge script script/party-ticket/sol/UpgradeV2.s.sol --rpc-url https://rpc.mainnet.taiko.xyz --broadcast" }, "devDependencies": { "@types/node": "^20.11.30", diff --git a/packages/nfts/script/party-ticket/sol/Deploy.s.sol b/packages/nfts/script/party-ticket/sol/Deploy.s.sol index b9ce2a3c2d3..1e574758a2e 100644 --- a/packages/nfts/script/party-ticket/sol/Deploy.s.sol +++ b/packages/nfts/script/party-ticket/sol/Deploy.s.sol @@ -7,6 +7,7 @@ import { Merkle } from "murky/Merkle.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import { TaikoPartyTicket } from "../../../contracts/party-ticket/TaikoPartyTicket.sol"; import { IMinimalBlacklist } from "@taiko/blacklist/IMinimalBlacklist.sol"; +import { TaikoPartyTicketV2 } from "../../../contracts/party-ticket/TaikoPartyTicketV2.sol"; contract DeployScript is Script { UtilsScript public utils; @@ -15,7 +16,7 @@ contract DeployScript is Script { address public deployerAddress; string baseURI = - "https://taikonfts.4everland.link/ipfs/bafybeia5mmkauevbhs4fm6wfib5wfifefrwfremlc67whrrgsgzj46kfsm"; + "https://taikonfts.4everland.link/ipfs/bafybeiep3ju3glnzsrqdzaibv7v5ifa7dy4bkyprwkjz6wytl37oqwcmya"; IMinimalBlacklist blacklist = IMinimalBlacklist(0xfA5EA6f9A13532cd64e805996a941F101CCaAc9a); uint256 mintFee = 0.002 ether; @@ -50,7 +51,14 @@ contract DeployScript is Script { console.log("Token Base URI:", baseURI); console.log("Deployed TaikoPartyTicket to:", address(token)); + /* + token.upgradeToAndCall( + address(new TaikoPartyTicketV2()), abi.encodeCall(TaikoPartyTicketV2.version, ()) + ); + + TaikoPartyTicketV2 tokenV2 = TaikoPartyTicketV2(address(token)); + */ string memory finalJson = vm.serializeAddress(jsonRoot, "TaikoPartyTicket", address(token)); vm.writeJson(finalJson, jsonLocation); diff --git a/packages/nfts/script/party-ticket/sol/UpgradeV2.s.sol b/packages/nfts/script/party-ticket/sol/UpgradeV2.s.sol new file mode 100644 index 00000000000..ffef2740fca --- /dev/null +++ b/packages/nfts/script/party-ticket/sol/UpgradeV2.s.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { UtilsScript } from "./Utils.s.sol"; +import { Script, console } from "forge-std/src/Script.sol"; +import { Merkle } from "murky/Merkle.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { TaikoPartyTicket } from "../../../contracts/party-ticket/TaikoPartyTicket.sol"; +import { IMinimalBlacklist } from "@taiko/blacklist/IMinimalBlacklist.sol"; +import { TaikoPartyTicketV2 } from "../../../contracts/party-ticket/TaikoPartyTicketV2.sol"; + +contract DeployScript is Script { + UtilsScript public utils; + string public jsonLocation; + uint256 public deployerPrivateKey; + address public deployerAddress; + + // hekla + //address tokenV1 = 0x1d504615c42130F4fdbEb87775585B250BA78422; + // mainnet + address tokenV1 = 0x00E6dc8B0a58d505de61309df3568Ba3f9734a6C; + + function setUp() public { + utils = new UtilsScript(); + utils.setUp(); + + jsonLocation = utils.getContractJsonLocation(); + deployerPrivateKey = utils.getPrivateKey(); + deployerAddress = utils.getAddress(); + } + + function run() public { + string memory jsonRoot = "root"; + + vm.startBroadcast(deployerPrivateKey); + + TaikoPartyTicket token = TaikoPartyTicket(tokenV1); + + console.log("Deployed TaikoPartyTicket to:", address(token)); + + token.upgradeToAndCall( + address(new TaikoPartyTicketV2()), abi.encodeCall(TaikoPartyTicketV2.version, ()) + ); + + TaikoPartyTicketV2 tokenV2 = TaikoPartyTicketV2(address(token)); + console.log("Upgraded token to:", address(tokenV2)); + console.log("Version:", tokenV2.version()); + + string memory finalJson = vm.serializeAddress(jsonRoot, "TaikoPartyTicket", address(token)); + vm.writeJson(finalJson, jsonLocation); + + vm.stopBroadcast(); + } +} diff --git a/packages/nfts/test/party-ticket/TaikoPartyTicketV2.t.sol b/packages/nfts/test/party-ticket/TaikoPartyTicketV2.t.sol new file mode 100644 index 00000000000..4eca227cef6 --- /dev/null +++ b/packages/nfts/test/party-ticket/TaikoPartyTicketV2.t.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { Test } from "forge-std/src/Test.sol"; + +import { TaikoPartyTicket } from "../../contracts/party-ticket/TaikoPartyTicket.sol"; +import { Merkle } from "murky/Merkle.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { MockBlacklist } from "../util/Blacklist.sol"; +import { IMinimalBlacklist } from "@taiko/blacklist/IMinimalBlacklist.sol"; +import { TaikoPartyTicketV2 } from "../../contracts/party-ticket/TaikoPartyTicketV2.sol"; + +contract TaikoPartyTicketTest is Test { + TaikoPartyTicket public tokenV1; + TaikoPartyTicketV2 public tokenV2; + + address public payoutWallet = vm.addr(0x5); + address public admin = vm.addr(0x6); + + address[3] public minters = [vm.addr(0x1), vm.addr(0x2), vm.addr(0x3)]; + + uint256 constant MINT_FEE = 1.1 ether; + uint256 constant INITIAL_BALANCE = 2 ether; + + MockBlacklist public blacklist; + + function setUp() public { + blacklist = new MockBlacklist(); + // create whitelist merkle tree + vm.startPrank(admin); + + address impl = address(new TaikoPartyTicket()); + address proxy = address( + new ERC1967Proxy( + impl, + abi.encodeCall( + TaikoPartyTicket.initialize, + (payoutWallet, MINT_FEE, "ipfs://baseURI", blacklist) + ) + ) + ); + + tokenV1 = TaikoPartyTicket(proxy); + + // deploy v2 upgrade + + tokenV1.upgradeToAndCall( + address(new TaikoPartyTicketV2()), abi.encodeCall(TaikoPartyTicketV2.version, ()) + ); + + tokenV2 = TaikoPartyTicketV2(address(tokenV1)); + + // assign initial balance to all minters + for (uint256 i = 0; i < minters.length; i++) { + vm.deal(minters[i], INITIAL_BALANCE); + } + + vm.stopPrank(); + } + + function test_metadata() public view { + assertEq(tokenV2.name(), "TaikoPartyTicket"); + assertEq(tokenV2.symbol(), "TPT"); + assertEq(tokenV2.totalSupply(), 0); + } + + function test_mint() public { + vm.prank(minters[0]); + tokenV2.mint{ value: MINT_FEE }(); + assertEq(tokenV2.totalSupply(), 1); + assertEq(tokenV2.ownerOf(0), minters[0]); + assertEq(minters[0].balance, INITIAL_BALANCE - MINT_FEE); + } + + function test_mint_admin() public { + vm.prank(admin); + tokenV2.mint(minters[1]); + assertEq(tokenV2.totalSupply(), 1); + assertEq(tokenV2.ownerOf(0), minters[1]); + } + + function test_winnerFlow() public { + // have all minters mint + vm.prank(minters[0]); + tokenV2.mint{ value: MINT_FEE }(); + vm.prank(minters[1]); + tokenV2.mint{ value: MINT_FEE }(); + vm.prank(minters[2]); + tokenV2.mint{ value: MINT_FEE }(); + + // set minters[0] as winner + vm.startPrank(admin); + uint256[] memory winners = new uint256[](2); + // assign the winners + winners[0] = 0; + winners[1] = 1; + // ability to pause the minting and set the winners later + tokenV2.pause(); + tokenV2.setWinners(winners); + vm.stopPrank(); + // check winner with both tokenId and address + assertTrue(tokenV2.isWinner(0)); + assertTrue(tokenV2.isWinner(minters[0])); + assertTrue(tokenV2.isWinner(minters[1])); + assertFalse(tokenV2.isWinner(minters[2])); + // check golden winner + assertTrue(tokenV2.isGoldenWinner(0)); + assertTrue(tokenV2.isGoldenWinner(minters[0])); + assertFalse(tokenV2.isGoldenWinner(1)); + assertFalse(tokenV2.isGoldenWinner(minters[1])); + assertFalse(tokenV2.isGoldenWinner(2)); + assertFalse(tokenV2.isGoldenWinner(minters[2])); + + // and the contract is paused + assertTrue(tokenV2.paused()); + // ensure the contract's balance + assertEq(address(tokenV2).balance, MINT_FEE * minters.length); + } + + function test_payout() public { + test_winnerFlow(); + uint256 collectedEth = address(tokenV2).balance; + vm.prank(admin); + tokenV2.payout(); + assertEq(payoutWallet.balance, collectedEth); + assertEq(address(tokenV2).balance, 0); + } + + function test_ipfs_metadata_goldenWinner() public { + // ensure URIs are "ticket" before setting winners + assertEq(tokenV2.baseURI(), "ipfs://baseURI"); + assertEq(tokenV2.tokenURI(0), "ipfs://baseURI/raffle.json"); + assertEq(tokenV2.tokenURI(1), "ipfs://baseURI/raffle.json"); + // run winner flow + test_winnerFlow(); + // ensure URIs are "winner" and "loser" after setting winners + assertEq(tokenV2.tokenURI(0), "ipfs://baseURI/golden-winner.json"); + assertEq(tokenV2.tokenURI(1), "ipfs://baseURI/winner.json"); + assertEq(tokenV2.tokenURI(2), "ipfs://baseURI/loser.json"); + } + + function test_revokeWinner() public { + test_winnerFlow(); + // ensure the contract is paused + assertTrue(tokenV2.paused()); + // ensure wallet0 is winner + assertTrue(tokenV2.isWinner(minters[0])); + + uint256[] memory winnerIds = tokenV2.getWinnerTokenIds(); + assertEq(winnerIds.length, 2); + address[] memory winners = tokenV2.getWinners(); + assertEq(winners.length, 2); + + // revoke the winner + vm.prank(admin); + tokenV2.revokeWinner(winnerIds[0]); + + winnerIds = tokenV2.getWinnerTokenIds(); + assertEq(winnerIds.length, 1); + winners = tokenV2.getWinners(); + assertEq(winners.length, 1); + } + + function test_revokeAndReplaceWinner() public { + test_winnerFlow(); + // ensure the contract is paused + assertTrue(tokenV2.paused()); + + // ensure wallet0 is winner + assertTrue(tokenV2.isWinner(minters[0])); + assertTrue(tokenV2.isWinner(minters[1])); + assertFalse(tokenV2.isWinner(minters[2])); + + uint256[] memory winnerIds = tokenV2.getWinnerTokenIds(); + assertEq(winnerIds.length, 2); + address[] memory winners = tokenV2.getWinners(); + assertEq(winners.length, 2); + + // revoke and replace with token id 2 + vm.prank(admin); + tokenV2.revokeAndReplaceWinner(winnerIds[0], 2); + + assertFalse(tokenV2.isWinner(minters[0])); + assertTrue(tokenV2.isWinner(minters[1])); + assertTrue(tokenV2.isWinner(minters[2])); + + winnerIds = tokenV2.getWinnerTokenIds(); + assertEq(winnerIds.length, 2); + + winners = tokenV2.getWinners(); + assertEq(winners.length, 2); + + assertFalse(tokenV2.isWinner(minters[0])); + assertTrue(tokenV2.isWinner(minters[1])); + assertTrue(tokenV2.isWinner(minters[2])); + } +}