From d9bfd7b9f1c35ae8a04cd17c39cef7b58ed84572 Mon Sep 17 00:00:00 2001 From: Kai Hirota <34954529+kaihirota@users.noreply.github.com> Date: Mon, 14 Oct 2024 18:22:25 +1100 Subject: [PATCH 1/3] create immutable X sample game --- .../Shared/Scripts/UI/LevelCompleteScreen.cs | 49 ++++- Assets/Shared/Scripts/UI/MainMenu.cs | 34 ++- Assets/Shared/Scripts/UI/MintScreen.cs | 125 ++++++++--- Assets/Shared/Scripts/UI/SetupWalletScreen.cs | 16 +- .../Shared/Scripts/UI/UnlockedSkinScreen.cs | 80 +++++++- mint-backend/dest/index.js | 154 +++++++++----- mint-backend/package.json | 23 ++- mint-backend/src/index.ts | 194 ++++++++++++------ mint-backend/tsconfig.json | 2 +- 9 files changed, 497 insertions(+), 180 deletions(-) diff --git a/Assets/Shared/Scripts/UI/LevelCompleteScreen.cs b/Assets/Shared/Scripts/UI/LevelCompleteScreen.cs index e98d46263..131fa00b2 100644 --- a/Assets/Shared/Scripts/UI/LevelCompleteScreen.cs +++ b/Assets/Shared/Scripts/UI/LevelCompleteScreen.cs @@ -5,6 +5,7 @@ using UnityEngine.UI; using System; using System.Collections.Generic; +using Immutable.Passport; namespace HyperCasual.Runner { @@ -40,7 +41,7 @@ public class LevelCompleteScreen : View AbstractGameEvent m_UnlockedSkinEvent; /// - /// The slider that displays the XP value + /// The slider that displays the XP value /// public Slider XpSlider => m_XpSlider; @@ -108,7 +109,7 @@ public void OnEnable() m_NextButton.RemoveListener(OnNextButtonClicked); m_NextButton.AddListener(OnNextButtonClicked); - // Set listener to "Continue with Passport" button + // Set listener to "Continue with Passport" button m_ContinuePassportButton.RemoveListener(OnContinueWithPassportButtonClicked); m_ContinuePassportButton.AddListener(OnContinueWithPassportButtonClicked); @@ -116,11 +117,39 @@ public void OnEnable() m_TryAgainButton.RemoveListener(OnTryAgainButtonClicked); m_TryAgainButton.AddListener(OnTryAgainButtonClicked); - ShowNextButton(true); + // Show 'Next' button if player is already logged into Passport + ShowNextButton(SaveManager.Instance.IsLoggedIn); + // Show "Continue with Passport" button if the player is not logged into Passport + ShowContinueWithPassportButton(!SaveManager.Instance.IsLoggedIn); } - private void OnContinueWithPassportButtonClicked() + private async void OnContinueWithPassportButtonClicked() { + try + { + // Show loading + ShowContinueWithPassportButton(false); + ShowLoading(true); + + // Log into Passport + await Passport.Instance.Login(); + + // Successfully logged in + // Save a persistent flag in the game that the player is logged in + SaveManager.Instance.IsLoggedIn = true; + // Show 'Next' button + ShowNextButton(true); + ShowLoading(false); + // Take the player to the Setup Wallet screen + m_SetupWalletEvent.Raise(); + } + catch (Exception ex) + { + Debug.Log($"Failed to log into Passport: {ex.Message}"); + // Show Continue with Passport button again + ShowContinueWithPassportButton(true); + ShowLoading(false); + } } private void OnTryAgainButtonClicked() @@ -129,7 +158,17 @@ private void OnTryAgainButtonClicked() private void OnNextButtonClicked() { - m_NextLevelEvent.Raise(); + // Check if the player is already using a new skin + if (!SaveManager.Instance.UseNewSkin) + { + // Player is not using a new skin, take player to Unlocked Skin screen + m_UnlockedSkinEvent.Raise(); + } + else + { + // Player is already using a new skin, take player to the next level + m_NextLevelEvent.Raise(); + } } private void ShowCompletedContainer(bool show) diff --git a/Assets/Shared/Scripts/UI/MainMenu.cs b/Assets/Shared/Scripts/UI/MainMenu.cs index 39c0643c0..b31b3ded5 100644 --- a/Assets/Shared/Scripts/UI/MainMenu.cs +++ b/Assets/Shared/Scripts/UI/MainMenu.cs @@ -4,6 +4,7 @@ using UnityEngine; using UnityEngine.UI; using TMPro; +using Immutable.Passport; namespace HyperCasual.Runner { @@ -23,7 +24,9 @@ public class MainMenu : View [SerializeField] GameObject m_Loading; - void OnEnable() + Passport passport; + + async void OnEnable() { ShowLoading(true); @@ -34,8 +37,34 @@ void OnEnable() m_LogoutButton.RemoveListener(OnLogoutButtonClick); m_LogoutButton.AddListener(OnLogoutButtonClick); + // Initialise Passport + string clientId = "YOUR_IMMUTABLE_CLIENT_ID"; + string environment = Immutable.Passport.Model.Environment.SANDBOX; + passport = await Passport.Init(clientId, environment); + + // Check if the player is supposed to be logged in and if there are credentials saved + if (SaveManager.Instance.IsLoggedIn && await Passport.Instance.HasCredentialsSaved()) + { + // Try to log in using saved credentials + bool success = await Passport.Instance.Login(useCachedSession: true); + // Update the login flag + SaveManager.Instance.IsLoggedIn = success; + + // Set up wallet if successful + if (success) + { + await Passport.Instance.ConnectImx(); + } + } else { + // No saved credentials to re-login the player, reset the login flag + SaveManager.Instance.IsLoggedIn = false; + } + ShowLoading(false); ShowStartButton(true); + + // Show the logout button if the player is logged in + ShowLogoutButton(SaveManager.Instance.IsLoggedIn); } void OnDisable() @@ -49,7 +78,7 @@ void OnStartButtonClick() AudioManager.Instance.PlayEffect(SoundID.ButtonSound); } - void OnLogoutButtonClick() + async void OnLogoutButtonClick() { try { @@ -59,6 +88,7 @@ void OnLogoutButtonClick() ShowLoading(true); // Logout + await passport.Logout(); // Reset the login flag SaveManager.Instance.IsLoggedIn = false; diff --git a/Assets/Shared/Scripts/UI/MintScreen.cs b/Assets/Shared/Scripts/UI/MintScreen.cs index c13f50f8b..8e66c8197 100644 --- a/Assets/Shared/Scripts/UI/MintScreen.cs +++ b/Assets/Shared/Scripts/UI/MintScreen.cs @@ -4,7 +4,19 @@ using UnityEngine.UI; using System; using System.Collections.Generic; +using System.Net.Http; using System.Threading; +using Cysharp.Threading.Tasks; +using Immutable.Passport; +using Immutable.Passport.Model; + +[Serializable] +public class MintResult +{ + public string token_id; + public string contract_address; + public string tx_id; +} namespace HyperCasual.Runner { @@ -33,6 +45,9 @@ public class MintScreen : View [SerializeField] HyperCasualButton m_WalletButton; + // If there's an error minting, these values will be used when the player clicks the "Try again" button + private bool mintedFox = false; + public void OnEnable() { // Set listener to 'Next' button @@ -47,10 +62,13 @@ public void OnEnable() m_WalletButton.RemoveListener(OnWalletClicked); m_WalletButton.AddListener(OnWalletClicked); + // Reset values + mintedFox = false; + Mint(); } - private void Mint() + private async void Mint() { try { @@ -59,20 +77,80 @@ private void Mint() ShowError(false); ShowNextButton(false); - // Mint + // Mint fox if not minted yet + if (!mintedFox) + { + MintResult mintResult = await MintFox(); - ShowMintedMessage(); - ShowLoading(false); - ShowError(false); - ShowNextButton(true); + // Show minted message if minted fox successfully + ShowMintedMessage(); + } } catch (Exception ex) { // Failed to mint, let the player try again - Debug.Log($"Failed to mint: {ex.Message}"); - ShowLoading(false); - ShowError(true); - ShowNextButton(false); + Debug.Log($"Failed to mint or transfer: {ex.Message}"); + } + ShowLoading(false); + + // Show error if failed to mint fox + ShowError(!mintedFox); + + // Show next button if fox minted successfully + ShowNextButton(mintedFox); + } + + /// + /// Gets the wallet address of the player. + /// + private async UniTask GetWalletAddress() + { + string address = await Passport.Instance.GetAddress(); + return address; + } + + /// + /// Mints a fox (i.e. Immutable Runner Fox) to the player's wallet + /// + /// True if minted a fox successfully to player's wallet. Otherwise, false. + private async UniTask MintFox() + { + Debug.Log("Minting fox..."); + try + { + string address = await GetWalletAddress(); // Get the player's wallet address to mint the fox to + + if (address != null) + { + var nvc = new List> + { + // Set 'to' to the player's wallet address + new KeyValuePair("to", address) + }; + using var client = new HttpClient(); + string url = $"http://localhost:3000/mint/fox"; // Endpoint to mint fox + using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = new FormUrlEncodedContent(nvc) }; + using var res = await client.SendAsync(req); + + // Parse JSON and extract token_id + string content = await res.Content.ReadAsStringAsync(); + Debug.Log($"Mint fox response: {content}"); + + MintResult mintResult = JsonUtility.FromJson(content); + Debug.Log($"Minted fox with token_id: {mintResult.token_id}"); + + mintedFox = res.IsSuccessStatusCode; + return mintResult; + } + + mintedFox = false; + return null; + } + catch (Exception ex) + { + Debug.Log($"Failed to mint fox: {ex.Message}"); + mintedFox = false; + return null; } } @@ -106,16 +184,7 @@ private void ShowCheckoutWallet(bool show) private void ShowMintingMessage() { ShowCheckoutWallet(false); - // Get number of coins col - int numCoins = GetNumCoinsCollected(); - if (numCoins > 0) - { - m_Title.text = $"Let's mint the {numCoins} coin{(numCoins > 1 ? "s" : "")} you've collected and a fox to your wallet"; - } - else - { - m_Title.text = "Let's mint a fox to your wallet!"; - } + m_Title.text = "Let's mint a fox to your wallet!"; } /// @@ -130,19 +199,15 @@ private int GetNumCoinsCollected() private void ShowMintedMessage() { ShowCheckoutWallet(true); - int numCoins = GetNumCoinsCollected(); - if (numCoins > 0) - { - m_Title.text = $"You now own {numCoins} coin{(numCoins > 1 ? "s" : "")} and a fox"; - } - else - { - m_Title.text = "You now own a fox!"; - } + m_Title.text = "You now own a fox!"; } - private void OnWalletClicked() + private async void OnWalletClicked() { + // Get the player's wallet address to mint the fox to + string address = await GetWalletAddress(); + // Show the player's tokens on the block explorer page. + Application.OpenURL($"https://sandbox.immutascan.io/address/{address}?tab=1"); } } } diff --git a/Assets/Shared/Scripts/UI/SetupWalletScreen.cs b/Assets/Shared/Scripts/UI/SetupWalletScreen.cs index f8cf64f94..58780db0f 100644 --- a/Assets/Shared/Scripts/UI/SetupWalletScreen.cs +++ b/Assets/Shared/Scripts/UI/SetupWalletScreen.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Threading; +using Immutable.Passport; namespace HyperCasual.Runner { @@ -44,7 +45,7 @@ public void OnEnable() SetupWallet(); } - private void SetupWallet() + private async void SetupWallet() { try { @@ -53,7 +54,16 @@ private void SetupWallet() ShowError(false); ShowSuccess(false); - // Set up wallet + // Set up provider + await Passport.Instance.ConnectImx(); + + Debug.Log("Checking if wallet is registered offchain..."); + bool isRegistered = await Passport.Instance.IsRegisteredOffchain(); + if (!isRegistered) + { + Debug.Log("Registering wallet offchain..."); + await Passport.Instance.RegisterOffchain(); + } m_Title.text = "Your wallet has been successfully set up!"; ShowLoading(false); @@ -72,7 +82,7 @@ private void SetupWallet() private void OnNextButtonClicked() { - m_NextEvent.Raise(); + m_MintEvent.Raise(); } private void ShowNextButton(bool show) diff --git a/Assets/Shared/Scripts/UI/UnlockedSkinScreen.cs b/Assets/Shared/Scripts/UI/UnlockedSkinScreen.cs index 0b563d450..059e2e25b 100644 --- a/Assets/Shared/Scripts/UI/UnlockedSkinScreen.cs +++ b/Assets/Shared/Scripts/UI/UnlockedSkinScreen.cs @@ -5,7 +5,27 @@ using UnityEngine.UI; using System; using System.Collections.Generic; +using System.Net.Http; +using System.Threading; using System.Threading.Tasks; +using Cysharp.Threading.Tasks; +using Immutable.Passport; +using Immutable.Passport.Model; + + +[Serializable] +public class GetAssetsResponse +{ + public Asset[] result; +} + +[Serializable] +public class Asset +{ + public string id; + public string token_id; + public string token_address; +} namespace HyperCasual.Runner { @@ -45,7 +65,7 @@ public CraftSkinState? CraftState get => m_CraftState; set { - CraftState = value; + m_CraftState = value; switch (m_CraftState) { case CraftSkinState.Crafting: @@ -113,19 +133,59 @@ public async void OnEnable() private async void Craft() { - CraftState = CraftSkinState.Crafting; + try { + m_CraftState = CraftSkinState.Crafting; + + // burn + Asset[] assets = await GetAssets(); + if (assets.Length == 0) + { + Debug.Log("No assets to burn"); + m_CraftState = CraftSkinState.Failed; + return; + } - // Burn tokens and mint a new skin i.e. crafting a skin - await Task.Delay(TimeSpan.FromSeconds(5)); + CreateTransferResponseV1 transferResult = await Passport.Instance.ImxTransfer( + new UnsignedTransferRequest("ERC721", "1", "0x0000000000000000000000000000000000000000", assets[0].token_id, assets[0].token_address) + ); + Debug.Log($"Transfer(id={transferResult.transfer_id} receiver={transferResult.receiver} status={transferResult.status})"); - CraftState = CraftSkinState.Crafted; + m_CraftState = CraftSkinState.Crafted; - // If successfully crafted skin and this screen is visible, go to collect skin screen - // otherwise it will be picked in the OnEnable function above when this screen reappears - if (m_CraftState == CraftSkinState.Crafted && gameObject.active) + // If successfully crafted skin and this screen is visible, go to collect skin screen + // otherwise it will be picked in the OnEnable function above when this screen reappears + if (m_CraftState == CraftSkinState.Crafted && gameObject.active) + { + CollectSkin(); + } + } catch (Exception ex) { + Debug.Log($"Failed to craft skin: {ex.Message}"); + m_CraftState = CraftSkinState.Failed; + } + } + + private async UniTask GetAssets() + { + const string collection = "0xcf77af96b269169f149b3c23230e103bda67fd0c"; + string address = await Passport.Instance.GetAddress(); + Debug.Log($"Wallet address: {address}"); + + if (address != null) { - CollectSkin(); + using var client = new HttpClient(); + string url = $"https://api.sandbox.x.immutable.com/v1/assets?user={address}&collection={collection}&status=imx"; + using var req = new HttpRequestMessage(HttpMethod.Get, url); + using var res = await client.SendAsync(req); + + // Parse JSON and extract token_id + string content = await res.Content.ReadAsStringAsync(); + Debug.Log($"Get Assets response: {content}"); + + GetAssetsResponse body = JsonUtility.FromJson(content); + Debug.Log($"Get Assets result: {body.result.Length}"); + return body.result; } + return null; } private void CollectSkin() @@ -136,9 +196,9 @@ private void CollectSkin() private void OnCraftButtonClicked() { - m_NextLevelEvent.Raise(); // Craft in the background, while the player plays the next level Craft(); + // m_NextLevelEvent.Raise(); } private void OnTryAgainButtonClicked() diff --git a/mint-backend/dest/index.js b/mint-backend/dest/index.js index 2748d0bcf..e438a2dd5 100644 --- a/mint-backend/dest/index.js +++ b/mint-backend/dest/index.js @@ -3,12 +3,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); +exports.nextTokenId = void 0; const express_1 = __importDefault(require("express")); const cors_1 = __importDefault(require("cors")); const http_1 = __importDefault(require("http")); const ethers_1 = require("ethers"); +const utils_1 = require("ethers/lib/utils"); +const providers_1 = require("@ethersproject/providers"); +const keccak256_1 = require("@ethersproject/keccak256"); +const strings_1 = require("@ethersproject/strings"); const morgan_1 = __importDefault(require("morgan")); const dotenv_1 = __importDefault(require("dotenv")); +const sdk_1 = require("@imtbl/sdk"); dotenv_1.default.config(); const app = (0, express_1.default)(); app.use((0, morgan_1.default)('dev')); // Logging @@ -16,72 +22,118 @@ app.use(express_1.default.urlencoded({ extended: false })); // Parse request app.use(express_1.default.json()); // Handle JSON app.use((0, cors_1.default)()); // Enable CORS const router = express_1.default.Router(); -const zkEvmProvider = new ethers_1.JsonRpcProvider('https://rpc.testnet.immutable.com'); // Contract addresses const foxContractAddress = process.env.FOX_CONTRACT_ADDRESS; -const tokenContractAddress = process.env.TOKEN_CONTRACT_ADDRESS; // Private key of wallet with minter role const privateKey = process.env.PRIVATE_KEY; -const gasOverrides = { - // Use parameter to set tip for EIP1559 transaction (gas fee) - maxPriorityFeePerGas: 10e9, // 10 Gwei. This must exceed minimum gas fee expectation from the chain - maxFeePerGas: 15e9, // 15 Gwei -}; // Mint Immutable Runner Fox router.post('/mint/fox', async (req, res) => { + if (!foxContractAddress || !privateKey) { + res.writeHead(500); + res.end(); + return; + } try { - if (foxContractAddress && privateKey) { - // Get the address to mint the fox to - let to = req.body.to ?? null; - // Get the quantity to mint if specified, default is one - let quantity = parseInt(req.body.quantity ?? "1"); - // Connect to wallet with minter role - const signer = new ethers_1.Wallet(privateKey).connect(zkEvmProvider); - // Specify the function to call - const abi = ['function mintByQuantity(address to, uint256 quantity)']; - // Connect contract to the signer - const contract = new ethers_1.Contract(foxContractAddress, abi, signer); - // Mints the number of tokens specified - const tx = await contract.mintByQuantity(to, quantity, gasOverrides); - await tx.wait(); - return res.status(200).json({}); + // Set up IMXClient + const client = new sdk_1.x.IMXClient(sdk_1.x.imxClientConfig({ environment: sdk_1.config.Environment.SANDBOX })); + // Set up signer + const provider = (0, providers_1.getDefaultProvider)('sepolia'); + // Connect to wallet with minter role + const ethSigner = new ethers_1.Wallet(privateKey, provider); + const tokenId = await (0, exports.nextTokenId)(foxContractAddress, client); + console.log('Next token ID: ', tokenId); + // recipient + const recipient = req.body.to ?? null; + // Set up request + let mintRequest = { + auth_signature: '', // This will be filled in later + contract_address: foxContractAddress, + users: [ + { + user: ethSigner.address, + tokens: [ + { + id: tokenId.toString(), + blueprint: 'onchain-metadata', + royalties: [ + { + recipient: ethSigner.address, + percentage: 1, + }, + ], + }, + ], + }, + ], + }; + const message = (0, keccak256_1.keccak256)((0, strings_1.toUtf8Bytes)(JSON.stringify(mintRequest))); + const authSignature = await ethSigner.signMessage((0, utils_1.arrayify)(message)); + mintRequest.auth_signature = authSignature; + console.log('sender', ethSigner.address, 'recipient', recipient, 'tokenId', tokenId); + // Mint + const mintResponse = await client.mint(ethSigner, mintRequest); + console.log('Mint response: ', mintResponse); + try { + // Transfer to recipient + const imxProviderConfig = new sdk_1.x.ProviderConfiguration({ + baseConfig: { + environment: sdk_1.config.Environment.SANDBOX, + }, + }); + const starkPrivateKey = await sdk_1.x.generateLegacyStarkPrivateKey(ethSigner); + const starkSigner = sdk_1.x.createStarkSigner(starkPrivateKey); + const imxProvider = new sdk_1.x.GenericIMXProvider(imxProviderConfig, ethSigner, starkSigner); + const result = await imxProvider.transfer({ + type: 'ERC721', + receiver: recipient, + tokenAddress: foxContractAddress, + tokenId: mintResponse.results[0].token_id, + }); + console.log('Transfer result: ', result); + res.writeHead(200); + res.end(JSON.stringify(mintResponse.results[0])); } - else { - return res.status(500).json({}); + catch (error) { + console.log(error); + res.writeHead(400); + res.end(JSON.stringify({ message: 'Failed to transfer to user' })); } } catch (error) { console.log(error); - return res.status(400).json({ message: "Failed to mint to user" }); + res.writeHead(400); + res.end(JSON.stringify({ message: 'Failed to mint to user' })); } }); -// Mint Immutable Runner Token -router.post('/mint/token', async (req, res) => { +app.use('/', router); +http_1.default.createServer(app).listen(3000, () => console.log('Listening on port 3000')); +/** + * Helper function to get the next token id for a collection + */ +const nextTokenId = async (collectionAddress, imxClient) => { try { - if (tokenContractAddress && privateKey) { - // Get the address to mint the token to - let to = req.body.to ?? null; - // Get the quantity to mint if specified, default is one - let quantity = BigInt(req.body.quantity ?? "1"); - // Connect to wallet with minter role - const signer = new ethers_1.Wallet(privateKey).connect(zkEvmProvider); - // Specify the function to call - const abi = ['function mint(address to, uint256 quantity)']; - // Connect contract to the signer - const contract = new ethers_1.Contract(tokenContractAddress, abi, signer); - // Mints the number of tokens specified - const tx = await contract.mint(to, quantity, gasOverrides); - await tx.wait(); - return res.status(200).json({}); - } - else { - return res.status(500).json({}); - } + let remaining = 0; + let cursor; + let tokenId = 0; + do { + // eslint-disable-next-line no-await-in-loop + const assets = await imxClient.listAssets({ + collection: collectionAddress, + cursor, + }); + remaining = assets.remaining; + cursor = assets.cursor; + for (const asset of assets.result) { + const id = parseInt(asset.token_id, 10); + if (id > tokenId) { + tokenId = id; + } + } + } while (remaining > 0); + return tokenId + 1; } catch (error) { - console.log(error); - return res.status(400).json({ message: "Failed to mint to user" }); + return 0; } -}); -app.use('/', router); -http_1.default.createServer(app).listen(3000, () => console.log(`Listening on port 3000`)); +}; +exports.nextTokenId = nextTokenId; diff --git a/mint-backend/package.json b/mint-backend/package.json index 71406e648..61c35f30a 100644 --- a/mint-backend/package.json +++ b/mint-backend/package.json @@ -10,24 +10,25 @@ }, "devDependencies": { "@types/cors": "^2.8.13", - "@types/express": "^4.17.17", + "@types/express": "^5.0.0", "@types/morgan": "^1.9.9", - "@typescript-eslint/eslint-plugin": "^7.11.0", - "eslint": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^8.8.0", + "eslint": "^9.11.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^18.0.0", - "eslint-plugin-import": "^2.29.1", - "eslint-plugin-jsx-a11y": "^6.8.0", - "eslint-plugin-react": "^7.34.2", + "eslint-plugin-import": "^2.30.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.1", "eslint-plugin-react-hooks": "^4.6.2", - "typescript": "^5.4.3", - "typescript-eslint": "^7.11.0" + "typescript": "^5.6.2", + "typescript-eslint": "^8.8.0" }, "dependencies": { + "@ethersproject/providers": "^5.7.2", + "@imtbl/sdk": "1.55.0", "cors": "^2.8.5", "dotenv": "^16.4.5", - "ethers": "^6.11.1", - "express": "^4.20.0", + "express": "^5.0.0", "morgan": "^1.10.0" } -} \ No newline at end of file +} diff --git a/mint-backend/src/index.ts b/mint-backend/src/index.ts index 4d4c11037..240ed51e2 100644 --- a/mint-backend/src/index.ts +++ b/mint-backend/src/index.ts @@ -6,9 +6,14 @@ import express, { } from 'express'; import cors from 'cors'; import http from 'http'; -import { JsonRpcProvider, Wallet, Contract } from 'ethers'; +import { Wallet } from 'ethers'; +import { arrayify } from 'ethers/lib/utils'; +import { getDefaultProvider } from '@ethersproject/providers'; +import { keccak256 } from '@ethersproject/keccak256'; +import { toUtf8Bytes } from '@ethersproject/strings'; import morgan from 'morgan'; import dotenv from 'dotenv'; +import { x, config } from '@imtbl/sdk'; dotenv.config(); @@ -19,89 +24,144 @@ app.use(express.json()); // Handle JSON app.use(cors()); // Enable CORS const router: Router = express.Router(); -const zkEvmProvider = new JsonRpcProvider('https://rpc.testnet.immutable.com'); - // Contract addresses const foxContractAddress = process.env.FOX_CONTRACT_ADDRESS; -const tokenContractAddress = process.env.TOKEN_CONTRACT_ADDRESS; + // Private key of wallet with minter role const privateKey = process.env.PRIVATE_KEY; -const gasOverrides = { - // Use parameter to set tip for EIP1559 transaction (gas fee) - maxPriorityFeePerGas: 10e9, // 10 Gwei. This must exceed minimum gas fee expectation from the chain - maxFeePerGas: 15e9, // 15 Gwei -}; - // Mint Immutable Runner Fox router.post('/mint/fox', async (req: Request, res: Response) => { - try { - if (foxContractAddress && privateKey) { - // Get the address to mint the fox to - let to: string = req.body.to ?? null; - // Get the quantity to mint if specified, default is one - let quantity = parseInt(req.body.quantity ?? '1'); - - // Connect to wallet with minter role - const signer = new Wallet(privateKey).connect(zkEvmProvider); - - // Specify the function to call - const abi = ['function mintByQuantity(address to, uint256 quantity)']; - // Connect contract to the signer - const contract = new Contract(foxContractAddress, abi, signer); - - // Mints the number of tokens specified - const tx = await contract.mintByQuantity(to, quantity, gasOverrides); - await tx.wait(); - - return res.status(200).json({}); - } else { - return res.status(500).json({}); - } - - } catch (error) { - console.log(error); - return res.status(400).json({ message: 'Failed to mint to user' }); + if (!foxContractAddress || !privateKey) { + res.writeHead(500); + res.end(); + return; } -}, -); -// Mint Immutable Runner Token -router.post('/mint/token', async (req: Request, res: Response) => { try { - if (tokenContractAddress && privateKey) { - // Get the address to mint the token to - let to: string = req.body.to ?? null; - // Get the quantity to mint if specified, default is one - let quantity = BigInt(req.body.quantity ?? '1'); - - // Connect to wallet with minter role - const signer = new Wallet(privateKey).connect(zkEvmProvider); - - // Specify the function to call - const abi = ['function mint(address to, uint256 quantity)']; - // Connect contract to the signer - const contract = new Contract(tokenContractAddress, abi, signer); - - // Mints the number of tokens specified - const tx = await contract.mint(to, quantity, gasOverrides); - await tx.wait(); - - return res.status(200).json({}); - } else { - return res.status(500).json({}); + // Set up IMXClient + const client = new x.IMXClient( + x.imxClientConfig({ environment: config.Environment.SANDBOX }) + ); + + // Set up signer + const provider = getDefaultProvider('sepolia'); + + // Connect to wallet with minter role + const ethSigner = new Wallet(privateKey, provider); + + const tokenId = await nextTokenId(foxContractAddress, client); + console.log('Next token ID: ', tokenId); + + // recipient + const recipient: string = req.body.to ?? null; + + // Set up request + let mintRequest = { + auth_signature: '', // This will be filled in later + contract_address: foxContractAddress, + users: [ + { + user: ethSigner.address, + tokens: [ + { + id: tokenId.toString(), + blueprint: 'onchain-metadata', + royalties: [ + { + recipient: ethSigner.address, + percentage: 1, + }, + ], + }, + ], + }, + ], + }; + const message = keccak256(toUtf8Bytes(JSON.stringify(mintRequest))); + const authSignature = await ethSigner.signMessage(arrayify(message)); + mintRequest.auth_signature = authSignature; + + console.log('sender', ethSigner.address, 'recipient', recipient, 'tokenId', tokenId); + + // Mint + const mintResponse = await client.mint(ethSigner, mintRequest); + console.log('Mint response: ', mintResponse); + + try { + // Transfer to recipient + const imxProviderConfig = new x.ProviderConfiguration({ + baseConfig: { + environment: config.Environment.SANDBOX, + }, + }); + const starkPrivateKey = await x.generateLegacyStarkPrivateKey(ethSigner); + const starkSigner = x.createStarkSigner(starkPrivateKey); + const imxProvider = new x.GenericIMXProvider( + imxProviderConfig, + ethSigner, + starkSigner + ); + const result = await imxProvider.transfer({ + type: 'ERC721', + receiver: recipient, + tokenAddress: foxContractAddress, + tokenId: mintResponse.results[0].token_id, + }); + console.log('Transfer result: ', result); + + res.writeHead(200); + res.end(JSON.stringify(mintResponse.results[0])); + } catch (error) { + console.log(error); + res.writeHead(400); + res.end(JSON.stringify({ message: 'Failed to transfer to user' })); } - } catch (error) { console.log(error); - return res.status(400).json({ message: 'Failed to mint to user' }); + res.writeHead(400); + res.end(JSON.stringify({ message: 'Failed to mint to user' })); } -}, -); +}); app.use('/', router); http.createServer(app).listen( 3000, () => console.log('Listening on port 3000'), -); \ No newline at end of file +); + +/** + * Helper function to get the next token id for a collection + */ +export const nextTokenId = async ( + collectionAddress: string, + imxClient: x.IMXClient +) => { + try { + let remaining = 0; + let cursor: string | undefined; + let tokenId = 0; + + do { + // eslint-disable-next-line no-await-in-loop + const assets = await imxClient.listAssets({ + collection: collectionAddress, + cursor, + }); + remaining = assets.remaining; + cursor = assets.cursor; + + for (const asset of assets.result) { + const id = parseInt(asset.token_id, 10); + if (id > tokenId) { + tokenId = id; + } + } + } while (remaining > 0); + + return tokenId + 1; + } catch (error) { + return 0; + } +}; \ No newline at end of file diff --git a/mint-backend/tsconfig.json b/mint-backend/tsconfig.json index c8b808b5b..6fd82388b 100644 --- a/mint-backend/tsconfig.json +++ b/mint-backend/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2020", - "module": "Node16", + "module": "CommonJS", "esModuleInterop": true, "outDir": "./dest", "skipLibCheck": true, From f7211772bd9397c588f33b8ec2bf95870c6a6218 Mon Sep 17 00:00:00 2001 From: Kai Hirota <34954529+kaihirota@users.noreply.github.com> Date: Wed, 23 Oct 2024 13:47:13 +1100 Subject: [PATCH 2/3] add client ID --- Assets/Shared/Scripts/UI/MainMenu.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Shared/Scripts/UI/MainMenu.cs b/Assets/Shared/Scripts/UI/MainMenu.cs index b31b3ded5..62e1d5251 100644 --- a/Assets/Shared/Scripts/UI/MainMenu.cs +++ b/Assets/Shared/Scripts/UI/MainMenu.cs @@ -38,7 +38,7 @@ async void OnEnable() m_LogoutButton.AddListener(OnLogoutButtonClick); // Initialise Passport - string clientId = "YOUR_IMMUTABLE_CLIENT_ID"; + string clientId = "MnIdiF95fTw4vsyGJGHGdbjxnKZV5lfG"; string environment = Immutable.Passport.Model.Environment.SANDBOX; passport = await Passport.Init(clientId, environment); From 7c7bacd8b249a00c65befc229bde1e8c15b42360 Mon Sep 17 00:00:00 2001 From: Kai Hirota <34954529+kaihirota@users.noreply.github.com> Date: Wed, 23 Oct 2024 13:54:37 +1100 Subject: [PATCH 3/3] add dependencies --- Packages/manifest.json | 2 ++ Packages/packages-lock.json | 26 +++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Packages/manifest.json b/Packages/manifest.json index 8e0ea5080..46170b4f9 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -1,5 +1,7 @@ { "dependencies": { + "com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask", + "com.immutable.passport": "https://github.com/immutable/unity-immutable-sdk.git?path=/src/Packages/Passport", "com.unity.collab-proxy": "2.0.4", "com.unity.ide.rider": "3.0.21", "com.unity.ide.visualstudio": "2.0.18", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index c439fc2fa..7038da7d9 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -1,5 +1,22 @@ { "dependencies": { + "com.cysharp.unitask": { + "version": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask", + "depth": 0, + "source": "git", + "dependencies": {}, + "hash": "f9fd769be7c634610f2a61aa914a1a55f34740e1" + }, + "com.immutable.passport": { + "version": "https://github.com/immutable/unity-immutable-sdk.git?path=/src/Packages/Passport", + "depth": 0, + "source": "git", + "dependencies": { + "com.unity.nuget.newtonsoft-json": "3.2.0", + "com.cysharp.unitask": "2.3.3" + }, + "hash": "b8ffae9b07a1340f091336abc2b77c5b0dbbdf7e" + }, "com.unity.burst": { "version": "1.8.4", "depth": 1, @@ -81,6 +98,13 @@ "dependencies": {}, "url": "https://packages.unity.com" }, + "com.unity.nuget.newtonsoft-json": { + "version": "3.2.0", + "depth": 1, + "source": "registry", + "dependencies": {}, + "url": "https://packages.unity.com" + }, "com.unity.postprocessing": { "version": "3.2.2", "depth": 0, @@ -159,9 +183,9 @@ "depth": 0, "source": "registry", "dependencies": { + "com.unity.modules.audio": "1.0.0", "com.unity.modules.director": "1.0.0", "com.unity.modules.animation": "1.0.0", - "com.unity.modules.audio": "1.0.0", "com.unity.modules.particlesystem": "1.0.0" }, "url": "https://packages.unity.com"